Skip to content
Open
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
27 changes: 27 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,30 @@ NUXT_SESSION_PASSWORD=""

# HMAC secret for image-proxy and OG image URL signing, can use `openssl rand -hex 32`
NUXT_IMAGE_PROXY_SECRET=""

# Socket (socket.dev) security data source - optional.
# Without these, the Socket source reports itself as unavailable and the UI
# shows its checkbox as "unavailable on this deployment"; OSV still works.
#
# To obtain a key: sign in at https://socket.dev, then in the dashboard go to
# Settings -> API Tokens -> "+ Create API token" and grant it the
# `packages:list` scope (docs: https://docs.socket.dev/reference/creating-and-managing-api-tokens).
# That is the only scope needed - it is what the batch-purl endpoint npmx uses
# requires. The `alerts:list`/`alerts:trend` scopes are for a different (org
# alerts feed) endpoint and do NOT grant access here.
# Quota note: that endpoint costs a flat 100 units per request (up to 1024
# packages), so a default 500-units/hour token allows only ~5 fresh scans an
# hour. To stretch that, npmx caches Socket findings per package@version for a
# day (a published version is immutable), so overlapping and revisited
# dependency trees are served from cache and only never-before-seen versions
# cost a request; a quota/auth rejection also trips a short cooldown rather
# than hammering the API.
# NUXT_SOCKET_ORG_SLUG is your organization's slug (the `orgs/<slug>` segment
# in the dashboard URL).
NUXT_SOCKET_API_KEY=""
NUXT_SOCKET_ORG_SLUG=""
# Prefer providing the credentials above at build time: the public
# availability flag is derived during the build, and prerendered pages (like
# the settings page) bake it in. If the credentials only exist at runtime,
# also set this so server-rendered pages report Socket as available:
# NUXT_PUBLIC_SOCKET_CONFIGURED="true"
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,15 @@ Please use the version pinned in the `engines.node` and `packageManager` field o
pnpm npmx-connector
```

5. (optional) to work on the Socket security data source, copy `.env.example` to `.env` and set your own Socket credentials:

- `NUXT_SOCKET_API_KEY` — create an organization API token at [socket.dev](https://socket.dev) under **Settings → API Tokens → "+ Create API token"**, granting it the `packages:list` scope (see [Socket's token docs](https://docs.socket.dev/reference/creating-and-managing-api-tokens)). That single scope is what the batch-purl endpoint npmx uses requires; the `alerts:list`/`alerts:trend` scopes are for a different (org alerts feed) endpoint and do **not** grant access here.
- `NUXT_SOCKET_ORG_SLUG` — your organization's slug (the `orgs/<slug>` segment in the dashboard URL).

The key stays server-side (it is never sent to the browser). Without these, the Socket source simply reports itself as unavailable — everything else, including OSV-based vulnerability scanning, works normally.

> **Quota:** the batch-purl endpoint costs a flat 100 units per request (up to 1024 packages), so a default 500-units/hour token only covers ~5 fresh scans an hour. npmx caches Socket findings per `package@version` for a day (a published version is immutable), so overlapping and revisited trees are served from cache and only never-before-seen versions cost a request; a quota/auth rejection also trips a short cooldown, so an exhausted token degrades to OSV-only rather than erroring repeatedly.

## Development workflow

### Available commands
Expand Down
1 change: 1 addition & 0 deletions app/assets/logos/security-sources/osv-mark-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions app/assets/logos/security-sources/osv-mark-light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions app/assets/logos/security-sources/socket.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 16 additions & 1 deletion app/components/Compare/FacetScatterChart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ watch(
const isDarkMode = computed(() => resolvedMode.value === 'dark')

const { facetLabels } = useFacetSelection()
const { anySourceEnabled: anySecuritySourceEnabled } = useSecuritySources()

const chartableFacets = computed(() =>
(
Expand All @@ -69,12 +70,26 @@ const chartableFacets = computed(() =>
description: facet.description,
chartable: facet.chartable_scatter,
}))
.filter(facet => facet.chartable),
.filter(facet => facet.chartable)
// Security counts are meaningless without an enabled security source
.filter(
facet =>
(facet.name !== 'vulnerabilities' && facet.name !== 'supplyChainAlerts') ||
anySecuritySourceEnabled.value,
),
)

const selectedFacetX = ref<ComparisonFacet>('downloads')
const selectedFacetY = ref<ComparisonFacet>('installSize')

// If the selected axis facet becomes unavailable (e.g. all security sources
// were disabled), fall back to the defaults
watchEffect(() => {
const available = new Set(chartableFacets.value.map(facet => facet.name))
if (!available.has(selectedFacetX.value)) selectedFacetX.value = 'downloads'
if (!available.has(selectedFacetY.value)) selectedFacetY.value = 'installSize'
})

const dataset = computed<VueUiScatterDatasetItem[]>(() =>
buildCompareScatterChartDataset(
props.packagesData,
Expand Down
15 changes: 13 additions & 2 deletions app/components/Package/Dependencies.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,24 @@ const { data: vulnTree } = useDependencyAnalysis(
() => props.version,
)

// Only show findings from enabled security data sources
const { effectiveSources } = useSecuritySources()
const displayVulnTree = computed(() => {
if (!vulnTree.value) return null
return filterVulnerabilityTreeBySources(vulnTree.value, effectiveSources.value)
})

// Check if a dependency has vulnerabilities (only direct deps)
function getVulnerableDepInfo(depName: string) {
if (!vulnTree.value) return null
return vulnTree.value.vulnerablePackages.find(p => p.name === depName && p.depth === 'direct')
if (!displayVulnTree.value) return null
return displayVulnTree.value.vulnerablePackages.find(
p => p.name === depName && p.depth === 'direct',
)
}

// Check if a dependency is deprecated (only direct deps)
// Note: deprecation comes from npm packument data, not from security sources,
// so it is intentionally not gated by the security-source preference
function getDeprecatedDepInfo(depName: string) {
if (!vulnTree.value) return null
return vulnTree.value.deprecatedPackages.find(p => p.name === depName && p.depth === 'direct')
Expand Down
215 changes: 215 additions & 0 deletions app/components/Package/SupplyChainAlerts.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
<script setup lang="ts">
import type { OsvSeverityLevel, SupplyChainAlertType } from '#shared/types/dependency-analysis'
import { SEVERITY_COLORS } from '#shared/utils/severity'

const props = defineProps<{
packageName: string
version: string
}>()

// Shares the cached fetch with PackageVulnerabilityTree
const { data: vulnTree, status } = useDependencyAnalysis(
() => props.packageName,
() => props.version,
)

const { effectiveSources, anySourceEnabled } = useSecuritySources()

// Display filter: only show alerts from enabled security data sources.
// The no-sources warning is rendered by PackageVulnerabilityTree; this
// component simply renders nothing in that state.
const alertPackages = computed(() => {
if (!anySourceEnabled.value || !vulnTree.value) return []
return filterVulnerabilityTreeBySources(vulnTree.value, effectiveSources.value)
.supplyChainPackages
})

// Supply-chain alerts come exclusively from Socket. When the user has Socket
// enabled but the scan produced nothing (outage, quota, misconfiguration),
// say so instead of rendering nothing - silence would read as "all clear".
const socketScanFailed = computed(() => {
if (!vulnTree.value || !effectiveSources.value.socket) return false
const socketStatus = vulnTree.value.sourceStatus.socket
return socketStatus !== 'ok' && socketStatus !== 'partial'
})

const totalAlerts = computed(() =>
alertPackages.value.reduce((sum, pkg) => sum + pkg.alerts.length, 0),
)

const isExpanded = shallowRef(false)

const {
visibleItems: visiblePackages,
hasMore: hasMorePackages,
expand: expandPackages,
} = useVisibleItems(alertPackages, 5)

const alertTypeLabels = computed<Record<SupplyChainAlertType, string>>(() => ({
malware: $t('package.supply_chain.types.malware'),
gptMalware: $t('package.supply_chain.types.gptMalware'),
didYouMean: $t('package.supply_chain.types.didYouMean'),
gptDidYouMean: $t('package.supply_chain.types.gptDidYouMean'),
troll: $t('package.supply_chain.types.troll'),
obfuscatedFile: $t('package.supply_chain.types.obfuscatedFile'),
manifestConfusion: $t('package.supply_chain.types.manifestConfusion'),
installScripts: $t('package.supply_chain.types.installScripts'),
telemetry: $t('package.supply_chain.types.telemetry'),
unstableOwnership: $t('package.supply_chain.types.unstableOwnership'),
}))

const severityLabels = computed<Record<OsvSeverityLevel, string>>(() => ({
critical: $t('package.vulnerabilities.severity.critical'),
high: $t('package.vulnerabilities.severity.high'),
moderate: $t('package.vulnerabilities.severity.moderate'),
low: $t('package.vulnerabilities.severity.low'),
unknown: $t('package.vulnerabilities.severity.unknown'),
}))

// A critical alert anywhere in the tree (e.g. malware) makes the whole
// banner red; otherwise it matches the amber vulnerability banner
const hasCriticalAlert = computed(() =>
alertPackages.value.some(pkg => pkg.alerts.some(alert => alert.severity === 'critical')),
)

const bannerColor = computed(() =>
hasCriticalAlert.value
? 'border-red-600/40 bg-red-500/10 text-red-800 dark:text-red-400'
: 'border-amber-600/40 bg-amber-500/10 text-amber-800 dark:text-amber-400',
)

const depthStyles = {
root: 'border-is-2 border-is-amber-600',
direct: 'border-is-2 border-is-amber-500',
transitive: 'border-is-2 border-is-amber-400',
} as const

function getDepthStyle(depth: string | undefined) {
if (depth && depth in depthStyles) {
return depthStyles[depth as keyof typeof depthStyles]
}
return depthStyles.transitive
}
</script>

<template>
<section
v-if="status === 'success' && alertPackages.length > 0"
aria-labelledby="supply-chain-heading"
class="relative"
>
<div role="alert" class="rounded-lg border overflow-hidden" :class="bannerColor">
<!-- Header -->
<button
type="button"
class="w-full flex items-center justify-between gap-3 px-4 py-3 text-start transition-colors duration-200 hover:bg-white/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/70"
:aria-expanded="isExpanded"
aria-controls="supply-chain-details"
@click="isExpanded = !isExpanded"
>
<span class="flex items-center gap-2 min-w-0">
<span class="i-lucide:shield-alert w-4 h-4 shrink-0" aria-hidden="true" />
<span id="supply-chain-heading" class="font-mono text-sm font-medium truncate">
{{ $t('package.supply_chain.alerts_found', { alerts: totalAlerts }, totalAlerts) }}
{{
$t(
'package.supply_chain.in_packages',
{ packages: alertPackages.length },
alertPackages.length,
)
}}
</span>
</span>
<span class="flex items-center gap-2 shrink-0">
<!-- all supply-chain alerts are sourced from Socket -->
<SecuritySourceLogo source="socket" class="h-5" />
<span
class="i-lucide:chevron-down w-4 h-4 transition-transform duration-200"
:class="{ 'rotate-180': isExpanded }"
aria-hidden="true"
/>
</span>
</button>

<!-- Expandable details -->
<div
v-show="isExpanded"
id="supply-chain-details"
class="border-t border-border bg-bg-subtle"
>
<ul class="divide-y divide-border list-none m-0 p-0">
<li
v-for="pkg in visiblePackages"
:key="`${pkg.name}@${pkg.version}`"
class="px-4 py-3 bg-amber-500/5"
:class="getDepthStyle(pkg.depth)"
>
<div class="flex items-center justify-between gap-2 mb-2">
<div class="flex items-center gap-2 min-w-0 relative">
<DependencyPathPopup v-if="pkg.path && pkg.path.length > 1" :path="pkg.path" />
<NuxtLink
:to="packageRoute(pkg.name, pkg.version)"
class="font-mono text-sm font-medium hover:underline truncate shrink min-w-0 text-fg"
>
{{ pkg.name }}@{{ pkg.version }}
</NuxtLink>
</div>
<a
:href="getSocketPackageUrl(pkg.name)"
target="_blank"
rel="noopener noreferrer"
class="shrink-0 inline-flex items-center gap-1 text-xs text-fg-subtle hover:text-fg hover:underline"
>
{{ $t('common.view_on.socket_dev') }}
<span class="i-lucide:external-link w-3 h-3" aria-hidden="true" />
</a>
</div>
<ul class="space-y-1 list-none m-0 p-0">
<li
v-for="alert in pkg.alerts"
:key="alert.type"
class="flex items-center gap-2 text-xs text-fg-muted"
>
<span
class="px-1.5 py-0.5 text-3xs font-mono rounded border shrink-0"
:class="SEVERITY_COLORS[alert.severity]"
>
{{ severityLabels[alert.severity] }}
</span>
<span class="truncate">{{ alertTypeLabels[alert.type] }}</span>
</li>
</ul>
</li>
</ul>

<button
v-if="hasMorePackages"
type="button"
class="w-full px-4 py-2 text-xs font-mono text-fg-muted hover:text-fg border-t border-border transition-colors duration-200"
@click="expandPackages"
>
{{
$t('package.supply_chain.show_all_packages', {
count: alertPackages.length,
})
}}
</button>
</div>
</div>
</section>

<!-- Socket enabled but the scan produced no data - subtle, not alarming -->
<section
v-else-if="status === 'success' && anySourceEnabled && socketScanFailed"
:aria-label="$t('package.supply_chain.scan_failed')"
>
<div class="rounded-lg border border-border bg-bg-subtle px-4 py-3">
<div class="flex items-center gap-2">
<span class="i-lucide:circle-alert w-4 h-4 text-fg-subtle" aria-hidden="true" />
<span class="text-sm text-fg-muted">
{{ $t('package.supply_chain.scan_failed') }}
</span>
</div>
</div>
</section>
</template>
Loading
Loading