Skip to content

Commit f44e5ae

Browse files
authored
feat: Add vulnerability tab on report view (#111)
1 parent 97b5b4f commit f44e5ae

File tree

9 files changed

+333
-3
lines changed

9 files changed

+333
-3
lines changed

packages/node-modules-inspector/build.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export default defineBuildConfig({
2323
rollup: {
2424
inlineDependencies: [
2525
'@antfu/utils',
26+
'semver',
2627
],
2728
},
2829
})

packages/node-modules-inspector/src/app/components/display/PackageSpec.vue

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
<script setup lang="ts">
22
import type { PackageNode } from 'node-modules-tools'
33
import { computed } from 'vue'
4-
import { getDeprecatedInfo } from '../../state/payload'
4+
import { getDeprecatedInfo, getVulnerability } from '../../state/payload'
55
66
const props = defineProps<{
77
pkg: PackageNode
88
}>()
99
1010
const deprecation = computed(() => getDeprecatedInfo(props.pkg))
11+
const vulnerability = computed(() => getVulnerability(props.pkg))
1112
</script>
1213

1314
<template>
@@ -32,7 +33,12 @@ const deprecation = computed(() => getDeprecatedInfo(props.pkg))
3233
op-fade
3334
:version="props.pkg.version"
3435
prefix="@"
35-
:class="{ 'text-red line-through': deprecation?.current }"
36+
:class="{
37+
'text-red line-through': deprecation?.current || vulnerability?.level === 'critical',
38+
'text-orange line-through': vulnerability?.level === 'high',
39+
'text-yellow line-through': vulnerability?.level === 'moderate',
40+
'text-gray line-through': vulnerability?.level === 'low',
41+
}"
3642
/>
3743
</span>
3844
</template>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<script setup lang="ts">
2+
import type { PackageNode } from 'node-modules-tools'
3+
import { computed } from 'vue'
4+
import { getVulnerability } from '../../state/payload'
5+
6+
const props = withDefaults(defineProps<{
7+
pkg: PackageNode
8+
showTitle?: boolean
9+
}>(), {
10+
showTitle: true,
11+
})
12+
13+
const vulnerability = computed(() => getVulnerability(props.pkg))
14+
</script>
15+
16+
<template>
17+
<a
18+
v-if="vulnerability"
19+
px3 py2 block
20+
:href="vulnerability.url"
21+
:class="vulnerability.level !== 'critical' ? 'badge-color-orange' : 'badge-color-red'"
22+
target="_blank"
23+
>
24+
<div
25+
v-if="showTitle"
26+
flex="~ gap-1 items-center"
27+
text-sm font-bold
28+
:class="vulnerability.level !== 'critical' ? 'text-orange' : 'text-red'"
29+
>
30+
<div :class="vulnerability.level !== 'critical' ? 'i-ph-warning-circle-duotone' : 'i-ph-warning-duotone'" flex-none />
31+
<div rounded-full font-mono>
32+
Package has vulnerability
33+
</div>
34+
</div>
35+
<div text-sm>
36+
{{ vulnerability.title }}
37+
</div>
38+
</a>
39+
</template>
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<script setup lang="ts">
2+
import type { PackageNode } from 'node-modules-tools'
3+
import { useRouter } from '#app/composables/router'
4+
import { DisplayDateBadge } from '#components'
5+
import { computed, nextTick } from 'vue'
6+
import { selectedNode } from '../../state/current'
7+
import { filters } from '../../state/filters'
8+
import { getVulnerability, payloads } from '../../state/payload'
9+
10+
const router = useRouter()
11+
12+
const vulnerabilities = computed(() => {
13+
const result: PackageNode[] = []
14+
15+
payloads.filtered.packages.forEach((pkg) => {
16+
const vulnerability = getVulnerability(pkg)
17+
if (!vulnerability) {
18+
return
19+
}
20+
21+
result.push(pkg)
22+
})
23+
24+
return result
25+
})
26+
27+
function showGraph(pkg: PackageNode) {
28+
filters.state.focus = null
29+
filters.state.why = [pkg.spec]
30+
selectedNode.value = pkg
31+
nextTick(() => {
32+
router.push({ path: '/graph', hash: location.hash })
33+
})
34+
}
35+
</script>
36+
37+
<template>
38+
<template v-if="vulnerabilities.length">
39+
<div badge-color-orange flex="~ gap-2 items-center" rounded-lg p2 my2 mt5 px3>
40+
<div i-ph-warning-duotone flex-none />
41+
<span>
42+
Vulnerabilities in packages usually indicate security risks or misbehaviors.
43+
Please upgrade to the supported versions or migrate to alternatives.
44+
</span>
45+
</div>
46+
47+
<UiSubTitle>
48+
<span>Vulnerable Packages</span>
49+
<DisplayNumberBadge :number="vulnerabilities.length" rounded-full text-sm color="badge-color-red" />
50+
</UiSubTitle>
51+
<div grid="~ cols-minmax-250px gap-4">
52+
<div
53+
v-for="pkg of vulnerabilities" :key="pkg.spec"
54+
border="~ base rounded-lg" bg-glass
55+
flex="~ col"
56+
cursor-pointer
57+
:class="selectedNode === pkg ? 'border-primary ring-4 ring-primary:20' : ''"
58+
@click="selectedNode = pkg"
59+
>
60+
<div flex="~ items-center gap-2" border="b base" px2 py1>
61+
<h2 font-mono flex-auto pl2>
62+
<DisplayPackageSpec :pkg="pkg" />
63+
</h2>
64+
<button
65+
p1 rounded-full op-fade hover:bg-active hover:text-primary hover:op100 flex="~ items-center"
66+
title="Show Graph"
67+
@click="showGraph(pkg)"
68+
>
69+
<div i-ph-graph-duotone text-lg />
70+
</button>
71+
</div>
72+
<DisplayVulnerabilityMessage
73+
:pkg="pkg"
74+
:show-title="false"
75+
class="bg-transparent!" pointer-events-none
76+
/>
77+
<div flex="~ justify-between items-end w-full" mt-auto p2>
78+
<DisplayDateBadge :pkg rounded-full text-xs />
79+
<DisplayModuleType :pkg text-xs />
80+
</div>
81+
</div>
82+
</div>
83+
</template>
84+
<template v-else>
85+
<UiEmptyState
86+
type="checkmark"
87+
title="No Vulnerable Packages"
88+
message="Great! your packages are secure."
89+
/>
90+
</template>
91+
</template>

