diff --git a/CHANGELOG.md b/CHANGELOG.md index f4e4df0..1c91ab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,40 @@ +## 2.2.0 - April 2026 +### Transitive Dependency Visibility + +#### Transitive Dependency Resolution +- Dependency Health view now resolves the complete dependency set (direct and transitive) by parsing lockfiles and manifests directly. +- Ecosystems with lockfiles (npm, Yarn, pnpm, Python/Poetry/uv, Rust, Ruby, Go, NuGet, Dart, PHP, Helm, Swift, Hex) resolve the full transitive tree automatically. +- Maven and Gradle resolve direct dependencies from pom.xml and build.gradle. The extension prompts when a tree file is not found. +- Docker and Helm resolve direct dependencies (base images and chart dependencies). +- Summary bar shows total dependency count with direct/transitive breakdown and per-ecosystem composition for multi-ecosystem projects. + +#### Vulnerability, License, and Policy Overlays +- Resolved dependencies found in Cloudsmith are checked for known vulnerabilities with severity indicators displayed inline. +- License classification (permissive, weak copyleft, restrictive) and policy compliance status shown for all covered dependencies. +- Summary bar aggregates vulnerability counts by severity, restrictive license count, and policy violation count. + +#### Upstream Proxy Gap Analysis +- Dependencies not found in Cloudsmith are checked against configured upstream proxies across all repositories. +- Each uncovered dependency shows whether it's reachable via an existing upstream or requires a new proxy to be configured. + +#### Tree Visualization +- New tree view mode displays the full dependency hierarchy with collapsible parent-child relationships. +- Diamond dependencies collapsed on subsequent occurrences to prevent exponential tree growth. +- Filters prune the tree to show only vulnerable, uncovered, or restrictive-license dependency paths. +- Three-way view toggle: direct only, all (flat), or all (tree). + +#### Pull Dependencies Through Upstream +- New pull action caches uncovered dependencies through a selected repository's upstream proxy directly from the editor. +- Repository selector shows only repositories with upstream proxies matching the project's dependency formats. +- Right-click any individual uncovered dependency to pull just that package through an upstream. + +#### Compliance Report +- New report view opens a dependency health summary in a dedicated editor panel with coverage, vulnerability, license, policy, and upstream gap analysis. + +#### UX Improvements +- Toolbar consolidated to five inline actions: scan, pull, view mode cycle, sort and filter, and compliance report. +- Format-specific icons displayed on uncovered dependencies for at-a-glance ecosystem identification. + ## 2.1.1 - April 2026 ### Fixed - Fixed erroneous error banner displaying in the Upstream Webview when all upstream data loaded successfully. diff --git a/README.md b/README.md index 8d05fbe..f008842 100644 --- a/README.md +++ b/README.md @@ -80,12 +80,12 @@ The right-click menu provides access to the following commands, varying dependin - **Inspect package** — View the full raw JSON API response for the package. - **Copy Install Command** — Copy the installation command for the package to the clipboard. -- **Show Install Command** - Show the installation command for the package. -- **Show vulnerabilities** - Open a webview showing the vulnerabilities report for a package. -- **View package in Cloudsmith** - Open the package page in the Cloudsmith web UI for the configured workspace. -- **Promote Package** - Promote the package between configured repositories. -- **Show Promotion Status** - Show the current status of the package promotion request. -- **Find safe version** - Show possible safe versions of the package within Cloudsmith for quick remediation. +- **Show Install Command** - Show the installation command for the package. +- **Show vulnerabilities** - Open a webview showing the vulnerabilities report for a package. +- **View package in Cloudsmith** - Open the package page in the Cloudsmith web UI for the configured workspace. +- **Promote Package** - Promote the package between configured repositories. +- **Show Promotion Status** - Show the current status of the package promotion request. +- **Find safe version** - Show possible safe versions of the package within Cloudsmith for quick remediation. contextMenu @@ -109,6 +109,54 @@ If you have access to multiple workspaces, the explorer lets you switch between View vulnerability data associated with packages directly in the explorer, including security scan results when available. +### Dependency Health + +The Dependency Health view scans your project's manifest and lockfiles, cross-references every declared and transitive dependency against your Cloudsmith workspace, and shows coverage, vulnerability, license, and policy status at a glance. + +#### Transitive Resolution + +The extension parses lockfiles and manifests directly. Most ecosystems resolve the full dependency tree from an existing lockfile. For ecosystems without a standard lockfile, the extension parses the manifest for direct dependencies and can optionally parse a generated dependency tree for transitives. + +| Ecosystem | Automatic (lockfile) | Direct only (manifest) | Notes | +|-----------|---------------------|----------------------|-------| +| npm / Yarn / pnpm | package-lock.json, yarn.lock, pnpm-lock.yaml | package.json | | +| Python | poetry.lock, uv.lock, Pipfile.lock | pyproject.toml, requirements.txt | requirements.txt provides direct deps only | +| Maven | | pom.xml | Run `mvn dependency:tree -DoutputFile=dependency-tree.txt` once to enable transitive resolution | +| Gradle | gradle.lockfile | build.gradle, build.gradle.kts | Run `gradle dependencies` once if dependency locking is not enabled | +| Go | go.mod | | go.mod marks direct vs indirect natively | +| Rust | Cargo.lock | Cargo.toml | | +| Ruby | Gemfile.lock | Gemfile | | +| Docker | | Dockerfile, docker-compose.yml | All dependencies are direct (base images) | +| NuGet | packages.lock.json | | | +| Dart | pubspec.lock | | | +| PHP | composer.lock | | | +| Helm | Chart.lock | | Helm dependencies are all direct | +| Swift | Package.resolved | | | +| Elixir | mix.lock | | | + +#### View Modes + +- **Direct only** — shows only top-level manifest dependencies. +- **All (flat)** — shows every resolved dependency in a flat list with direct/transitive labels. +- **All (tree)** — shows the full dependency hierarchy. Diamond dependencies are collapsed to keep the tree manageable. + +#### Overlays + +Each dependency found in Cloudsmith is enriched with: +- **Vulnerability status** — severity count and max severity inline, with click-through to CVE details. +- **License classification** — permissive, weak copyleft, or restrictive, with configurable flagging. +- **Policy compliance** — quarantine and policy violation indicators. + +Dependencies not found in Cloudsmith show upstream proxy reachability — whether a configured upstream could serve them. + +#### Pull Through Upstream + +Click "Pull dependencies" to cache uncovered dependencies through a repository's upstream proxy. The extension shows only repositories with matching upstream formats, pulls in parallel, and automatically rescans after completion. You can also right-click any individual dependency to pull just that one package. + +#### Compliance Report + +The report view opens a styled summary panel with coverage percentage, vulnerability breakdown by severity, license risk summary, policy compliance, and upstream gap analysis. + ### Configuration & Settings The extension exposes several settings under `cloudsmith-vsc.*`: @@ -121,13 +169,16 @@ The extension exposes several settings under `cloudsmith-vsc.*`: | `cloudsmith-vsc.defaultWorkspace` | Cloudsmith workspace slug to load by default. Leave empty to show all accessible workspaces. | | `cloudsmith-vsc.showPermissibilityIndicators` | Show visual indicators for quarantined packages and policy violations. Default: `true`. | | `cloudsmith-vsc.showLicenseIndicators` | Show license risk classification on packages. Default: `true`. | +| `cloudsmith-vsc.flagRestrictiveLicenses` | Color-code restrictive licenses in the Dependency Health view. Default: `true`. | +| `cloudsmith-vsc.restrictiveLicenses` | List of SPDX license identifiers flagged as restrictive. Default: `["AGPL-3.0", "GPL-2.0", "GPL-3.0", "SSPL-1.0"]`. | | `cloudsmith-vsc.showDockerDigestCommand` | Show an additional "Pull by digest" option for Docker install commands. Default: `false`. | | `cloudsmith-vsc.experimentalSSOBrowser` | Enable experimental browser-based SSO authentication. Default: `false`. | -| `cloudsmith-vsc.useLegacyWebApp` | Use the legacy `cloudsmith.io` webapp for platform links. Default: `false`. | | `cloudsmith-vsc.autoScanOnOpen` | Automatically scan project dependencies against Cloudsmith when a workspace is opened. Default: `false`. | | `cloudsmith-vsc.dependencyScanWorkspace` | Cloudsmith workspace slug to use for dependency health scanning. | | `cloudsmith-vsc.dependencyScanRepo` | Cloudsmith repository slug to use for dependency health scanning. | -| `cloudsmith-vsc.resolveTransitiveDependencies` | Resolve transitive (indirect) dependencies using the package manager CLI. Default: `false`. | +| `cloudsmith-vsc.resolveTransitiveDependencies` | Parse lockfiles to resolve transitive dependencies. When disabled, only direct manifest dependencies are shown. Default: `true`. | +| `cloudsmith-vsc.dependencyTreeDefaultView` | Default view mode for the Dependency Health panel: `direct`, `flat`, or `tree`. Default: `flat`. | +| `cloudsmith-vsc.maxDependenciesToScan` | Maximum number of dependencies to display. Pull operations always process all dependencies regardless of this limit. Default: `10000`. | | `cloudsmith-vsc.searchPageSize` | Number of results per page when searching packages (10–100). Default: `50`. | | `cloudsmith-vsc.recentSearches` | Number of recent searches to remember (0–50). Default: `10`. | @@ -145,8 +196,14 @@ All commands are available via the Command Palette (`Cmd+Shift+P`): | `Cloudsmith: Copy to Clipboard` | Copy a package detail value to the clipboard. | | `Cloudsmith: Refresh Packages` | Refresh the Cloudsmith explorer tree. | | `Cloudsmith: Search Packages` | Search for packages within a repository. | +| `Cloudsmith: Scan Dependencies` | Scan project lockfiles and check dependency coverage against Cloudsmith. | +| `Cloudsmith: Pull Dependencies` | Pull uncovered dependencies through a repository's upstream proxy. | +| `Cloudsmith: Pull Dependency` | Pull a single dependency through an upstream proxy (right-click context menu). | +| `Cloudsmith: View Compliance Report` | Open the dependency health compliance report in an editor panel. | +| `Cloudsmith: Cycle Dependency View` | Switch between direct, flat, and tree view modes. | +| `Cloudsmith: Sort and Filter Dependencies` | Open sort and filter options for the Dependency Health view. | ## License -Apache 2.0 +Apache 2.0 \ No newline at end of file diff --git a/extension.js b/extension.js index 3d7cb23..0e08aac 100644 --- a/extension.js +++ b/extension.js @@ -6,9 +6,14 @@ const { CloudsmithAPI } = require("./util/cloudsmithAPI"); const { CredentialManager } = require("./util/credentialManager"); const { RecentSearches } = require("./util/recentSearches"); const { RemediationHelper } = require("./util/remediationHelper"); -const { DependencyHealthProvider } = require("./views/dependencyHealthProvider"); +const { + DependencyHealthProvider, + FILTER_MODES, + SORT_MODES, +} = require("./views/dependencyHealthProvider"); const { InstallCommandBuilder } = require("./util/installCommandBuilder"); const { VulnerabilityProvider } = require("./views/vulnerabilityProvider"); +const { ComplianceReportProvider } = require("./views/complianceReportProvider"); const { QuarantineExplainProvider } = require("./views/quarantineExplainProvider"); const { DiagnosticsPublisher } = require("./util/diagnosticsPublisher"); const { SSOAuthManager } = require("./util/ssoAuthManager"); @@ -322,6 +327,223 @@ function buildPresetQuery(preset, customQuery) { return builder.build(); } +async function resolveDependencyScanTarget(context) { + const config = vscode.workspace.getConfiguration("cloudsmith-vsc"); + let scanWorkspace = config.get("dependencyScanWorkspace"); + let scanRepo = config.get("dependencyScanRepo") || null; + + if (!scanWorkspace) { + scanWorkspace = getDefaultWorkspace(); + } + + if (scanWorkspace) { + return { + scanWorkspace, + scanRepo, + }; + } + + const workspaces = await getWorkspaces(context); + if (!workspaces) { + return null; + } + if (workspaces.length === 0) { + vscode.window.showErrorMessage("No workspaces found. Connect to Cloudsmith first."); + return null; + } + + const selectedWorkspace = await vscode.window.showQuickPick( + workspaces.map((workspace) => ({ + label: workspace.name, + description: workspace.slug, + })), + { + placeHolder: "Select a Cloudsmith workspace for the scan", + } + ); + + if (!selectedWorkspace) { + return null; + } + + scanWorkspace = selectedWorkspace.description; + + const selectedScope = await vscode.window.showQuickPick( + [ + { + label: "All repositories", + description: "Search across the entire workspace", + _all: true, + }, + { + label: "Select a specific repository", + description: "Search one repository", + _all: false, + }, + ], + { + placeHolder: "Select a scan scope", + } + ); + + if (!selectedScope) { + return null; + } + + if (!selectedScope._all) { + const cloudsmithAPI = new CloudsmithAPI(context); + const repos = await cloudsmithAPI.get(`repos/${scanWorkspace}/?sort=name`); + if (typeof repos !== "string" && Array.isArray(repos) && repos.length > 0) { + const selectedRepo = await vscode.window.showQuickPick( + repos.map((repository) => ({ + label: repository.name, + description: repository.slug, + })), + { + placeHolder: "Select a repository", + } + ); + + if (selectedRepo) { + scanRepo = selectedRepo.description; + } + } + } + + return { + scanWorkspace, + scanRepo, + }; +} + +function buildDependencySortFilterItems(provider) { + const currentSort = provider.getSortMode(); + const currentFilter = provider.getFilterMode(); + return [ + { + label: "Sort", + kind: vscode.QuickPickItemKind.Separator, + }, + createDependencyPickerItem( + "Alphabetical", + "Default ordering", + "sort", + SORT_MODES.ALPHABETICAL, + currentSort === SORT_MODES.ALPHABETICAL + ), + createDependencyPickerItem( + "Severity", + "Most severe first", + "sort", + SORT_MODES.SEVERITY, + currentSort === SORT_MODES.SEVERITY + ), + createDependencyPickerItem( + "Coverage", + "Not found first", + "sort", + SORT_MODES.COVERAGE, + currentSort === SORT_MODES.COVERAGE + ), + { + label: "Filters", + kind: vscode.QuickPickItemKind.Separator, + }, + createDependencyPickerItem( + "Vulnerable only", + "Toggle vulnerable dependencies", + "filter", + FILTER_MODES.VULNERABLE, + currentFilter === FILTER_MODES.VULNERABLE + ), + createDependencyPickerItem( + "Not in Cloudsmith", + "Toggle uncovered dependencies", + "filter", + FILTER_MODES.UNCOVERED, + currentFilter === FILTER_MODES.UNCOVERED + ), + createDependencyPickerItem( + "Restrictive licenses", + "Toggle restrictive or weak copyleft results", + "filter", + FILTER_MODES.RESTRICTIVE_LICENSE, + currentFilter === FILTER_MODES.RESTRICTIVE_LICENSE + ), + createDependencyPickerItem( + "Policy violations", + "Toggle policy failures", + "filter", + FILTER_MODES.POLICY_VIOLATION, + currentFilter === FILTER_MODES.POLICY_VIOLATION + ), + createDependencyPickerItem( + "Show all dependencies", + "Clear active dependency filters", + "filter", + null, + currentFilter === null + ), + ]; +} + +function createDependencyPickerItem(label, description, action, value, active) { + return { + label: `${active ? "$(check)" : "$(circle-large-outline)"} ${label}`, + description, + _action: action, + _value: value, + }; +} + +async function showDependencySortFilterPicker(provider) { + await new Promise((resolve) => { + const quickPick = vscode.window.createQuickPick(); + const disposables = []; + + const refreshItems = () => { + quickPick.items = buildDependencySortFilterItems(provider); + }; + + quickPick.title = "Sort & filter dependencies"; + quickPick.matchOnDescription = true; + quickPick.ignoreFocusOut = true; + refreshItems(); + + disposables.push(quickPick.onDidAccept(async () => { + const selected = quickPick.selectedItems[0]; + if (!selected || !selected._action) { + quickPick.hide(); + return; + } + + quickPick.busy = true; + try { + if (selected._action === "sort") { + provider.setSortMode(selected._value); + } else if (selected._value === null || provider.getFilterMode() === selected._value) { + await provider.clearFilter(); + } else { + await provider.setFilterMode(selected._value); + } + refreshItems(); + } finally { + quickPick.busy = false; + } + })); + + disposables.push(quickPick.onDidHide(() => { + quickPick.dispose(); + for (const disposable of disposables) { + disposable.dispose(); + } + resolve(); + })); + + quickPick.show(); + }); +} + /** * @param {vscode.ExtensionContext} context @@ -386,7 +608,7 @@ async function activate(context) { const dependencyHealthProvider = new DependencyHealthProvider(context, diagnosticsPublisher); vscode.window.createTreeView("cloudsmithDependencyHealthView", { treeDataProvider: dependencyHealthProvider, - showCollapseAll: true, + showCollapseAll: false, }); context.subscriptions.push( @@ -413,6 +635,10 @@ async function activate(context) { const vulnerabilityProvider = new VulnerabilityProvider(context); context.subscriptions.push({ dispose: () => vulnerabilityProvider.dispose() }); + // Create compliance report WebView provider + const complianceReportProvider = new ComplianceReportProvider(context); + context.subscriptions.push({ dispose: () => complianceReportProvider.dispose() }); + // Create quarantine explanation WebView provider const quarantineExplainProvider = new QuarantineExplainProvider(context); context.subscriptions.push({ dispose: () => quarantineExplainProvider.dispose() }); @@ -447,28 +673,6 @@ async function activate(context) { void initializeConnectionContext(); - // Auto-scan dependencies on open if configured - const autoScanConfig = vscode.workspace.getConfiguration("cloudsmith-vsc"); - if (autoScanConfig.get("autoScanOnOpen")) { - const scanWorkspace = autoScanConfig.get("dependencyScanWorkspace"); - if (scanWorkspace) { - const scanRepo = autoScanConfig.get("dependencyScanRepo") || null; - // Delay to avoid blocking VS Code startup - setTimeout(() => { - dependencyHealthProvider.scan(scanWorkspace, scanRepo); - }, 2000); - } else { - vscode.window.showInformationMessage( - "Auto-scan is enabled but no Cloudsmith workspace is configured.", - "Configure" - ).then((selection) => { - if (selection === "Configure") { - vscode.commands.executeCommand("workbench.action.openSettings", "cloudsmith-vsc.dependencyScanWorkspace"); - } - }); - } - } - // Shared post-authentication handler: connect, refresh all views, and prompt // to set default workspace if only one workspace is available. @@ -1331,6 +1535,14 @@ async function activate(context) { await vulnerabilityProvider.show(item); }), + vscode.commands.registerCommand("cloudsmith-vsc.showDepVulnerabilities", async (item) => { + await vscode.commands.executeCommand("cloudsmith-vsc.showVulnerabilities", item); + }), + + vscode.commands.registerCommand("cloudsmith-vsc.findDepSafeVersion", async (item) => { + await vscode.commands.executeCommand("cloudsmith-vsc.findSafeVersion", item); + }), + // Register vulnerability filter command — updates a summary node in-place vscode.commands.registerCommand("cloudsmith-vsc.filterVulnerabilities", async (vulnSummaryNode) => { if (!vulnSummaryNode || @@ -1423,99 +1635,99 @@ async function activate(context) { // Register scan dependencies command vscode.commands.registerCommand("cloudsmith-vsc.scanDependencies", async () => { - const config = vscode.workspace.getConfiguration("cloudsmith-vsc"); - let scanWorkspace = config.get("dependencyScanWorkspace"); - let scanRepo = config.get("dependencyScanRepo") || null; + if (dependencyHealthProvider.lastWorkspace) { + await dependencyHealthProvider.rescan(); + return; + } - // If no dedicated scan workspace, try the default workspace setting - if (!scanWorkspace) { - scanWorkspace = getDefaultWorkspace(); + const scanTarget = await resolveDependencyScanTarget(context); + if (!scanTarget) { + return; } - // If still no workspace, prompt user - if (!scanWorkspace) { - const workspaces = await getWorkspaces(context); - if (!workspaces) { - return; - } - if (workspaces.length === 0) { - vscode.window.showErrorMessage("No workspaces found. Connect to Cloudsmith first."); - return; - } + await dependencyHealthProvider.scan(scanTarget.scanWorkspace, scanTarget.scanRepo); + }), - const wsItems = workspaces.map(ws => ({ label: ws.name, description: ws.slug })); - const selectedWs = await vscode.window.showQuickPick(wsItems, { - placeHolder: "Select a Cloudsmith workspace for the scan", - }); - if (!selectedWs) { - return; - } - scanWorkspace = selectedWs.description; + vscode.commands.registerCommand("cloudsmith-vsc.scanDependenciesPending", async () => { + await vscode.commands.executeCommand("cloudsmith-vsc.scanDependencies"); + }), - // Optionally select a repo - const scopeItems = [ - { label: "All repositories", description: "Search across the entire workspace", _all: true }, - { label: "Select a specific repository", description: "Search one repository" }, - ]; - const selectedScope = await vscode.window.showQuickPick(scopeItems, { - placeHolder: "Select a scan scope", - }); - if (!selectedScope) { - return; - } + vscode.commands.registerCommand("cloudsmith-vsc.scanDependenciesComplete", async () => { + await vscode.commands.executeCommand("cloudsmith-vsc.scanDependencies"); + }), - if (!selectedScope._all) { - const cloudsmithAPI = new CloudsmithAPI(context); - const repos = await cloudsmithAPI.get(`repos/${scanWorkspace}/?sort=name`); - if (typeof repos !== "string" && Array.isArray(repos) && repos.length > 0) { - const repoItems = repos.map(r => ({ label: r.name, description: r.slug })); - const selectedRepo = await vscode.window.showQuickPick(repoItems, { - placeHolder: "Select a repository", - }); - if (selectedRepo) { - scanRepo = selectedRepo.description; - } - } - } - } + vscode.commands.registerCommand("cloudsmith-vsc.pullDependencies", async () => { + await dependencyHealthProvider.pullDependencies(); + }), - // Resolve project folder: stored path > workspace folder > prompt - // The provider handles the prompt internally if no folder is available - await dependencyHealthProvider.scan(scanWorkspace, scanRepo); + vscode.commands.registerCommand("cloudsmith-vsc.pullSingleDependency", async (item) => { + await dependencyHealthProvider.pullSingleDependency(item); }), - // Register rescan dependencies command - vscode.commands.registerCommand("cloudsmith-vsc.rescanDependencies", async () => { - await dependencyHealthProvider.rescan(); + vscode.commands.registerCommand("cloudsmith-vsc.cycleDepView", async () => { + await dependencyHealthProvider.cycleViewMode(); }), - // Register change dependency folder command - vscode.commands.registerCommand("cloudsmith-vsc.changeDependencyFolder", async () => { - const selected = await vscode.window.showOpenDialog({ - canSelectFolders: true, - canSelectFiles: false, - canSelectMany: false, - openLabel: "Select project folder to scan", - }); + vscode.commands.registerCommand("cloudsmith-vsc.cycleDepViewDirect", async () => { + await vscode.commands.executeCommand("cloudsmith-vsc.cycleDepView"); + }), - if (!selected || selected.length === 0) { - return; - } + vscode.commands.registerCommand("cloudsmith-vsc.cycleDepViewFlat", async () => { + await vscode.commands.executeCommand("cloudsmith-vsc.cycleDepView"); + }), - dependencyHealthProvider.setProjectFolder(selected[0].fsPath); + vscode.commands.registerCommand("cloudsmith-vsc.cycleDepViewTree", async () => { + await vscode.commands.executeCommand("cloudsmith-vsc.cycleDepView"); + }), - // Re-run scan if we have a previous workspace context - if (dependencyHealthProvider.lastWorkspace) { - await dependencyHealthProvider.scan( - dependencyHealthProvider.lastWorkspace, - dependencyHealthProvider.lastRepo - ); - } else { - vscode.window.showInformationMessage( - `Project folder set to ${selected[0].fsPath}. Run "Scan dependencies" to check against Cloudsmith.` - ); - dependencyHealthProvider.refresh(); + vscode.commands.registerCommand("cloudsmith-vsc.depViewDirect", async () => { + await dependencyHealthProvider.setViewMode("direct"); + }), + + vscode.commands.registerCommand("cloudsmith-vsc.depViewFlat", async () => { + await dependencyHealthProvider.setViewMode("flat"); + }), + + vscode.commands.registerCommand("cloudsmith-vsc.depViewTree", async () => { + await dependencyHealthProvider.setViewMode("tree"); + }), + + vscode.commands.registerCommand("cloudsmith-vsc.depFilterVulnerable", async () => { + await dependencyHealthProvider.setFilterMode(FILTER_MODES.VULNERABLE); + }), + + vscode.commands.registerCommand("cloudsmith-vsc.depFilterUncovered", async () => { + await dependencyHealthProvider.setFilterMode(FILTER_MODES.UNCOVERED); + }), + + vscode.commands.registerCommand("cloudsmith-vsc.depFilterRestrictiveLicense", async () => { + await dependencyHealthProvider.setFilterMode(FILTER_MODES.RESTRICTIVE_LICENSE); + }), + + vscode.commands.registerCommand("cloudsmith-vsc.depFilterPolicyViolation", async () => { + await dependencyHealthProvider.setFilterMode(FILTER_MODES.POLICY_VIOLATION); + }), + + vscode.commands.registerCommand("cloudsmith-vsc.depFilterClear", async () => { + await dependencyHealthProvider.clearFilter(); + }), + + vscode.commands.registerCommand("cloudsmith-vsc.depSortFilter", async () => { + await showDependencySortFilterPicker(dependencyHealthProvider); + }), + + vscode.commands.registerCommand("cloudsmith-vsc.depSortFilterActive", async () => { + await vscode.commands.executeCommand("cloudsmith-vsc.depSortFilter"); + }), + + vscode.commands.registerCommand("cloudsmith-vsc.viewComplianceReport", async () => { + const reportData = dependencyHealthProvider.getReportData(); + if (!reportData) { + vscode.window.showInformationMessage("Run a dependency scan before opening the report."); + return; } + + complianceReportProvider.show(reportData); }), // Register copy install command diff --git a/models/packageNode.js b/models/packageNode.js index b378815..6d44ce6 100644 --- a/models/packageNode.js +++ b/models/packageNode.js @@ -1,8 +1,8 @@ // Package node treeview const vscode = require("vscode"); -const path = require("path"); const { LicenseClassifier } = require("../util/licenseClassifier"); +const { getFormatIconPath } = require("../util/formatIcons"); class PackageNode { constructor(pkg, context) { @@ -117,8 +117,7 @@ class PackageNode { if (format === "raw") { return new vscode.ThemeIcon("file-binary"); } - const iconURI = "file_type_" + format + ".svg"; - return path.join(__filename, "..", "..", "media", "vscode_icons", iconURI); + return getFormatIconPath(format, this.context && this.context.extensionPath); } _buildTooltip() { diff --git a/package.json b/package.json index 834a64d..d1cbbfd 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "publisher": "Cloudsmith", "displayName": "Cloudsmith VS Code", "description": "Access packages from a Cloudsmith instance.", - "version": "2.1.1", + "version": "2.2.0", "license": "SEE LICENSE IN LICENSE", "homepage": "https://github.com/cloudsmith-io/cloudsmith-vscode-extension/blob/main/README.md", "bugs": { @@ -172,6 +172,16 @@ "title": "Show vulnerabilities", "category": "Cloudsmith" }, + { + "command": "cloudsmith-vsc.showDepVulnerabilities", + "title": "Show vulnerabilities", + "category": "Cloudsmith" + }, + { + "command": "cloudsmith-vsc.findDepSafeVersion", + "title": "Find safe version", + "category": "Cloudsmith" + }, { "command": "cloudsmith-vsc.filterVulnerabilities", "title": "Filter vulnerabilities", @@ -190,16 +200,118 @@ "icon": "$(play)" }, { - "command": "cloudsmith-vsc.rescanDependencies", + "command": "cloudsmith-vsc.scanDependenciesPending", + "title": "Scan dependencies", + "category": "Cloudsmith", + "icon": "$(play)" + }, + { + "command": "cloudsmith-vsc.scanDependenciesComplete", "title": "Rescan dependencies", "category": "Cloudsmith", - "icon": "$(refresh)" + "icon": "$(play)" }, { - "command": "cloudsmith-vsc.changeDependencyFolder", - "title": "Change project folder", + "command": "cloudsmith-vsc.pullDependencies", + "title": "Pull dependencies", "category": "Cloudsmith", - "icon": "$(folder-opened)" + "icon": "$(cloud-download)" + }, + { + "command": "cloudsmith-vsc.pullSingleDependency", + "title": "Pull Dependency", + "category": "Cloudsmith", + "icon": "$(cloud-download)" + }, + { + "command": "cloudsmith-vsc.cycleDepView", + "title": "Cycle dependency view", + "category": "Cloudsmith", + "icon": "$(symbol-enum)" + }, + { + "command": "cloudsmith-vsc.cycleDepViewDirect", + "title": "View: Direct only (click to switch)", + "category": "Cloudsmith", + "icon": "$(list-selection)" + }, + { + "command": "cloudsmith-vsc.cycleDepViewFlat", + "title": "View: Flat list (click to switch)", + "category": "Cloudsmith", + "icon": "$(list-unordered)" + }, + { + "command": "cloudsmith-vsc.cycleDepViewTree", + "title": "View: Dependency tree (click to switch)", + "category": "Cloudsmith", + "icon": "$(list-tree)" + }, + { + "command": "cloudsmith-vsc.depViewDirect", + "title": "Show direct dependencies", + "category": "Cloudsmith", + "icon": "$(list-selection)" + }, + { + "command": "cloudsmith-vsc.depViewFlat", + "title": "Show all dependencies (flat)", + "category": "Cloudsmith", + "icon": "$(list-unordered)" + }, + { + "command": "cloudsmith-vsc.depViewTree", + "title": "Show dependency tree", + "category": "Cloudsmith", + "icon": "$(list-tree)" + }, + { + "command": "cloudsmith-vsc.depFilterVulnerable", + "title": "Show only vulnerable", + "category": "Cloudsmith", + "icon": "$(warning)" + }, + { + "command": "cloudsmith-vsc.depFilterUncovered", + "title": "Show only not in Cloudsmith", + "category": "Cloudsmith", + "icon": "$(package)" + }, + { + "command": "cloudsmith-vsc.depFilterRestrictiveLicense", + "title": "Show only restrictive licenses", + "category": "Cloudsmith", + "icon": "$(law)" + }, + { + "command": "cloudsmith-vsc.depFilterPolicyViolation", + "title": "Show only policy violations", + "category": "Cloudsmith", + "icon": "$(shield)" + }, + { + "command": "cloudsmith-vsc.depFilterClear", + "title": "Clear dependency filters", + "category": "Cloudsmith", + "icon": "$(clear-all)" + }, + { + "command": "cloudsmith-vsc.depSortFilter", + "title": "Sort & filter dependencies", + "category": "Cloudsmith", + "icon": "$(filter)" + }, + { + "command": "cloudsmith-vsc.depSortFilterActive", + "title": "Sort & filter dependencies", + "category": "Cloudsmith", + "icon": "$(filter-filled)" + }, + { + "command": "cloudsmith-vsc.viewComplianceReport", + "title": "View compliance report", + "category": "Cloudsmith", + "icon": "$(graph)" }, { "command": "cloudsmith-vsc.copyInstallCommand", @@ -347,12 +459,12 @@ }, { "command": "cloudsmith-vsc.inspectPackage", - "when": "view == cloudsmithDependencyHealthView && viewItem =~ /^(dependencyHealth|dependencyHealthBlocked|dependencyHealthViolated|dependencyHealthSyncing)$/", + "when": "view == cloudsmithDependencyHealthView && viewItem =~ /^(dependencyHealthFound|dependencyHealthVulnerable|dependencyHealthQuarantined|dependencyHealthSyncing)$/", "group": "navigation" }, { "command": "cloudsmith-vsc.openPackage", - "when": "view == cloudsmithDependencyHealthView && viewItem =~ /^(dependencyHealth|dependencyHealthBlocked|dependencyHealthViolated|dependencyHealthSyncing)$/", + "when": "view == cloudsmithDependencyHealthView && viewItem =~ /^(dependencyHealthFound|dependencyHealthVulnerable|dependencyHealthQuarantined|dependencyHealthSyncing)$/", "group": "navigation" }, { @@ -417,7 +529,7 @@ }, { "command": "cloudsmith-vsc.findSafeVersion", - "when": "viewItem =~ /^(package|packageQuarantined|dependencyHealthBlocked|dependencyHealthViolated)$/", + "when": "viewItem =~ /^(package|packageQuarantined)$/", "group": "navigation" }, { @@ -427,7 +539,17 @@ }, { "command": "cloudsmith-vsc.showVulnerabilities", - "when": "viewItem =~ /^(package|packageQuarantined|dependencyHealthBlocked|dependencyHealthViolated)$/", + "when": "viewItem =~ /^(package|packageQuarantined)$/", + "group": "navigation" + }, + { + "command": "cloudsmith-vsc.showDepVulnerabilities", + "when": "view == cloudsmithDependencyHealthView && viewItem == dependencyHealthVulnerable", + "group": "navigation" + }, + { + "command": "cloudsmith-vsc.findDepSafeVersion", + "when": "view == cloudsmithDependencyHealthView && viewItem == dependencyHealthVulnerable", "group": "navigation" }, { @@ -442,17 +564,17 @@ }, { "command": "cloudsmith-vsc.explainQuarantine", - "when": "viewItem =~ /^(packageQuarantined|dependencyHealthBlocked)$/", + "when": "viewItem =~ /^(packageQuarantined|dependencyHealthQuarantined)$/", "group": "navigation" }, { "command": "cloudsmith-vsc.copyInstallCommand", - "when": "viewItem =~ /^(package|dependencyHealth|dependencyHealthViolated)$/", + "when": "viewItem =~ /^(package|dependencyHealthFound|dependencyHealthVulnerable)$/", "group": "navigation" }, { "command": "cloudsmith-vsc.showInstallCommand", - "when": "viewItem =~ /^(package|dependencyHealth|dependencyHealthViolated)$/", + "when": "viewItem =~ /^(package|dependencyHealthFound|dependencyHealthVulnerable)$/", "group": "navigation" }, { @@ -467,9 +589,19 @@ }, { "command": "cloudsmith-vsc.previewUpstreamResolution", - "when": "view == cloudsmithDependencyHealthView && viewItem == dependencyHealthNotFound", + "when": "view == cloudsmithDependencyHealthView && viewItem =~ /^(dependencyHealthMissing|dependencyHealthUpstreamReachable|dependencyHealthUpstreamUnreachable)$/", "group": "navigation" }, + { + "command": "cloudsmith-vsc.pullSingleDependency", + "when": "view == cloudsmithDependencyHealthView && viewItem =~ /^(dependencyHealthMissing|dependencyHealthUpstreamReachable)$/", + "group": "inline" + }, + { + "command": "cloudsmith-vsc.pullSingleDependency", + "when": "view == cloudsmithDependencyHealthView && viewItem =~ /^(dependencyHealthMissing|dependencyHealthUpstreamReachable)$/", + "group": "1_pull" + }, { "command": "cloudsmith-vsc.showPromotionStatus", "when": "view == cloudsmithView && viewItem == package", @@ -563,18 +695,63 @@ "when": "view == cloudsmithSearchView" }, { - "command": "cloudsmith-vsc.scanDependencies", - "group": "navigation", + "command": "cloudsmith-vsc.scanDependenciesPending", + "group": "navigation@1", + "when": "view == cloudsmithDependencyHealthView && !cloudsmith.depScanComplete" + }, + { + "command": "cloudsmith-vsc.scanDependenciesComplete", + "group": "navigation@1", + "when": "view == cloudsmithDependencyHealthView && cloudsmith.depScanComplete" + }, + { + "command": "cloudsmith-vsc.pullDependencies", + "group": "navigation@2", + "when": "view == cloudsmithDependencyHealthView && cloudsmith.depScanComplete" + }, + { + "command": "cloudsmith-vsc.cycleDepViewDirect", + "group": "navigation@3", + "when": "view == cloudsmithDependencyHealthView && cloudsmith.depViewMode == 'direct'" + }, + { + "command": "cloudsmith-vsc.cycleDepViewFlat", + "group": "navigation@3", + "when": "view == cloudsmithDependencyHealthView && cloudsmith.depViewMode == 'flat'" + }, + { + "command": "cloudsmith-vsc.cycleDepViewTree", + "group": "navigation@3", + "when": "view == cloudsmithDependencyHealthView && cloudsmith.depViewMode == 'tree'" + }, + { + "command": "cloudsmith-vsc.depSortFilter", + "group": "navigation@4", + "when": "view == cloudsmithDependencyHealthView && !cloudsmith.depFilterActive" + }, + { + "command": "cloudsmith-vsc.depSortFilterActive", + "group": "navigation@4", + "when": "view == cloudsmithDependencyHealthView && cloudsmith.depFilterActive" + }, + { + "command": "cloudsmith-vsc.viewComplianceReport", + "group": "navigation@5", + "when": "view == cloudsmithDependencyHealthView && cloudsmith.depScanComplete" + }, + { + "command": "cloudsmith-vsc.depViewDirect", + "group": "view@1", "when": "view == cloudsmithDependencyHealthView" }, { - "command": "cloudsmith-vsc.rescanDependencies", - "group": "navigation", + "command": "cloudsmith-vsc.depViewFlat", + "group": "view@2", "when": "view == cloudsmithDependencyHealthView" }, { - "command": "cloudsmith-vsc.changeDependencyFolder", - "group": "navigation", + "command": "cloudsmith-vsc.depViewTree", + "group": "view@3", "when": "view == cloudsmithDependencyHealthView" } ] @@ -645,15 +822,20 @@ }, "cloudsmith-vsc.maxDependenciesToScan": { "type": "integer", - "default": 200, + "default": 10000, "minimum": 1, - "description": "Maximum number of dependencies to scan before truncating a dependency health run." + "description": "Maximum number of dependencies to display in the Dependency Health view. Pull operations always process all resolved dependencies." }, "cloudsmith-vsc.showLicenseIndicators": { "type": "boolean", "default": true, "description": "Show license risk classification on packages. When disabled, license nodes are hidden." }, + "cloudsmith-vsc.flagRestrictiveLicenses": { + "type": "boolean", + "default": true, + "description": "Highlight restrictive licenses inline in the Dependency Health view." + }, "cloudsmith-vsc.showDockerDigestCommand": { "type": "boolean", "default": false, @@ -666,8 +848,14 @@ }, "cloudsmith-vsc.resolveTransitiveDependencies": { "type": "boolean", - "default": false, - "description": "Use the package manager CLI to resolve transitive dependencies. Requires the package manager and installed project dependencies." + "default": true, + "description": "Parse lockfiles to resolve transitive dependencies. When disabled, only direct manifest dependencies are shown." + }, + "cloudsmith-vsc.dependencyTreeDefaultView": { + "type": "string", + "enum": ["direct", "flat", "tree"], + "default": "flat", + "description": "Default view mode for the Dependency Health panel." }, "cloudsmith-vsc.showLegacyPolicies": { "type": "boolean", diff --git a/test/complianceReportProvider.test.js b/test/complianceReportProvider.test.js new file mode 100644 index 0000000..583c2f3 --- /dev/null +++ b/test/complianceReportProvider.test.js @@ -0,0 +1,150 @@ +const assert = require("assert"); +const vscode = require("vscode"); +const { ComplianceReportProvider } = require("../views/complianceReportProvider"); +const { buildComplianceReportData } = require("../views/dependencyHealthProvider"); + +suite("ComplianceReportProvider", () => { + test("report data and HTML escape dynamic content", () => { + const dependencies = [ + { + name: "evil'\"", + version: "1.0.0'\"", + format: "npm", + ecosystem: "npm", + isDirect: true, + cloudsmithStatus: "FOUND", + cloudsmithPackage: { + repository: "prod", + status_str: "Completed", + license: "MIT", + }, + vulnerabilities: { + count: 2, + maxSeverity: "High", + severityCounts: { High: 2 }, + hasFixAvailable: true, + entries: [{ fixVersion: "1.0.1" }], + detailsLoaded: true, + }, + }, + { + name: "license-risk", + version: "2.0.0", + format: "npm", + ecosystem: "npm", + isDirect: false, + cloudsmithStatus: "FOUND", + cloudsmithPackage: { + repository: "prod", + status_str: "Completed", + license: "GPL-3.0", + }, + license: { + display: "GPL-3.0", + spdx: "GPL-3.0", + classification: "restrictive", + }, + }, + { + name: "policy-fail", + version: "3.0.0", + format: "pypi", + ecosystem: "pypi", + isDirect: true, + cloudsmithStatus: "FOUND", + cloudsmithPackage: { + repository: "prod", + status_str: "Quarantined", + }, + policy: { + violated: true, + denied: true, + quarantined: true, + status: "Quarantined", + statusReason: "Blocked by policy ", + }, + }, + { + name: "missing-lib", + version: "0.1.0", + format: "npm", + ecosystem: "npm", + isDirect: true, + cloudsmithStatus: "NOT_FOUND", + upstreamStatus: "reachable", + upstreamDetail: "proxy ", + }, + { + name: "missing-lib", + version: "0.1.0", + format: "npm", + ecosystem: "npm", + isDirect: false, + cloudsmithStatus: "NOT_FOUND", + upstreamStatus: "reachable", + upstreamDetail: "proxy ", + }, + ]; + + const reportData = buildComplianceReportData("fixture ", dependencies, { + scanDate: "2026-04-05T12:30:00Z", + }); + + assert.strictEqual(reportData.summary.total, 4); + assert.strictEqual(reportData.summary.found, 3); + assert.strictEqual(reportData.summary.notFound, 1); + assert.strictEqual(reportData.summary.coveragePct, 75); + assert.strictEqual(reportData.summary.vulnCount, 1); + assert.strictEqual(reportData.summary.restrictiveLicenseCount, 1); + assert.strictEqual(reportData.summary.policyViolationCount, 1); + + const provider = new ComplianceReportProvider({}); + const html = provider._getHtml(reportData); + + assert.match(html, /fixture <app>/); + assert.match(html, /evil<script>alert\(1\)<\/script>'"/); + assert.match(html, /1\.0\.0'"/); + assert.match(html, /proxy <prod>/); + assert.match(html, /Blocked by policy <rule>/); + assert.doesNotMatch(html, /