Skip to content

Commit 9db5a03

Browse files
committed
Add CVSS scores to Security tab
1 parent 55aa886 commit 9db5a03

File tree

14 files changed

+407
-42
lines changed

14 files changed

+407
-42
lines changed

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,10 @@ jobs:
223223
cache: pnpm
224224
node-version-file: package.json
225225

226+
- uses: taiki-e/install-action@735e5933943122c5ac182670a935f54a949265c1 # v2.52.4
227+
with:
228+
tool: wasm-pack
229+
226230
- run: pnpm install
227231

228232
- run: pnpm lint:hbs

Cargo.lock

Lines changed: 66 additions & 16 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/routes/crate/security.js

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,38 @@
11
import Route from '@ember/routing/route';
22
import { service } from '@ember/service';
33

4+
import { loadCvssModule, parseCvss } from 'crates-io/utils/cvss';
45
import { versionRanges } from 'crates-io/utils/version-ranges';
56

6-
function extractCvss(advisory) {
7+
async function extractCvssWithScore(advisory) {
78
// Prefer V4 over V3
89
let cvssEntry =
910
advisory.severity?.find(s => s.type === 'CVSS_V4') ?? advisory.severity?.find(s => s.type === 'CVSS_V3');
10-
return cvssEntry?.score ?? null;
11+
12+
if (!cvssEntry?.score) {
13+
return null;
14+
}
15+
16+
// Parse the vector using WASM module to get calculated score and severity
17+
try {
18+
let parsed = await parseCvss(cvssEntry.score);
19+
return {
20+
vector: cvssEntry.score,
21+
calculatedScore: parsed.score,
22+
severity: parsed.severity,
23+
version: parsed.version,
24+
valid: parsed.valid,
25+
};
26+
} catch {
27+
// Fallback to just returning the vector string
28+
return {
29+
vector: cvssEntry.score,
30+
calculatedScore: null,
31+
severity: null,
32+
version: null,
33+
valid: false,
34+
};
35+
}
1136
}
1237

1338
async function fetchAdvisories(crateId) {
@@ -17,17 +42,22 @@ async function fetchAdvisories(crateId) {
1742
return [];
1843
} else if (response.ok) {
1944
let advisories = await response.json();
20-
return advisories
21-
.filter(
22-
advisory =>
23-
!advisory.withdrawn &&
24-
!advisory.affected?.some(affected => affected.database_specific?.informational === 'unmaintained'),
25-
)
26-
.map(advisory => ({
45+
46+
// Filter advisories
47+
let filtered = advisories.filter(
48+
advisory =>
49+
!advisory.withdrawn &&
50+
!advisory.affected?.some(affected => affected.database_specific?.informational === 'unmaintained'),
51+
);
52+
53+
// Process CVSS scores in parallel
54+
return Promise.all(
55+
filtered.map(async advisory => ({
2756
...advisory,
2857
versionRanges: versionRanges(advisory),
29-
cvss: extractCvss(advisory),
30-
}));
58+
cvss: await extractCvssWithScore(advisory),
59+
})),
60+
);
3161
} else {
3262
throw new Error(`HTTP error! status: ${response}`);
3363
}
@@ -44,6 +74,7 @@ export default class SecurityRoute extends Route {
4474
fetchAdvisories(crate.id),
4575
import('micromark'),
4676
import('micromark-extension-gfm'),
77+
loadCvssModule(), // Pre-load WASM module
4778
]);
4879

4980
let convertMarkdown = markdown => {

app/templates/crate/security.css

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,44 @@
6565
.cvss strong {
6666
margin-right: var(--space-2xs);
6767
}
68+
69+
70+
.cvss a {
71+
font-family: monospace;
72+
font-size: 0.9em;
73+
}
74+
75+
:global(.severity-none),
76+
:global(.severity-low),
77+
:global(.severity-medium),
78+
:global(.severity-high),
79+
:global(.severity-critical) {
80+
font-weight: 600;
81+
padding: var(--space-4xs) var(--space-2xs);
82+
border-radius: var(--space-4xs);
83+
}
84+
85+
:global(.severity-none) {
86+
background-color: light-dark(#e0e0e0, #424242);
87+
color: light-dark(#616161, #bdbdbd);
88+
}
89+
90+
:global(.severity-low) {
91+
background-color: light-dark(#c8e6c9, #1b5e20);
92+
color: light-dark(#2e7d32, #a5d6a7);
93+
}
94+
95+
:global(.severity-medium) {
96+
background-color: light-dark(#fff3e0, #e65100);
97+
color: light-dark(#e65100, #ffe0b2);
98+
}
99+
100+
:global(.severity-high) {
101+
background-color: light-dark(#ffccbc, #bf360c);
102+
color: light-dark(#d84315, #ffab91);
103+
}
104+
105+
:global(.severity-critical) {
106+
background-color: light-dark(#ffcdd2, #b71c1c);
107+
color: light-dark(#c62828, #ef9a9a);
108+
}

app/templates/crate/security.gjs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,28 @@ function aliasUrl(alias) {
1212
}
1313

1414
function cvssUrl(cvss) {
15-
// Extract version from CVSS string (e.g., "CVSS:3.1/..." -> "3.1")
16-
let match = cvss.match(/^CVSS:(\d+\.\d+)\//);
15+
if (!cvss?.vector) return null;
16+
let match = cvss.vector.match(/^CVSS:(\d+\.\d+)\//);
1717
if (match) {
18-
return `https://www.first.org/cvss/calculator/${match[1]}#${cvss}`;
18+
return `https://www.first.org/cvss/calculator/${match[1]}#${cvss.vector}`;
1919
}
2020
return null;
2121
}
2222

23+
function severityClass(severity) {
24+
return `severity-${severity ?? 'none'}`;
25+
}
26+
27+
function formatScoreDisplay(cvss) {
28+
let score = cvss?.calculatedScore;
29+
let severity = cvss?.severity;
30+
if (score === null || score === undefined || Number.isNaN(score)) {
31+
return 'N/A';
32+
}
33+
let capitalizedSeverity = severity ? severity.charAt(0).toUpperCase() + severity.slice(1) : '';
34+
return `${score.toFixed(1)} (${capitalizedSeverity})`;
35+
}
36+
2337
<template>
2438
<CrateHeader @crate={{@controller.crate}} />
2539
{{#if @controller.advisories.length}}
@@ -50,7 +64,13 @@ function cvssUrl(cvss) {
5064
{{#if advisory.cvss}}
5165
<div class='cvss' data-test-cvss>
5266
<strong>CVSS:</strong>
53-
<a href={{cvssUrl advisory.cvss}}>{{advisory.cvss}}</a>
67+
{{#if advisory.cvss.valid}}
68+
<span class={{severityClass advisory.cvss.severity}} data-test-cvss-score>{{~formatScoreDisplay
69+
advisory.cvss
70+
~}}</span>
71+
72+
{{/if}}
73+
<a href={{cvssUrl advisory.cvss}}>{{advisory.cvss.vector}}</a>
5474
</div>
5575
{{/if}}
5676
{{htmlSafe (@controller.convertMarkdown advisory.details)}}

app/utils/cvss.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Lazy-loaded CVSS WASM module
2+
let cvssModule = null;
3+
let loadingPromise = null;
4+
5+
/**
6+
* Load the CVSS WASM module.
7+
* Returns a cached promise if already loading/loaded.
8+
*/
9+
export async function loadCvssModule() {
10+
if (cvssModule) {
11+
return cvssModule;
12+
}
13+
14+
if (loadingPromise) {
15+
return loadingPromise;
16+
}
17+
18+
loadingPromise = import('crates_io_cvss_wasm')
19+
.then(module => {
20+
cvssModule = module;
21+
return module;
22+
})
23+
.catch(error => {
24+
console.error('Failed to load CVSS WASM module:', error);
25+
throw error;
26+
});
27+
28+
return loadingPromise;
29+
}
30+
31+
/**
32+
* Parse a CVSS vector and get score information.
33+
* @param {string} vector - CVSS vector string
34+
* @returns {Promise<{score: number, severity: string, version: string, valid: boolean, error?: string}>}
35+
*/
36+
export async function parseCvss(vector) {
37+
let module = await loadCvssModule();
38+
return module.parse_cvss(vector);
39+
}

0 commit comments

Comments
 (0)