From 9bae73f98135242e9f2de54d04948f6e776ef28c Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Fri, 26 Sep 2025 12:40:17 +0100 Subject: [PATCH 1/4] feat: diff trust levels --- README.md | 3 +- main.js | 98 +++++++++++++++++++++++++++++++++++++++++++++++- src/main.ts | 59 ++++++++++++++++++++++++++++- src/npm.ts | 92 ++++++++++++++++++++++++++++++++++++++++++++- test/npm_test.ts | 3 +- tsconfig.json | 2 +- 6 files changed, 250 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c87d6b4..ae9b67c 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,7 @@ This action compares dependencies between your base branch and current branch, analyzing potential security and maintenance concerns: -- 🔒 **Provenance changes** - Detects loss of provenance -- ✅ **Trusted publisher changes** - Detects loss of trusted publish status +- 🛡️ **Package trust levels** - Detects decreases in package trust levels (provenance and trusted publisher status) - 📈 **Dependency growth** - Warns when dependency count increases significantly - 📦 **Install size** - Warns when package size increases significantly - 🔄 **Duplicate versions** - Detects packages with multiple versions installed diff --git a/main.js b/main.js index a826036..27596d6 100644 --- a/main.js +++ b/main.js @@ -24157,7 +24157,50 @@ function getBaseRef() { // src/npm.ts var core2 = __toESM(require_core(), 1); -async function fetchPackageMetadata(packageName, version) { +function getProvenance(meta) { + if (meta._npmUser?.trustedPublisher) { + return "trusted"; + } + if (meta.dist?.attestations?.provenance) { + return "provenance"; + } + return "none"; +} +function getTrustLevel(status) { + switch (status) { + case "trusted": + return 2; + case "provenance": + return 1; + case "none": + return 0; + default: + return 0; + } +} +async function getProvenanceForPackageVersions(packageName, versions) { + const result = /* @__PURE__ */ new Map(); + for (const version of versions) { + const metadata = await fetchPackageMetadata(packageName, version); + if (metadata) { + result.set(version, getProvenance(metadata)); + } + } + return result; +} +function getMinTrustLevel(statuses) { + const result = { level: 2, status: "trusted" }; + for (const status of statuses) { + const level = getTrustLevel(status); + if (level < result.level) { + result.level = level; + result.status = status; + } + } + return result; +} +var metaCache = /* @__PURE__ */ new Map(); +async function fetchPackageMetadataImmediate(packageName, version) { try { const url = `https://registry.npmjs.org/${packageName}/${version}`; const response = await fetch(url); @@ -24170,6 +24213,18 @@ async function fetchPackageMetadata(packageName, version) { return null; } } +async function fetchPackageMetadata(packageName, version) { + const cacheKey = `${packageName}@${version}`; + const cached = metaCache.get(cacheKey); + if (cached) { + return cached; + } + const meta = fetchPackageMetadataImmediate(packageName, version); + metaCache.set(cacheKey, meta); + const result = await meta; + metaCache.set(cacheKey, result); + return result; +} async function calculateTotalDependencySizeIncrease(newVersions) { let totalSize = 0; const processedPackages = /* @__PURE__ */ new Set(); @@ -24473,6 +24528,47 @@ ${packageRows}` core4.info(`Failed to calculate total dependency size increase: ${err}`); } } + const provenanceWarnings = []; + for (const [packageName, currentVersionSet] of currentDeps) { + const baseVersionSet = baseDeps.get(packageName); + if (!baseVersionSet || baseVersionSet.size === 0) { + continue; + } + if (baseVersionSet.isSubsetOf(currentVersionSet)) { + continue; + } + try { + const baseProvenances = await getProvenanceForPackageVersions( + packageName, + baseVersionSet + ); + const currentProvenances = await getProvenanceForPackageVersions( + packageName, + currentVersionSet + ); + if (baseProvenances.size === 0 || currentProvenances.size === 0) { + continue; + } + const minBaseTrust = getMinTrustLevel(baseProvenances.values()); + const minCurrentTrust = getMinTrustLevel(currentProvenances.values()); + if (minCurrentTrust.level < minBaseTrust.level) { + provenanceWarnings.push( + `\u{1F4E6} **${packageName}**: trust level decreased (${minBaseTrust.status} \u2192 ${minCurrentTrust.status})` + ); + } + } catch (err) { + core4.info(`Failed to check provenance for ${packageName}: ${err}`); + } + } + if (provenanceWarnings.length > 0) { + messages.push( + `## \u26A0\uFE0F Package Trust Level Decreased + +These packages have decreased trust levels: + +${provenanceWarnings.join("\n")}` + ); + } const basePackagesPattern = core4.getInput("base-packages"); const sourcePackagesPattern = core4.getInput("source-packages"); if (basePackagesPattern && sourcePackagesPattern) { diff --git a/src/main.ts b/src/main.ts index 22c8b15..aff6753 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,7 +3,11 @@ import * as core from '@actions/core'; import * as github from '@actions/github'; import {parseLockfile, detectLockfile} from './lockfile.js'; import {getFileFromRef, getBaseRef} from './git.js'; -import {calculateTotalDependencySizeIncrease} from './npm.js'; +import { + calculateTotalDependencySizeIncrease, + getMinTrustLevel, + getProvenanceForPackageVersions +} from './npm.js'; import {getPacksFromPattern, comparePackSizes} from './packs.js'; function formatBytes(bytes: number): string { @@ -196,6 +200,59 @@ ${packageRows}` } } + const provenanceWarnings: string[] = []; + + for (const [packageName, currentVersionSet] of currentDeps) { + const baseVersionSet = baseDeps.get(packageName); + + if (!baseVersionSet || baseVersionSet.size === 0) { + continue; + } + + if (baseVersionSet.isSubsetOf(currentVersionSet)) { + continue; + } + + try { + const baseProvenances = await getProvenanceForPackageVersions( + packageName, + baseVersionSet + ); + const currentProvenances = await getProvenanceForPackageVersions( + packageName, + currentVersionSet + ); + + if (baseProvenances.size === 0 || currentProvenances.size === 0) { + continue; + } + + const minBaseTrust = getMinTrustLevel(baseProvenances.values()); + const minCurrentTrust = getMinTrustLevel(currentProvenances.values()); + + if (minCurrentTrust.level < minBaseTrust.level) { + provenanceWarnings.push( + `🛡️ **${packageName}**: trust level decreased (${minBaseTrust.status} → ${minCurrentTrust.status})` + ); + } + } catch (err) { + core.info(`Failed to check provenance for ${packageName}: ${err}`); + } + } + + if (provenanceWarnings.length > 0) { + messages.push( + `## ⚠️ Package Trust Level Decreased + +> [!CAUTION] +> Decreased trust levels may indicate a higher risk of supply chain attacks. Please review these changes carefully. + +These packages have decreased trust levels: + +${provenanceWarnings.join('\n')}` + ); + } + // Compare pack sizes if patterns are provided const basePackagesPattern = core.getInput('base-packages'); const sourcePackagesPattern = core.getInput('source-packages'); diff --git a/src/npm.ts b/src/npm.ts index 4c2d848..fc89dcb 100644 --- a/src/npm.ts +++ b/src/npm.ts @@ -5,6 +5,15 @@ export interface PackageMetadata { version: string; dist?: { unpackedSize?: number; + attestations?: { + url: string; + provenance?: unknown; + }; + }; + _npmUser: { + name: string; + email: string; + trustedPublisher?: unknown; }; dependencies?: Record; } @@ -13,7 +22,72 @@ export interface PackageIndex { versions: Record; } -export async function fetchPackageMetadata( +export type ProvenanceStatus = 'trusted' | 'provenance' | 'none'; + +export function getProvenance(meta: PackageMetadata): ProvenanceStatus { + if (meta._npmUser?.trustedPublisher) { + return 'trusted'; + } + if (meta.dist?.attestations?.provenance) { + return 'provenance'; + } + return 'none'; +} + +export function getTrustLevel(status: ProvenanceStatus): number { + switch (status) { + case 'trusted': + return 2; + case 'provenance': + return 1; + case 'none': + return 0; + default: + return 0; + } +} + +export async function getProvenanceForPackageVersions( + packageName: string, + versions: Set +): Promise> { + const result = new Map(); + for (const version of versions) { + const metadata = await fetchPackageMetadata(packageName, version); + if (metadata) { + result.set(version, getProvenance(metadata)); + } + } + return result; +} + +export interface MinTrustLevelResult { + level: number; + status: ProvenanceStatus; +} + +export function getMinTrustLevel( + statuses: Iterable +): MinTrustLevelResult { + const result: MinTrustLevelResult = {level: 2, status: 'trusted'}; + for (const status of statuses) { + const level = getTrustLevel(status); + if (level < result.level) { + result.level = level; + result.status = status; + } + } + return result; +} + +type MaybePromise = T | Promise; + +export const metaCache = new Map< + string, + MaybePromise +>(); + +async function fetchPackageMetadataImmediate( packageName: string, version: string ): Promise { @@ -30,6 +104,22 @@ export async function fetchPackageMetadata( } } +export async function fetchPackageMetadata( + packageName: string, + version: string +): Promise { + const cacheKey = `${packageName}@${version}`; + const cached = metaCache.get(cacheKey); + if (cached) { + return cached; + } + const meta = fetchPackageMetadataImmediate(packageName, version); + metaCache.set(cacheKey, meta); + const result = await meta; + metaCache.set(cacheKey, result); + return result; +} + export async function calculateTotalDependencySizeIncrease( newVersions: Array<{name: string; version: string}> ): Promise<{totalSize: number; packageSizes: Map} | null> { diff --git a/test/npm_test.ts b/test/npm_test.ts index 18647bd..5b20471 100644 --- a/test/npm_test.ts +++ b/test/npm_test.ts @@ -8,7 +8,7 @@ import { type MockInstance, expect } from 'vitest'; -import {fetchPackageMetadata} from '../src/npm.js'; +import {fetchPackageMetadata, metaCache} from '../src/npm.js'; describe('fetchPackageMetadata', () => { let fetchMock: MockInstance; @@ -28,6 +28,7 @@ describe('fetchPackageMetadata', () => { afterEach(() => { fetchMock.mockRestore(); vi.clearAllMocks(); + metaCache.clear(); }); it('should return null if request fails', async () => { diff --git a/tsconfig.json b/tsconfig.json index e2bfb48..234b4d4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "noEmit": true, - "target": "es2022", + "target": "esnext", "module": "node18", "moduleResolution": "node16", "types": ["node"], From b26ef62a9d306aff245b2e407789d72432fe88ac Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Fri, 26 Sep 2025 12:43:30 +0100 Subject: [PATCH 2/4] wip: try downgrade trust --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ef916b4..00083b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3083,9 +3083,9 @@ } }, "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz", + "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==", "dev": true, "license": "MIT", "engines": { From 6535c53480d51ef69934232eb2007d588691799c Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Fri, 26 Sep 2025 12:45:21 +0100 Subject: [PATCH 3/4] fix: tweak emoji --- README.md | 2 +- main.js | 5 ++++- src/main.ts | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ae9b67c..44b75f3 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ This action compares dependencies between your base branch and current branch, analyzing potential security and maintenance concerns: -- 🛡️ **Package trust levels** - Detects decreases in package trust levels (provenance and trusted publisher status) +- 🔒 **Package trust levels** - Detects decreases in package trust levels (provenance and trusted publisher status) - 📈 **Dependency growth** - Warns when dependency count increases significantly - 📦 **Install size** - Warns when package size increases significantly - 🔄 **Duplicate versions** - Detects packages with multiple versions installed diff --git a/main.js b/main.js index 27596d6..4e62efe 100644 --- a/main.js +++ b/main.js @@ -24553,7 +24553,7 @@ ${packageRows}` const minCurrentTrust = getMinTrustLevel(currentProvenances.values()); if (minCurrentTrust.level < minBaseTrust.level) { provenanceWarnings.push( - `\u{1F4E6} **${packageName}**: trust level decreased (${minBaseTrust.status} \u2192 ${minCurrentTrust.status})` + `\u{1F512} **${packageName}**: trust level decreased (${minBaseTrust.status} \u2192 ${minCurrentTrust.status})` ); } } catch (err) { @@ -24564,6 +24564,9 @@ ${packageRows}` messages.push( `## \u26A0\uFE0F Package Trust Level Decreased +> [!CAUTION] +> Decreased trust levels may indicate a higher risk of supply chain attacks. Please review these changes carefully. + These packages have decreased trust levels: ${provenanceWarnings.join("\n")}` diff --git a/src/main.ts b/src/main.ts index aff6753..34c6001 100644 --- a/src/main.ts +++ b/src/main.ts @@ -232,7 +232,7 @@ ${packageRows}` if (minCurrentTrust.level < minBaseTrust.level) { provenanceWarnings.push( - `🛡️ **${packageName}**: trust level decreased (${minBaseTrust.status} → ${minCurrentTrust.status})` + `🔒 **${packageName}**: trust level decreased (${minBaseTrust.status} → ${minCurrentTrust.status})` ); } } catch (err) { From 747790e905003f7ec45076f2e0657a3290985a50 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Fri, 26 Sep 2025 12:47:55 +0100 Subject: [PATCH 4/4] chore: revert the test --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 00083b8..ef916b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3083,9 +3083,9 @@ } }, "node_modules/tinyspy": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz", - "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", "dev": true, "license": "MIT", "engines": {