Skip to content

Conversation

@wojtekmaj
Copy link
Contributor

@wojtekmaj wojtekmaj commented Feb 5, 2026

Copilot AI review requested due to automatic review settings February 5, 2026 22:36
@vercel
Copy link

vercel bot commented Feb 5, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Feb 6, 2026 9:22am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Feb 6, 2026 9:22am
npmx-lunaria Ignored Ignored Feb 6, 2026 9:22am

Request Review

@github-actions
Copy link

github-actions bot commented Feb 5, 2026

Lunaria Status Overview

🌕 This pull request will trigger status changes.

Learn more

By default, every PR changing files present in the Lunaria configuration's files property will be considered and trigger status changes accordingly.

You can change this by adding one of the keywords present in the ignoreKeywords property in your Lunaria configuration file in the PR's title (ignoring all files) or by including a tracker directive in the merged commit's description.

Tracked Files

File Note
lunaria/files/en-GB.json Localization changed, will be marked as complete.
lunaria/files/en-US.json Source changed, localizations will be marked as outdated.
Warnings reference
Icon Description
🔄️ The source for this localization has been updated since the creation of this pull request, make sure all changes in the source have been applied.

@codecov
Copy link

codecov bot commented Feb 5, 2026

Codecov Report

❌ Patch coverage is 72.30769% with 18 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
app/pages/package/[...package].vue 20.00% 8 Missing and 4 partials ⚠️
app/utils/publish-security.ts 84.37% 2 Missing and 3 partials ⚠️
app/components/Terminal/Install.vue 50.00% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 5, 2026

📝 Walkthrough

Walkthrough

Adds publish-security downgrade detection and surfaces it in the UI, including two alert blocks on the package page. Introduces app/utils/publish-security.ts with detection logic and a PublishSecurityDowngrade interface. Extends package data flow (transformPackument and shared types) to carry per-version trust metadata (securityVersions, trustLevel, hasProvenance). Wires a computed trusted fallback (installVersionOverride) into Terminal/Install and useInstallCommand so generated install commands can prefer a trusted version. Adds translations and tests for downgrade detection and install-override behaviour.

Possibly related PRs

Suggested reviewers

  • danielroe
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The PR description is directly related to the changeset, referencing issue #534 and providing examples of security downgrade detection functionality.
Linked Issues check ✅ Passed The PR successfully implements all core requirements from issue #534: detecting security downgrades, displaying prominent security warnings, and pinning install commands to trusted versions.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing security downgrade detection and UI display. No extraneous modifications detected outside the stated objectives.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR implements a security downgrade detection feature that warns users when an npm package version was published without provenance after previous versions had provenance. This addresses supply-chain security concerns by alerting users to potential compromises.

Changes:

  • Added security downgrade detection utilities that compare provenance status across package versions
  • Implemented a prominent warning banner in the package installation UI when a downgrade is detected
  • Modified install commands to default to the last trusted version when viewing a downgraded version
  • Added comprehensive test coverage for the detection logic
  • Added i18n strings in English locales for the security warning messages

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
app/utils/publish-security.ts Core detection logic for identifying security downgrades by comparing provenance across versions
test/unit/app/utils/publish-security.spec.ts Comprehensive unit tests covering main scenarios and edge cases
app/pages/package/[...package].vue Integration of downgrade detection with warning banner and version override logic
app/composables/useInstallCommand.ts Added optional installVersionOverride parameter to support pinning to trusted versions
app/components/Terminal/Install.vue Passes through installVersionOverride prop to support version pinning
test/nuxt/composables/use-install-command.spec.ts Test coverage for the version override functionality
i18n/locales/en.json, lunaria/files/en-US.json, lunaria/files/en-GB.json Warning message strings for the security banner

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
test/unit/app/utils/publish-security.spec.ts (1)

7-63: LGTM! Core scenarios are well covered.

The tests verify the three primary outcomes: downgrade detected, latest is trusted, and no trusted history.

Consider adding edge case tests for robustness:

  • Empty array input (should return null)
  • Single version input (should return null)
  • Versions with missing/invalid time fields (to verify timestamp fallback logic)
📝 Example edge case tests
it('returns null for empty versions array', () => {
  const result = detectPublishSecurityDowngrade([])
  expect(result).toBeNull()
})

it('returns null for single version', () => {
  const result = detectPublishSecurityDowngrade([
    { version: '1.0.0', time: '2026-01-01T00:00:00.000Z', hasProvenance: false },
  ])
  expect(result).toBeNull()
})

