Skip to content
Closed
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
76 changes: 60 additions & 16 deletions app/components/Package/Dependencies.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { SEVERITY_TEXT_COLORS, getHighestSeverity } from '#shared/utils/severity'
import { isUrlDependency } from '#shared/utils/version-source'
import { getOutdatedTooltip, getVersionClass } from '~/utils/npm/outdated-dependencies'

const { t } = useI18n()
Expand Down Expand Up @@ -168,6 +169,19 @@ const numberFormatter = useNumberFormatter()
>
<span class="sr-only">{{ $t('package.deprecated.label') }}</span>
</LinkBase>
<TooltipApp
v-if="isUrlDependency(version)"
class="shrink-0 text-amber-700 dark:text-amber-500"
:text="$t('package.dependencies.url_dependency')"
>
<button
type="button"
class="inline-flex items-center justify-center p-2 -m-2"
:aria-label="$t('package.dependencies.url_dependency')"
>
<span class="i-lucide:unlink w-3 h-3" aria-hidden="true" />
</button>
</TooltipApp>
<LinkBase
:to="packageRoute(dep, version)"
class="block truncate"
Expand Down Expand Up @@ -234,14 +248,29 @@ const numberFormatter = useNumberFormatter()
{{ $t('package.dependencies.optional') }}
</TagStatic>
</div>
<LinkBase
:to="packageRoute(peer.name, peer.version)"
class="block truncate max-w-[30%]"
:title="peer.version"
dir="ltr"
>
{{ peer.version }}
</LinkBase>
<span class="flex items-center gap-1 max-w-[30%]" dir="ltr">
<TooltipApp
v-if="isUrlDependency(peer.version)"
class="shrink-0 text-amber-700 dark:text-amber-500"
:text="$t('package.dependencies.url_dependency')"
>
<button
type="button"
class="inline-flex items-center justify-center p-2 -m-2"
:aria-label="$t('package.dependencies.url_dependency')"
>
<span class="i-lucide:unlink w-3 h-3" aria-hidden="true" />
</button>
</TooltipApp>
<LinkBase
:to="packageRoute(peer.name, peer.version)"
class="block truncate"
:title="peer.version"
dir="ltr"
>
{{ peer.version }}
</LinkBase>
</span>
</li>
</ul>
<button
Expand Down Expand Up @@ -291,14 +320,29 @@ const numberFormatter = useNumberFormatter()
<LinkBase :to="packageRoute(dep)" class="block max-w-[80%] break-words" dir="ltr">
{{ dep }}
</LinkBase>
<LinkBase
:to="packageRoute(dep, version)"
class="block truncate"
:title="version"
dir="ltr"
>
{{ version }}
</LinkBase>
<span class="flex items-center gap-1" dir="ltr">
<TooltipApp
v-if="isUrlDependency(version)"
class="shrink-0 text-amber-700 dark:text-amber-500"
:text="$t('package.dependencies.url_dependency')"
>
<button
type="button"
class="inline-flex items-center justify-center p-2 -m-2"
:aria-label="$t('package.dependencies.url_dependency')"
>
<span class="i-lucide:unlink w-3 h-3" aria-hidden="true" />
</button>
</TooltipApp>
<LinkBase
:to="packageRoute(dep, version)"
class="block truncate"
:title="version"
dir="ltr"
>
{{ version }}
</LinkBase>
</span>
</li>
</ul>
<button
Expand Down
1 change: 1 addition & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,7 @@
"outdated_minor": "{count} minor version behind (latest: {latest}) | {count} minor versions behind (latest: {latest})",
"outdated_patch": "Patch update available (latest: {latest})",
"has_replacement": "This dependency has suggested replacements",
"url_dependency": "This dependency uses a URL instead of the npm registry, bypassing integrity checks",
"vulnerabilities_count": "{count} vulnerability | {count} vulnerabilities"
},
"peer_dependencies": {
Expand Down
3 changes: 3 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1366,6 +1366,9 @@
"has_replacement": {
"type": "string"
},
"url_dependency": {
"type": "string"
},
"vulnerabilities_count": {
"type": "string"
}
Expand Down
21 changes: 21 additions & 0 deletions shared/utils/version-source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Detect whether a dependency version specifier points to a URL or Git source
* rather than the npm registry.
*
* These bypass npm registry integrity checks and can be manipulated.
* @see https://docs.npmjs.com/cli/v11/configuring-npm/package-json#git-urls-as-dependencies
* @see https://docs.npmjs.com/cli/v11/configuring-npm/package-json#urls-as-dependencies
*/
export function isUrlDependency(version: string): boolean {
// npm: protocol aliases are safe (resolved from the registry)
if (version.startsWith('npm:')) return false

// Protocols: git:, git+https:, git+ssh:, git+http:, https:, http:, file:
if (/^[a-z][a-z+]*:/i.test(version)) return true
Comment on lines +13 to +14
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if workspace: or link: protocols are used in any test fixtures or real packages
rg -n 'workspace:|link:' --type json -g 'package.json' || echo "No matches in package.json files"

# Verify the regex behaviour
echo "Testing regex against workspace and link protocols:"
node -e "
const re = /^[a-z][a-z+]*:/i;
console.log('workspace:* matches:', re.test('workspace:*'));
console.log('link:../path matches:', re.test('link:../path'));
"

Repository: npmx-dev/npmx.dev

Length of output: 198


🏁 Script executed:

cat -n shared/utils/version-source.ts | head -50

Repository: npmx-dev/npmx.dev

Length of output: 1099


🏁 Script executed:

rg -l 'version-source' --type ts --type tsx | head -10

Repository: npmx-dev/npmx.dev

Length of output: 88


🏁 Script executed:

rg -B 2 -A 2 'from.*version-source|import.*version-source' --type ts --type tsx | head -30

Repository: npmx-dev/npmx.dev

Length of output: 88


🏁 Script executed:

fd -t f 'test\|spec' -e ts | xargs rg -l 'isUrlDependency' 2>/dev/null | head -10

Repository: npmx-dev/npmx.dev

Length of output: 177


🏁 Script executed:

rg -B 2 -A 2 'isUrlDependency' --type ts | head -40

Repository: npmx-dev/npmx.dev

Length of output: 3367


🏁 Script executed:

cat -n test/unit/shared/utils/version-source.spec.ts

Repository: npmx-dev/npmx.dev

Length of output: 3321


🏁 Script executed:

cat -n app/components/Package/Dependencies.vue | grep -A 10 -B 5 'isUrlDependency'

Repository: npmx-dev/npmx.dev

Length of output: 3114


Exclude workspace and link protocols from external source detection.

The protocol regex /^[a-z][a-z+]*:/i matches workspace: and link: protocols used by pnpm and Yarn for local monorepo references, causing false positives. These are local, not external sources, and conflict with the function's documented purpose. Add explicit false returns for these protocols before the generic regex check:

Suggested fix
export function isUrlDependency(version: string): boolean {
  // npm: protocol aliases are safe (resolved from the registry)
  if (version.startsWith('npm:')) return false

+ // workspace: and link: are local monorepo references, not external sources
+ if (version.startsWith('workspace:') || version.startsWith('link:')) return false
+
  // Protocols: git:, git+https:, git+ssh:, git+http:, https:, http:, file:
  if (/^[a-z][a-z+]*:/i.test(version)) return true

Add test cases for these protocols to prevent regression.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Protocols: git:, git+https:, git+ssh:, git+http:, https:, http:, file:
if (/^[a-z][a-z+]*:/i.test(version)) return true
export function isUrlDependency(version: string): boolean {
// npm: protocol aliases are safe (resolved from the registry)
if (version.startsWith('npm:')) return false
// workspace: and link: are local monorepo references, not external sources
if (version.startsWith('workspace:') || version.startsWith('link:')) return false
// Protocols: git:, git+https:, git+ssh:, git+http:, https:, http:, file:
if (/^[a-z][a-z+]*:/i.test(version)) return true


// GitHub shorthand: user/repo or user/repo#ref
// Also matches github:, gist:, bitbucket:, gitlab: (already caught above by protocol check)
if (/^[^@][^/]*\/[^/]+/.test(version)) return true

return false
}
76 changes: 76 additions & 0 deletions test/unit/shared/utils/version-source.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import { isUrlDependency } from '#shared/utils/version-source'

describe('version-source', () => {
describe('isUrlDependency', () => {
it('returns false for semver ranges', () => {
expect(isUrlDependency('^1.0.0')).toBe(false)
expect(isUrlDependency('~2.3.4')).toBe(false)
expect(isUrlDependency('>=1.0.0 <2.0.0')).toBe(false)
expect(isUrlDependency('*')).toBe(false)
expect(isUrlDependency('1.0.0')).toBe(false)
expect(isUrlDependency('2.0.0-beta.1')).toBe(false)
})

it('returns false for npm: protocol aliases', () => {
expect(isUrlDependency('npm:other-pkg@^1.0.0')).toBe(false)
expect(isUrlDependency('npm:@scope/pkg@2.0.0')).toBe(false)
})

it('returns true for git: protocol', () => {
expect(isUrlDependency('git://github.com/user/repo.git')).toBe(true)
})

it('returns true for git+https: protocol', () => {
expect(isUrlDependency('git+https://github.com/user/repo.git')).toBe(true)
})

it('returns true for git+ssh: protocol', () => {
expect(isUrlDependency('git+ssh://git@github.com:user/repo.git')).toBe(true)
})

it('returns true for git+http: protocol', () => {
expect(isUrlDependency('git+http://github.com/user/repo.git')).toBe(true)
})

it('returns true for https: URLs', () => {
expect(isUrlDependency('https://github.com/user/repo/tarball/main')).toBe(true)
})

it('returns true for http: URLs', () => {
expect(isUrlDependency('http://example.com/pkg.tgz')).toBe(true)
})

it('returns true for file: protocol', () => {
expect(isUrlDependency('file:../local-pkg')).toBe(true)
expect(isUrlDependency('file:./packages/my-lib')).toBe(true)
})

it('returns true for GitHub shorthand (user/repo)', () => {
expect(isUrlDependency('user/repo')).toBe(true)
expect(isUrlDependency('user/repo#branch')).toBe(true)
expect(isUrlDependency('user/repo#semver:^1.0.0')).toBe(true)
})

it('returns true for github: prefix', () => {
expect(isUrlDependency('github:user/repo')).toBe(true)
})

it('returns true for gist: prefix', () => {
expect(isUrlDependency('gist:11081aaa281')).toBe(true)
})

it('returns true for bitbucket: prefix', () => {
expect(isUrlDependency('bitbucket:user/repo')).toBe(true)
})

it('returns true for gitlab: prefix', () => {
expect(isUrlDependency('gitlab:user/repo')).toBe(true)
})

it('returns false for scoped packages in semver ranges', () => {
// Scoped packages start with @ so they won't match the user/repo pattern
expect(isUrlDependency('@scope/pkg')).toBe(false)
})
})
})
Loading