packages/node-modules-inspector/src/app/pages/report/[...report].vue

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ const selected = computed(() => params.report?.[0] || 'all')
2222
<div i-ph-warning-duotone />
2323
Deprecated
2424
</NuxtLink>
25+
<NuxtLink btn-action as="button" :to="{ path: '/report/vulnerabilities', hash: location.hash }" active-class="text-red bg-red:5">
26+
<div i-ph-warning-duotone />
27+
Vulnerabilities
28+
</NuxtLink>
2529
<NuxtLink btn-action as="button" :to="{ path: '/report/multiple-versions', hash: location.hash }" active-class="text-primary bg-primary:5">
2630
<div i-ph-copy-duotone />
2731
Multiple Versions
@@ -51,6 +55,7 @@ const selected = computed(() => params.report?.[0] || 'all')
5155
<ReportTransitiveDeps v-if="selected === 'dependencies' || selected === 'all'" />
5256
<ReportUsedBy v-if="selected === 'dependencies' || selected === 'all'" />
5357
<ReportInstallSize v-if="selected === 'install-size' || selected === 'all'" />
58+
<ReportVulnerability v-if="selected === 'vulnerabilities' || selected === 'all'" />
5459
<ReportPublishTime v-if="selected === 'time' || selected === 'all'" />
5560
<ReportDeprecated v-if="selected === 'deprecated' || selected === 'all'" />
5661
<ReportEngines v-if="selected === 'node-engines' || selected === 'all'" />

packages/node-modules-inspector/src/app/state/payload.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,11 @@ export function getDeprecatedInfo(input: PackageNode | string) {
245245
}
246246
}
247247

248+
export function getVulnerability(input: PackageNode | string) {
249+
const meta = getNpmMeta(input)
250+
return meta?.vulnerability || null
251+
}
252+
248253
export const totalWorkspaceSize = computed(() => {
249254
return Array.from(payloads.available.packages).reduce((acc, pkg) => acc + (pkg.resolved.installSize?.bytes || 0), 0)
250255
})

packages/node-modules-inspector/src/shared/version-info.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ListPackagesNpmMetaLatestOptions, ListPackagesNpmMetaOptions } fro
44
import { getLatestVersion, getLatestVersionBatch } from 'fast-npm-meta'
55
import pLimit from 'p-limit'
66
import { isNpmMetaLatestValid } from './utils'
7+
import { addPackagesNpmVulnerabilityMeta } from './vulnerable-info'
78