it('handles versions with missing timestamps', () => {
  const result = detectPublishSecurityDowngrade([
    { version: '1.0.0', hasProvenance: true },
    { version: '1.0.1', hasProvenance: false },
  ])
  // Should still detect downgrade using semver ordering
  expect(result).toEqual({
    downgradedVersion: '1.0.1',
    downgradedPublishedAt: undefined,
    trustedVersion: '1.0.0',
    trustedPublishedAt: undefined,
  })
})
app/composables/useInstallCommand.ts (1)

34-44: Minor duplication of version resolution logic.

The version resolution pattern (toValue(installVersionOverride) ?? toValue(requestedVersion)) is repeated in both installCommandParts and installCommand. This is acceptable for clarity, but if this pattern grows in usage, consider extracting it to a computed ref.

♻️ Optional refactor to reduce duplication
+  const resolvedVersion = computed(() =>
+    toValue(installVersionOverride) ?? toValue(requestedVersion)
+  )
+
   const installCommandParts = computed(() => {
     const name = toValue(packageName)
     if (!name) return []
-    const version = toValue(installVersionOverride) ?? toValue(requestedVersion)
     return getInstallCommandParts({
       packageName: name,
       packageManager: selectedPM.value,
-      version,
+      version: resolvedVersion.value,
       jsrInfo: toValue(jsrInfo),
     })
   })

   const installCommand = computed(() => {
     const name = toValue(packageName)
     if (!name) return ''
-    const version = toValue(installVersionOverride) ?? toValue(requestedVersion)
     return getInstallCommand({
       packageName: name,
       packageManager: selectedPM.value,
-      version,
+      version: resolvedVersion.value,
       jsrInfo: toValue(jsrInfo),
     })
   })

Copy link

@ghostdevv ghostdevv left a comment

Choose a reason for hiding this comment

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

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/composables/npm/usePackage.ts (1)

87-96: ⚠️ Potential issue | 🟠 Major

Same hasProvenance semantic issue in filtered versions.

Line 87 uses hasPublishTrustEvidence(version) which includes trusted-publisher evidence, again conflating it with provenance for the hasProvenance field on SlimVersion.

