Skip to content

Commit a5edda9

Browse files
howwohmmclaude
andcommitted
feat(ui): flag git and https dependencies
Add visual warning icon next to dependencies that use URL-based version specifiers (git:, https:, github:user/repo, etc.) instead of the npm registry. These bypass npm registry integrity checks and can be manipulated. - Add isUrlDependency() utility in shared/utils/version-source.ts - Detect all non-registry protocols: git:, git+https:, git+ssh:, http:, https:, file:, github:, gist:, bitbucket:, gitlab:, and user/repo shorthand - Show unlink icon with i18n tooltip in all dependency sections (dependencies, peer dependencies, optional dependencies) - Add unit tests with 100% coverage for the detection function Closes #1084 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1824c62 commit a5edda9

File tree

5 files changed

+161
-16
lines changed

5 files changed

+161
-16
lines changed

app/components/Package/Dependencies.vue

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script setup lang="ts">
22
import { SEVERITY_TEXT_COLORS, getHighestSeverity } from '#shared/utils/severity'
3+
import { isUrlDependency } from '#shared/utils/version-source'
34
import { getOutdatedTooltip, getVersionClass } from '~/utils/npm/outdated-dependencies'
45
56
const { t } = useI18n()
@@ -168,6 +169,19 @@ const numberFormatter = useNumberFormatter()
168169
>
169170
<span class="sr-only">{{ $t('package.deprecated.label') }}</span>
170171
</LinkBase>
172+
<TooltipApp
173+
v-if="isUrlDependency(version)"
174+
class="shrink-0 text-amber-700 dark:text-amber-500"
175+
:text="$t('package.dependencies.url_dependency')"
176+
>
177+
<button
178+
type="button"
179+
class="inline-flex items-center justify-center p-2 -m-2"
180+
:aria-label="$t('package.dependencies.url_dependency')"
181+
>
182+
<span class="i-lucide:unlink w-3 h-3" aria-hidden="true" />
183+
</button>
184+
</TooltipApp>
171185
<LinkBase
172186
:to="packageRoute(dep, version)"
173187
class="block truncate"
@@ -234,14 +248,29 @@ const numberFormatter = useNumberFormatter()
234248
{{ $t('package.dependencies.optional') }}
235249
</TagStatic>
236250
</div>
237-
<LinkBase
238-
:to="packageRoute(peer.name, peer.version)"
239-
class="block truncate max-w-[30%]"
240-
:title="peer.version"
241-
dir="ltr"
242-
>
243-
{{ peer.version }}
244-
</LinkBase>
251+
<span class="flex items-center gap-1 max-w-[30%]" dir="ltr">
252+
<TooltipApp
253+
v-if="isUrlDependency(peer.version)"
254+
class="shrink-0 text-amber-700 dark:text-amber-500"
255+
:text="$t('package.dependencies.url_dependency')"
256+
>
257+
<button
258+
type="button"
259+
class="inline-flex items-center justify-center p-2 -m-2"
260+
:aria-label="$t('package.dependencies.url_dependency')"
261+
>
262+
<span class="i-lucide:unlink w-3 h-3" aria-hidden="true" />
263+
</button>
264+
</TooltipApp>
265+
<LinkBase
266+
:to="packageRoute(peer.name, peer.version)"
267+
class="block truncate"
268+
:title="peer.version"
269+
dir="ltr"
270+
>
271+
{{ peer.version }}
272+
</LinkBase>
273+
</span>
245274
</li>
246275
</ul>
247276
<button
@@ -291,14 +320,29 @@ const numberFormatter = useNumberFormatter()
291320
<LinkBase :to="packageRoute(dep)" class="block max-w-[80%] break-words" dir="ltr">
292321
{{ dep }}
293322
</LinkBase>
294-
<LinkBase
295-
:to="packageRoute(dep, version)"
296-
class="block truncate"
297-
:title="version"
298-
dir="ltr"
299-
>
300-
{{ version }}
301-
</LinkBase>
323+
<span class="flex items-center gap-1" dir="ltr">
324+
<TooltipApp
325+
v-if="isUrlDependency(version)"
326+
class="shrink-0 text-amber-700 dark:text-amber-500"
327+
:text="$t('package.dependencies.url_dependency')"
328+
>
329+
<button
330+
type="button"
331+
class="inline-flex items-center justify-center p-2 -m-2"
332+
:aria-label="$t('package.dependencies.url_dependency')"
333+
>
334+
<span class="i-lucide:unlink w-3 h-3" aria-hidden="true" />
335+
</button>
336+
</TooltipApp>
337+
<LinkBase
338+
:to="packageRoute(dep, version)"
339+
class="block truncate"
340+
:title="version"
341+
dir="ltr"
342+
>
343+
{{ version }}
344+
</LinkBase>
345+
</span>
302346
</li>
303347
</ul>
304348
<button

