Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions extensions/package-vulnerability-scanner/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Render markdown in vulnerability details using markdown-it (#204)
- Display language versions (Python, R, Quarto) for content items (#206)
- The counts of packages are now clickable giving the option to show all
packages, only Python packages, only R packages, or only vulnerable packages.
The list defaults to showing vulnerable packages. (#207)

### Changed

Expand Down
10 changes: 5 additions & 5 deletions extensions/package-vulnerability-scanner/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@
},
"packages": {},
"files": {
"dist/assets/index-D8PwKrP_.css": {
"checksum": "044d9467fefba864d607aec25d40b752"
"dist/assets/index-CmdH3BdM.css": {
"checksum": "133200803297edaaba634130f05ce6ac"
},
"dist/assets/index-DZg-mVL2.js": {
"checksum": "f2669c5c9adb7f51e92ca4703695fecb"
"dist/assets/index-Mo0hYq-0.js": {
"checksum": "a8cd717c6740df720243f5c5df7440a3"
},
"dist/index.html": {
"checksum": "2dcc09528bdcb3616a22cf8940867014"
"checksum": "4a7eba688b0cb49d2d5cc87c123fbcfd"
},
"main.py": {
"checksum": "f8385dbd8a8cd24204f1eb6209f8bb30"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,41 @@
import { useVulnsStore } from "../stores/vulns";
import { usePackagesStore } from "../stores/packages";
import { useContentStore } from "../stores/content";
import type { Vulnerability, VulnerabilityRange } from "../stores/vulns";
import type { Package } from "../stores/packages";
import { computed } from "vue";
import { computed, ref } from "vue";

// Import UI components
import LoadingSpinner from "./ui/LoadingSpinner.vue";
import StatusMessage from "./ui/StatusMessage.vue";

// Import vulnerability components
import StatsPanel from "./vulnerability/StatsPanel.vue";
import StatsPanel, { type FilterType } from "./vulnerability/StatsPanel.vue";
import EmptyState from "./vulnerability/EmptyState.vue";
import VulnerabilityList from "./vulnerability/VulnerabilityList.vue";
import PackageList from "./vulnerability/PackageList.vue";
import ArrowTopRight from "./icons/ArrowTopRight.vue";

interface VulnerablePackageItem {
packageInfo: Package;
vulnerabilities: Vulnerability[];
repo: "pypi" | "cran";
latestFixedVersion: string | null;
}
import type { PackageWithVulnsAndFix } from "../types";

const vulnStore = useVulnsStore();
const packagesStore = usePackagesStore();
const contentStore = useContentStore();

const packages = computed(() => {
const packages = computed((): PackageWithVulnsAndFix[] => {
// Get the current content's packages
const currentId = contentStore.currentContentId;
if (!currentId) return [];

const contentItem = packagesStore.contentItems[currentId];
return contentItem ? contentItem.packages : [];
const result = contentItem ? contentItem.packages : [];

return result.map((pkg): PackageWithVulnsAndFix => {
return {
package: pkg,
...vulnStore.getDetailsForPackageVersion(
pkg.name,
pkg.version,
pkg.language.toLowerCase() === "python" ? "pypi" : "cran",
),
};
});
});

// Track loading states
Expand Down Expand Up @@ -62,112 +65,16 @@ const hasPackages = computed(() => {
return packages.value.length > 0;
});

// Extract the fixed version from the vulnerability ranges data
function getFixedVersion(vuln: Vulnerability): string | null {
if (!vuln.ranges || !Array.isArray(vuln.ranges) || vuln.ranges.length === 0) {
return null;
}

let result: string | null = null;

const getFixedEventValue = (range: VulnerabilityRange): string | null => {
return range.events.find((e) => Boolean(e.fixed))?.fixed || null;
};

for (const range of vuln.ranges) {
if (range.type === "ECOSYSTEM" && range.events) {
return getFixedEventValue(range);
} else {
result = getFixedEventValue(range);
}
}

return result;
}

// Go back to content list
function goBack() {
contentStore.currentContentId = undefined;
scrollTo({ top: 0, left: 0, behavior: "instant" });
}

// Find vulnerable packages by comparing package data with vulnerability data
const vulnerablePackages = computed<VulnerablePackageItem[]>(() => {
if (isLoading.value || !packages.value.length || !vulnStore.isFetched)
return [];

// Use a Map to group vulnerabilities by package
const packageMap = new Map<
string,
{
packageInfo: Package;
vulnerabilities: Vulnerability[];
repo: "pypi" | "cran";
fixedVersions: string[];
}
>();

// Process each installed package
for (const pkg of packages.value) {
const packageId = `${pkg.name}@${pkg.version}`;
const repo = pkg.language.toLowerCase() === "python" ? "pypi" : "cran";
const vulnerabilityMap = repo === "pypi" ? vulnStore.pypi : vulnStore.cran;
const packageName = pkg.name.toLowerCase();

// If this package has known vulnerabilities
if (vulnerabilityMap[packageName]) {
// For each vulnerability associated with this package
for (const vuln of vulnerabilityMap[packageName]) {
// Check if the current package version is in the vulnerable versions
if (vuln.versions && vuln.versions[pkg.version]) {
const fixedVersion = getFixedVersion(vuln);

if (!packageMap.has(packageId)) {
packageMap.set(packageId, {
packageInfo: pkg,
vulnerabilities: [],
repo,
fixedVersions: [],
});
}

const packageData = packageMap.get(packageId)!;
packageData.vulnerabilities.push(vuln);

if (fixedVersion) {
packageData.fixedVersions.push(fixedVersion);
}
}
}
}
}

// Convert the Map to an array and determine the latest fixed version
return Array.from(packageMap.values()).map((item) => {
// Sort fixed versions semantically (assuming they are valid semver)
// This simple comparison works for most simple version formats
const sortedFixedVersions = [...item.fixedVersions].sort((a, b) => {
const aParts = a.split(".");
const bParts = b.split(".");

for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const aNum = parseInt(aParts[i] || "0", 10);
const bNum = parseInt(bParts[i] || "0", 10);
if (aNum !== bNum) {
return bNum - aNum; // Descending order (latest first)
}
}

return 0;
});

return {
packageInfo: item.packageInfo,
vulnerabilities: item.vulnerabilities,
repo: item.repo,
latestFixedVersion:
sortedFixedVersions.length > 0 ? sortedFixedVersions[0] : null,
};
const vulnerablePackages = computed((): PackageWithVulnsAndFix[] => {
return packages.value.filter((pkg) => {
return pkg.vulnerabilities && pkg.vulnerabilities.length > 0;
});
});

Expand All @@ -190,22 +97,57 @@ const contentTitle = computed(
);
const dashboardUrl = computed(() => contentInfo.value?.dashboard_url || null);

// Stats
const totalPackages = computed(() => packages.value.length);
const pythonPackages = computed(
() =>
packages.value.filter((p) => p.language.toLowerCase() === "python").length,
);
const rPackages = computed(
() => packages.value.filter((p) => p.language.toLowerCase() === "r").length,
);
const pythonPackages = computed((): PackageWithVulnsAndFix[] => {
if (isLoading.value || !packages.value.length) return [];
return packages.value.filter(
(p) => p.package.language.toLowerCase() === "python",
);
});

const rPackages = computed((): PackageWithVulnsAndFix[] => {
if (isLoading.value || !packages.value.length) return [];
return packages.value.filter((p) => p.package.language.toLowerCase() === "r");
});

// Total number of vulnerabilities (CVEs) across all packages
const totalVulnerabilities = computed(() => {
return vulnerablePackages.value.reduce((total, pkg) => {
return total + pkg.vulnerabilities.length;
return total + (pkg.vulnerabilities ? pkg.vulnerabilities.length : 0);
}, 0);
});

const activeFilter = ref<FilterType>("vulnerable");

const filterTitle = computed(() => {
switch (activeFilter.value) {
case "all":
return "All Packages";
case "python":
return "Python Packages";
case "r":
return "R Packages";
case "vulnerable":
return "Vulnerable Packages";
default:
return "Packages";
}
});

// Use the filtered arrays for the displayed packages
const filteredPackages = computed((): PackageWithVulnsAndFix[] => {
if (isLoading.value || !packages.value.length) return [];

switch (activeFilter.value) {
case "python":
return pythonPackages.value;
case "r":
return rPackages.value;
case "vulnerable":
return vulnerablePackages.value;
default:
return packages.value;
}
});
</script>

<template>
Expand Down Expand Up @@ -266,21 +208,27 @@ const totalVulnerabilities = computed(() => {
<!-- Content loaded successfully -->
<template v-else>
<StatsPanel
:totalPackages="totalPackages"
:pythonPackages="pythonPackages"
:rPackages="rPackages"
v-model="activeFilter"
:totalPackages="packages.length"
:pythonPackages="pythonPackages.length"
:rPackages="rPackages.length"
:vulnerabilities="totalVulnerabilities"
/>

<h3 class="text-lg text-gray-700 mb-4">{{ filterTitle }}</h3>

<EmptyState v-if="!hasPackages">
<p>This content has no packages. No vulnerabilities found.</p>
</EmptyState>

<EmptyState v-else-if="vulnerablePackages.length === 0">
<p>No vulnerabilities found.</p>
<EmptyState v-else-if="filteredPackages.length === 0">
<p v-if="activeFilter === 'vulnerable'">No vulnerabilities found.</p>
<p v-else-if="activeFilter === 'python'">No Python packages found.</p>
<p v-else-if="activeFilter === 'r'">No R packages found.</p>
<p v-else>No packages found.</p>
</EmptyState>

<VulnerabilityList v-else :vulnerablePackages="vulnerablePackages" />
<PackageList v-else :packages="filteredPackages" />
</template>
</div>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed } from "vue";

import PackageHeader from "./PackageHeader.vue";
import VulnerabilityDetails from "./VulnerabilityDetails.vue";
import type { Package } from "../../stores/packages";
import { type Vulnerability } from "../../stores/vulns";

const props = defineProps<{
package: Package;
vulnerabilities?: Vulnerability[];
latestFixedVersion?: string | null;
}>();

const repo = computed(() =>
props.package.language.toLowerCase() === "python" ? "pypi" : "cran",
);
</script>

<template>
<div>
<PackageHeader :package="package" :repo="repo" />

<template v-if="vulnerabilities && vulnerabilities.length > 0">
<p
v-if="vulnerabilities.length > 1"
class="text-sm font-medium text-gray-700 mb-3"
>
{{ vulnerabilities.length }} vulnerabilities found for this package
</p>

<div
v-if="latestFixedVersion"
class="bg-green-50 border-l-3 border-green-500 py-2 px-3 my-3 rounded-r-md"
>
<p class="m-0 text-[15px] text-green-700 flex items-center">
<span class="mr-2">🛠️</span>
<span
>To fix
{{
vulnerabilities.length > 1
? "these vulnerabilities"
: "this vulnerability"
}}, upgrade to version {{ latestFixedVersion }} or later.</span
>
</p>
</div>

<template
v-for="vulnerability in vulnerabilities"
:key="vulnerability.id"
>
<VulnerabilityDetails
:id="vulnerability.id"
class="not-last:mb-6"
:summary="vulnerability.summary"
:details="vulnerability.details"
:publishDate="vulnerability.published"
:modifiedDate="vulnerability.modified"
/>
</template>
</template>
</div>
</template>
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
<script setup lang="ts">
import type { Package } from "../../stores/packages";

defineProps<{
packageName: string;
packageVersion: string;
package: Package;
repo: "pypi" | "cran";
}>();
</script>

<template>
<div class="flex items-center mb-3 gap-2 font-mono">
<div class="flex items-center not-last:mb-3 gap-2 font-mono">
<h4 class="text-gray-800 text-lg m-0 font-semibold">
{{ packageName }}
{{ package.name }}
</h4>

<span class="text-gray-500">v{{ packageVersion }}</span>
<span class="text-gray-500">v{{ package.version }}</span>

<span
class="text-xs font-bold text-white py-1 px-2 rounded self-end ml-auto"
Expand Down
Loading