🐛 Proposed fix — use attestation-only check for hasProvenance
-      const hasProvenance = hasPublishTrustEvidence(version)
+      const provenance = hasAttestations(version)
       const trustLevel = getTrustLevel(version)

       filteredVersions[v] = {
-        hasProvenance,
+        hasProvenance: provenance,
         trustLevel,
🧹 Nitpick comments (3)
app/composables/npm/usePackage.ts (1)

59-68: securityVersions includes every version — potential payload bloat for large packages.

Unlike filteredVersions which is pruned to ~5 recent + dist-tags, securityVersions maps over all pkg.versions. Popular packages can have hundreds or thousands of versions, and this array is shipped to every client in the initial SSR payload.

Consider whether the downgrade detection logic could run server-side and only ship the result (or a trimmed window of versions) to the client.

app/utils/publish-security.ts (1)

55-87: detectPublishSecurityDowngrade does not skip deprecated versions.

A deprecated version that happens to be the newest by timestamp will be flagged as the "downgraded" version. Users seeing a deprecation notice and a security downgrade banner simultaneously may be confusing. Consider whether deprecated versions should be excluded from the "latest" candidate or documented as intentional.

app/pages/package/[...package].vue (1)

148-159: Fallback path derives security metadata from filtered versions only — downgrade detection may miss evidence.

When securityVersions is not populated (e.g. client-side rehydration edge cases), the fallback on line 152 iterates pkg.value.versions, which only contains ~5 recent versions + dist-tag versions. If the trusted older version falls outside that window, detectPublishSecurityDowngradeForVersion will return null and the warning won't appear.

This is a graceful degradation (no false positive), but it's worth documenting or logging when the fallback is hit so it's clear why the banner might not show in certain scenarios.

Comment on lines +59 to +68
const securityVersions = Object.entries(pkg.versions).map(([version, metadata]) => {
const trustLevel = getTrustLevel(metadata)
return {
version,
time: pkg.time[version],
hasProvenance: trustLevel !== 'none',
trustLevel,
deprecated: metadata.deprecated,
}
})
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

hasProvenance conflates trusted-publisher evidence with attestation provenance.

On line 64, hasProvenance: trustLevel !== 'none' means a version published via a trusted publisher (OIDC) but without SLSA attestations is marked as hasProvenance: true. This is semantically incorrect — "provenance" specifically refers to verifiable build attestations, not publisher identity trust. The downstream getTrustRank fallback in publish-security.ts (line 25: return version.hasProvenance ? TRUST_RANK.provenance : TRUST_RANK.none) would then incorrectly elevate a trustedPublisher version to provenance rank when trustLevel is absent.

Consider using the attestation-only check for hasProvenance and relying on trustLevel for the broader trust signal:

🐛 Proposed fix
     return {
       version,
       time: pkg.time[version],
-      hasProvenance: trustLevel !== 'none',
+      hasProvenance: hasAttestations(metadata),
       trustLevel,
       deprecated: metadata.deprecated,
     }
📝 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
const securityVersions = Object.entries(pkg.versions).map(([version, metadata]) => {
const trustLevel = getTrustLevel(metadata)
return {
version,
time: pkg.time[version],
hasProvenance: trustLevel !== 'none',
trustLevel,
deprecated: metadata.deprecated,
}
})
const securityVersions = Object.entries(pkg.versions).map(([version, metadata]) => {
const trustLevel = getTrustLevel(metadata)
return {
version,
time: pkg.time[version],
hasProvenance: hasAttestations(metadata),
trustLevel,
deprecated: metadata.deprecated,
}
})

Comment on lines 33 to 49
function sortByRecency(a: VersionWithIndex, b: VersionWithIndex): number {
const aValid = Number.isFinite(a.timestamp)
const bValid = Number.isFinite(b.timestamp)

if (aValid && bValid && a.timestamp !== b.timestamp) {
return b.timestamp - a.timestamp
}

if (aValid !== bValid) {
return aValid ? -1 : 1
}

const semverOrder = compare(b.version, a.version)
if (semverOrder !== 0) return semverOrder

return a.index - b.index
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

semver.compare can throw on invalid version strings.

compare(b.version, a.version) on line 45 throws a TypeError if either string is not valid semver. While npm versions are usually valid, edge cases exist (e.g. very old packages, yanked/corrupted metadata). A single bad version in the array would crash the entire sort and, by extension, the page render.

🛡️ Proposed defensive fix
+import { compare, valid } from 'semver'

 function sortByRecency(a: VersionWithIndex, b: VersionWithIndex): number {
   const aValid = Number.isFinite(a.timestamp)
   const bValid = Number.isFinite(b.timestamp)

   if (aValid && bValid && a.timestamp !== b.timestamp) {
     return b.timestamp - a.timestamp
   }

   if (aValid !== bValid) {
     return aValid ? -1 : 1
   }

-  const semverOrder = compare(b.version, a.version)
-  if (semverOrder !== 0) return semverOrder
+  if (valid(a.version) && valid(b.version)) {
+    const semverOrder = compare(b.version, a.version)
+    if (semverOrder !== 0) return semverOrder
+  }

   return a.index - b.index
 }

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
app/pages/package/[...package].vue (1)

148-159: Fallback derivation looks sound, but the !! coercion on hasProvenance is redundant.

On SlimVersion, hasProvenance is already a boolean (set in usePackage.ts line 91/94). The double-negation !!metadata.hasProvenance on line 155 is harmless but unnecessary.

Otherwise, the fallback logic for older cached payloads that lack securityVersions is a nice defensive pattern.

Nitpick: remove redundant coercion
   return Object.entries(pkg.value.versions).map(([version, metadata]) => ({
     version,
     time: pkg.value?.time?.[version],
-    hasProvenance: !!metadata.hasProvenance,
+    hasProvenance: metadata.hasProvenance,
     trustLevel: metadata.trustLevel,
     deprecated: metadata.deprecated,
   }))

Comment on lines +90 to +95
const trustLevel = getTrustLevel(version)
const hasProvenance = trustLevel !== 'none'

filteredVersions[v] = {
...((version?.dist as { attestations?: unknown }) ? { hasProvenance: true } : {}),
hasProvenance,
trustLevel,
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Same hasProvenance conflation applies here in the filtered versions.

Lines 90–91 replicate the same trustLevel !== 'none'hasProvenance pattern flagged above. This should use hasAttestations(version) for consistency and correctness.

Proposed fix
-      const trustLevel = getTrustLevel(version)
-      const hasProvenance = trustLevel !== 'none'
+      const trustLevel = getTrustLevel(version)
+      const hasProvenance = hasAttestations(version)
📝 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
const trustLevel = getTrustLevel(version)
const hasProvenance = trustLevel !== 'none'
filteredVersions[v] = {
...((version?.dist as { attestations?: unknown }) ? { hasProvenance: true } : {}),
hasProvenance,
trustLevel,
const trustLevel = getTrustLevel(version)
const hasProvenance = hasAttestations(version)
filteredVersions[v] = {
hasProvenance,
trustLevel,

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Big red scary banner if an npm publish security downgrade is detected

2 participants