i18n/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,7 @@
454454
"outdated_minor": "{count} minor version behind (latest: {latest}) | {count} minor versions behind (latest: {latest})",
455455
"outdated_patch": "Patch update available (latest: {latest})",
456456
"has_replacement": "This dependency has suggested replacements",
457+
"url_dependency": "This dependency uses a URL instead of the npm registry, bypassing integrity checks",
457458
"vulnerabilities_count": "{count} vulnerability | {count} vulnerabilities"
458459
},
459460
"peer_dependencies": {

i18n/schema.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1366,6 +1366,9 @@
13661366
"has_replacement": {
13671367
"type": "string"
13681368
},
1369+
"url_dependency": {
1370+
"type": "string"
1371+
},
13691372
"vulnerabilities_count": {
13701373
"type": "string"
13711374
}

shared/utils/version-source.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* Detect whether a dependency version specifier points to a URL or Git source
3+
* rather than the npm registry.
4+
*
5+
* These bypass npm registry integrity checks and can be manipulated.
6+
* @see https://docs.npmjs.com/cli/v11/configuring-npm/package-json#git-urls-as-dependencies
7+
* @see https://docs.npmjs.com/cli/v11/configuring-npm/package-json#urls-as-dependencies
8+
*/
9+
export function isUrlDependency(version: string): boolean {
10+
// npm: protocol aliases are safe (resolved from the registry)
11+
if (version.startsWith('npm:')) return false
12+
13+
// Protocols: git:, git+https:, git+ssh:, git+http:, https:, http:, file:
14+
if (/^[a-z][a-z+]*:/i.test(version)) return true
15+
16+
// GitHub shorthand: user/repo or user/repo#ref
17+
// Also matches github:, gist:, bitbucket:, gitlab: (already caught above by protocol check)
18+
if (/^[^@][^/]*\/[^/]+/.test(version)) return true
19+
20+
return false
21+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { isUrlDependency } from '#shared/utils/version-source'
3+
4+
describe('version-source', () => {
5+
describe('isUrlDependency', () => {
6+
it('returns false for semver ranges', () => {
7+
expect(isUrlDependency('^1.0.0')).toBe(false)
8+
expect(isUrlDependency('~2.3.4')).toBe(false)
9+
expect(isUrlDependency('>=1.0.0 <2.0.0')).toBe(false)
10+
expect(isUrlDependency('*')).toBe(false)
11+
expect(isUrlDependency('1.0.0')).toBe(false)
12+
expect(isUrlDependency('2.0.0-beta.1')).toBe(false)
13+
})
14+
15+
it('returns false for npm: protocol aliases', () => {
16+
expect(isUrlDependency('npm:other-pkg@^1.0.0')).toBe(false)
17+
expect(isUrlDependency('npm:@scope/pkg@2.0.0')).toBe(false)
18+
})
19+
20+
it('returns true for git: protocol', () => {
21+
expect(isUrlDependency('git://github.com/user/repo.git')).toBe(true)
22+
})
23+
24+
it('returns true for git+https: protocol', () => {
25+
expect(isUrlDependency('git+https://github.com/user/repo.git')).toBe(true)
26+
})
27+
28+
it('returns true for git+ssh: protocol', () => {
29+
expect(isUrlDependency('git+ssh://git@github.com:user/repo.git')).toBe(true)
30+
})
31+
32+
it('returns true for git+http: protocol', () => {
33+
expect(isUrlDependency('git+http://github.com/user/repo.git')).toBe(true)
34+
})
35+
36+
it('returns true for https: URLs', () => {
37+
expect(isUrlDependency('https://github.com/user/repo/tarball/main')).toBe(true)
38+
})
39+
40+
it('returns true for http: URLs', () => {
41+
expect(isUrlDependency('http://example.com/pkg.tgz')).toBe(true)
42+
})
43+
44+
it('returns true for file: protocol', () => {
45+
expect(isUrlDependency('file:../local-pkg')).toBe(true)
46+
expect(isUrlDependency('file:./packages/my-lib')).toBe(true)
47+
})
48+
49+
it('returns true for GitHub shorthand (user/repo)', () => {
50+
expect(isUrlDependency('user/repo')).toBe(true)
51+
expect(isUrlDependency('user/repo#branch')).toBe(true)
52+
expect(isUrlDependency('user/repo#semver:^1.0.0')).toBe(true)
53+
})
54+
55+
it('returns true for github: prefix', () => {
56+
expect(isUrlDependency('github:user/repo')).toBe(true)
57+
})
58+
59+
it('returns true for gist: prefix', () => {
60+
expect(isUrlDependency('gist:11081aaa281')).toBe(true)
61+
})
62+
63+
it('returns true for bitbucket: prefix', () => {
64+
expect(isUrlDependency('bitbucket:user/repo')).toBe(true)
65+
})
66+
67+
it('returns true for gitlab: prefix', () => {
68+
expect(isUrlDependency('gitlab:user/repo')).toBe(true)
69+
})
70+
71+
it('returns false for scoped packages in semver ranges', () => {
72+
// Scoped packages start with @ so they won't match the user/repo pattern
73+
expect(isUrlDependency('@scope/pkg')).toBe(false)
74+
})
75+
})
76+
})

0 commit comments

Comments
 (0)