Skip to content
Merged
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
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
101 changes: 100 additions & 1 deletion main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -24473,6 +24528,50 @@ ${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{1F512} **${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

> [!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")}`
);
}
const basePackagesPattern = core4.getInput("base-packages");
const sourcePackagesPattern = core4.getInput("source-packages");
if (basePackagesPattern && sourcePackagesPattern) {
Expand Down
59 changes: 58 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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');
Expand Down
92 changes: 91 additions & 1 deletion src/npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
}
Expand All @@ -13,7 +22,72 @@ export interface PackageIndex {
versions: Record<string, PackageMetadata>;
}

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<string>
): Promise<Map<string, ProvenanceStatus>> {
const result = new Map<string, ProvenanceStatus>();
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<ProvenanceStatus>
): 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> = T | Promise<T>;

export const metaCache = new Map<
string,
MaybePromise<PackageMetadata | null>
>();

async function fetchPackageMetadataImmediate(
packageName: string,
version: string
): Promise<PackageMetadata | null> {
Expand All @@ -30,6 +104,22 @@ export async function fetchPackageMetadata(
}
}

export async function fetchPackageMetadata(
packageName: string,
version: string
): Promise<PackageMetadata | null> {
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<string, number>} | null> {
Expand Down
3 changes: 2 additions & 1 deletion test/npm_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof globalThis.fetch>;
Expand All @@ -28,6 +28,7 @@ describe('fetchPackageMetadata', () => {
afterEach(() => {
fetchMock.mockRestore();
vi.clearAllMocks();
metaCache.clear();
});

it('should return null if request fails', async () => {
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"noEmit": true,
"target": "es2022",
"target": "esnext",
"module": "node18",
"moduleResolution": "node16",
"types": ["node"],
Expand Down