89
const HOUR = 1000 * 60 * 60
910
const DAY = HOUR * 24
@@ -83,7 +84,7 @@ export async function getPackagesNpmMeta(
8384
map.set(spec, meta)
8485
await storage.setItem(spec, meta)
8586
})
86-
87+
await addPackagesNpmVulnerabilityMeta(packages, options)
8788
if (missing.size) {
8889
console.warn('Failed to get npm meta for:', [...missing])
8990
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import type { AuditLevelString, NpmMeta } from 'node-modules-tools'
2+
import type { ListPackagesNpmMetaOptions } from './types'
3+
import pLimit from 'p-limit'
4+
import { satisfies } from 'semver'
5+
6+
interface AuditReport {
7+
id: string
8+
url: string
9+
title: string
10+
severity: AuditLevelString
11+
vulnerable_versions: string
12+
cwe: string[]
13+
cvss: any
14+
}
15+
16+
interface ResolvedVulnerability {
17+
name: string
18+
title: string
19+
version: string
20+
url: string
21+
level: AuditLevelString
22+
}
23+
24+
const AuditLevel: Record<AuditLevelString, number> = {
25+
low: 1,
26+
moderate: 2,
27+
high: 3,
28+
critical: 4,
29+
}
30+
31+
const registry = 'https://registry.npmjs.org'
32+
33+
async function getVulnerabilitiesBatch(dependencies: string[]): Promise<(ResolvedVulnerability | null)[]> {
34+
const payload: Record<string, Set<string>> = {}
35+
const dependencyInfo = [] as { name: string, version: string }[]
36+
for (const dependency of dependencies) {
37+
const depVersionIndex = dependency.indexOf('@', 1)
38+
const depName = dependency.slice(0, depVersionIndex).replace(/\//g, '__')
39+
const depVersion = dependency.slice(depVersionIndex + 1)
40+
dependencyInfo.push({ name: depName, version: depVersion })
41+
if (payload[depName]) {
42+
payload[depName].add(depVersion)
43+
}
44+
else {
45+
payload[depName] = new Set([depVersion])
46+
}
47+
}
48+
const body = {} as Record<string, string[]>
49+
for (const depName in payload) {
50+
if (!payload[depName]) {
51+
continue
52+
}
53+
body[depName] = Array.from(payload[depName])
54+
}
55+
const result = await fetch(`${registry}/-/npm/v1/security/advisories/bulk`, {
56+
method: 'POST',
57+
headers: {
58+
'Content-Type': 'application/json',
59+
},
60+
mode: 'no-cors',
61+
body: JSON.stringify(body),
62+
})
63+
if (!result.ok) {
64+
throw new Error(`Failed to fetch vulnerabilities: ${result.status} ${result.statusText}`)
65+
}
66+
const report = (await result.json()) as Record<string, AuditReport[]>
67+
return dependencyInfo.map(({ name, version }) => {
68+
let highestVulnerability: ResolvedVulnerability | null = null
69+
if (!report[name]) {
70+
return null
71+
}
72+
report[name].forEach((vulnerability) => {
73+
const isVulnerable = satisfies(version, vulnerability.vulnerable_versions)
74+
const level = AuditLevel[vulnerability.severity]
75+
if (isVulnerable && (!highestVulnerability || level > AuditLevel[highestVulnerability.level])) {
76+
highestVulnerability = {
77+
name,
78+
version,
79+
title: vulnerability.title,
80+
url: vulnerability.url,
81+
level: vulnerability.severity,
82+
}
83+
}
84+
})
85+
return highestVulnerability
86+
})
87+
}
88+
89+
async function fetchBatch(
90+
specs: string[],
91+
onResult: (result: ResolvedVulnerability) => void,
92+
) {
93+
const promises: Promise<void>[] = []
94+
const missingSpecs = new Set<string>()
95+
const BATCH_SIZE = 100
96+
const limit = pLimit(100)
97+
98+
for (let i = 0; i < specs.length; i += BATCH_SIZE) {
99+
const queue = specs.slice(i, i + BATCH_SIZE)
100+
promises.push(limit(async () => {
101+
try {
102+
const result = await getVulnerabilitiesBatch(queue)
103+
Object.entries(result).forEach(([_, r], idx) => {
104+
if (r !== null) {
105+
onResult(r)
106+
}
107+
else {
108+
missingSpecs.add(queue[idx]!)
109+
}
110+
})
111+
}
112+
catch {
113+
for (const spec of queue)
114+
missingSpecs.add(spec)
115+
}
116+
}))
117+
}
118+
119+
await Promise.all(promises)
120+
121+
// If batch failed, try to get publish date one by one
122+
if (missingSpecs.size) {
123+
await Promise.all(
124+
Array.from(missingSpecs).map(spec => limit(async () => {
125+
try {
126+
const result = await getVulnerabilitiesBatch([spec])
127+
if (result[0] !== null && result[0] !== undefined) {
128+
onResult(result[0])
129+
}
130+
if ('publishedAt' in result && result.publishedAt) {
131+
missingSpecs.delete(spec)
132+
}
133+
}
134+
catch {
135+
136+
}
137+
})),
138+
)
139+
}
140+
141+
return {
142+
missing: missingSpecs,
143+
}
144+
}
145+
146+
export async function addPackagesNpmVulnerabilityMeta(
147+
packages: string[],
148+
options: ListPackagesNpmMetaOptions,
149+
) {
150+
const { storageNpmMeta: storage } = options
151+
152+
const map = new Map<string, any>()
153+
const unknown = packages
154+
const {
155+
missing,
156+
} = await fetchBatch(unknown, async (r) => {
157+
const spec = `${r.name}@${r.version}`
158+
const oldMeta = await storage.getItem(spec)
159+
if (oldMeta) {
160+
const meta: NpmMeta = {
161+
...oldMeta,
162+
vulnerability: {
163+
title: r.title,
164+
url: r.url,
165+
level: r.level,
166+
},
167+
}
168+
map.set(spec, meta)
169+
await storage.setItem(spec, meta)
170+
}
171+
})
172+
173+
if (missing.size) {
174+
console.warn('Failed to get npm meta for:', [...missing])
175+
}
176+
}

0 commit comments

Comments
 (0)