Skip to content

Commit 6a4a726

Browse files
authored
feat: diff trust levels (#18)
* feat: diff trust levels * wip: try downgrade trust * fix: tweak emoji * chore: revert the test
1 parent a2443d2 commit 6a4a726

File tree

6 files changed

+253
-7
lines changed

6 files changed

+253
-7
lines changed

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66

77
This action compares dependencies between your base branch and current branch, analyzing potential security and maintenance concerns:
88

9-
- 🔒 **Provenance changes** - Detects loss of provenance
10-
-**Trusted publisher changes** - Detects loss of trusted publish status
9+
- 🔒 **Package trust levels** - Detects decreases in package trust levels (provenance and trusted publisher status)
1110
- 📈 **Dependency growth** - Warns when dependency count increases significantly
1211
- 📦 **Install size** - Warns when package size increases significantly
1312
- 🔄 **Duplicate versions** - Detects packages with multiple versions installed

main.js

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24157,7 +24157,50 @@ function getBaseRef() {
2415724157

2415824158
// src/npm.ts
2415924159
var core2 = __toESM(require_core(), 1);
24160-
async function fetchPackageMetadata(packageName, version) {
24160+
function getProvenance(meta) {
24161+
if (meta._npmUser?.trustedPublisher) {
24162+
return "trusted";
24163+
}
24164+
if (meta.dist?.attestations?.provenance) {
24165+
return "provenance";
24166+
}
24167+
return "none";
24168+
}
24169+
function getTrustLevel(status) {
24170+
switch (status) {
24171+
case "trusted":
24172+
return 2;
24173+
case "provenance":
24174+
return 1;
24175+
case "none":
24176+
return 0;
24177+
default:
24178+
return 0;
24179+
}
24180+
}
24181+
async function getProvenanceForPackageVersions(packageName, versions) {
24182+
const result = /* @__PURE__ */ new Map();
24183+
for (const version of versions) {
24184+
const metadata = await fetchPackageMetadata(packageName, version);
24185+
if (metadata) {
24186+
result.set(version, getProvenance(metadata));
24187+
}
24188+
}
24189+
return result;
24190+
}
24191+
function getMinTrustLevel(statuses) {
24192+
const result = { level: 2, status: "trusted" };
24193+
for (const status of statuses) {
24194+
const level = getTrustLevel(status);
24195+
if (level < result.level) {
24196+
result.level = level;
24197+
result.status = status;
24198+
}
24199+
}
24200+
return result;
24201+
}
24202+
var metaCache = /* @__PURE__ */ new Map();
24203+
async function fetchPackageMetadataImmediate(packageName, version) {
2416124204
try {
2416224205
const url = `https://registry.npmjs.org/${packageName}/${version}`;
2416324206
const response = await fetch(url);
@@ -24170,6 +24213,18 @@ async function fetchPackageMetadata(packageName, version) {
2417024213
return null;
2417124214
}
2417224215
}
24216+
async function fetchPackageMetadata(packageName, version) {
24217+
const cacheKey = `${packageName}@${version}`;
24218+
const cached = metaCache.get(cacheKey);
24219+
if (cached) {
24220+
return cached;
24221+
}
24222+
const meta = fetchPackageMetadataImmediate(packageName, version);
24223+
metaCache.set(cacheKey, meta);
24224+
const result = await meta;
24225+
metaCache.set(cacheKey, result);
24226+
return result;
24227+
}
2417324228
async function calculateTotalDependencySizeIncrease(newVersions) {
2417424229
let totalSize = 0;
2417524230
const processedPackages = /* @__PURE__ */ new Set();
@@ -24473,6 +24528,50 @@ ${packageRows}`
2447324528
core4.info(`Failed to calculate total dependency size increase: ${err}`);
2447424529
}
2447524530
}
24531+
const provenanceWarnings = [];
24532+
for (const [packageName, currentVersionSet] of currentDeps) {
24533+
const baseVersionSet = baseDeps.get(packageName);
24534+
if (!baseVersionSet || baseVersionSet.size === 0) {
24535+
continue;
24536+
}
24537+
if (baseVersionSet.isSubsetOf(currentVersionSet)) {
24538+
continue;
24539+
}
24540+
try {
24541+
const baseProvenances = await getProvenanceForPackageVersions(
24542+
packageName,
24543+
baseVersionSet
24544+
);
24545+
const currentProvenances = await getProvenanceForPackageVersions(
24546+
packageName,
24547+
currentVersionSet
24548+
);
24549+
if (baseProvenances.size === 0 || currentProvenances.size === 0) {
24550+
continue;
24551+
}
24552+
const minBaseTrust = getMinTrustLevel(baseProvenances.values());
24553+
const minCurrentTrust = getMinTrustLevel(currentProvenances.values());
24554+
if (minCurrentTrust.level < minBaseTrust.level) {
24555+
provenanceWarnings.push(
24556+
`\u{1F512} **${packageName}**: trust level decreased (${minBaseTrust.status} \u2192 ${minCurrentTrust.status})`
24557+
);
24558+
}
24559+
} catch (err) {
24560+
core4.info(`Failed to check provenance for ${packageName}: ${err}`);
24561+
}
24562+
}
24563+
if (provenanceWarnings.length > 0) {
24564+
messages.push(
24565+
`## \u26A0\uFE0F Package Trust Level Decreased
24566+
24567+
> [!CAUTION]
24568+
> Decreased trust levels may indicate a higher risk of supply chain attacks. Please review these changes carefully.
24569+
24570+
These packages have decreased trust levels:
24571+
24572+
${provenanceWarnings.join("\n")}`
24573+
);
24574+
}
2447624575
const basePackagesPattern = core4.getInput("base-packages");
2447724576
const sourcePackagesPattern = core4.getInput("source-packages");
2447824577
if (basePackagesPattern && sourcePackagesPattern) {

src/main.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import * as core from '@actions/core';
33
import * as github from '@actions/github';
44
import {parseLockfile, detectLockfile} from './lockfile.js';
55
import {getFileFromRef, getBaseRef} from './git.js';
6-
import {calculateTotalDependencySizeIncrease} from './npm.js';
6+
import {
7+
calculateTotalDependencySizeIncrease,
8+
getMinTrustLevel,
9+
getProvenanceForPackageVersions
10+
} from './npm.js';
711
import {getPacksFromPattern, comparePackSizes} from './packs.js';
812

913
function formatBytes(bytes: number): string {
@@ -196,6 +200,59 @@ ${packageRows}`
196200
}
197201
}
198202

203+
const provenanceWarnings: string[] = [];
204+
205+
for (const [packageName, currentVersionSet] of currentDeps) {
206+
const baseVersionSet = baseDeps.get(packageName);
207+
208+
if (!baseVersionSet || baseVersionSet.size === 0) {
209+
continue;
210+
}
211+
212+
if (baseVersionSet.isSubsetOf(currentVersionSet)) {
213+
continue;
214+
}
215+
216+
try {
217+
const baseProvenances = await getProvenanceForPackageVersions(
218+
packageName,
219+
baseVersionSet
220+
);
221+
const currentProvenances = await getProvenanceForPackageVersions(
222+
packageName,
223+
currentVersionSet
224+
);
225+
226+
if (baseProvenances.size === 0 || currentProvenances.size === 0) {
227+
continue;
228+
}
229+
230+
const minBaseTrust = getMinTrustLevel(baseProvenances.values());
231+
const minCurrentTrust = getMinTrustLevel(currentProvenances.values());
232+
233+
if (minCurrentTrust.level < minBaseTrust.level) {
234+
provenanceWarnings.push(
235+
`🔒 **${packageName}**: trust level decreased (${minBaseTrust.status}${minCurrentTrust.status})`
236+
);
237+
}
238+
} catch (err) {
239+
core.info(`Failed to check provenance for ${packageName}: ${err}`);
240+
}
241+
}
242+
243+
if (provenanceWarnings.length > 0) {
244+
messages.push(
245+
`## ⚠️ Package Trust Level Decreased
246+
247+
> [!CAUTION]
248+
> Decreased trust levels may indicate a higher risk of supply chain attacks. Please review these changes carefully.
249+
250+
These packages have decreased trust levels:
251+
252+
${provenanceWarnings.join('\n')}`
253+
);
254+
}
255+
199256
// Compare pack sizes if patterns are provided
200257
const basePackagesPattern = core.getInput('base-packages');
201258
const sourcePackagesPattern = core.getInput('source-packages');

src/npm.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ export interface PackageMetadata {
55
version: string;
66
dist?: {
77
unpackedSize?: number;
8+
attestations?: {
9+
url: string;
10+
provenance?: unknown;
11+
};
12+
};
13+
_npmUser: {
14+
name: string;
15+
email: string;
16+
trustedPublisher?: unknown;
817
};
918
dependencies?: Record<string, string>;
1019
}
@@ -13,7 +22,72 @@ export interface PackageIndex {
1322
versions: Record<string, PackageMetadata>;
1423
}
1524

16-
export async function fetchPackageMetadata(
25+
export type ProvenanceStatus = 'trusted' | 'provenance' | 'none';
26+
27+
export function getProvenance(meta: PackageMetadata): ProvenanceStatus {
28+
if (meta._npmUser?.trustedPublisher) {
29+
return 'trusted';
30+
}
31+
if (meta.dist?.attestations?.provenance) {
32+
return 'provenance';
33+
}
34+
return 'none';
35+
}
36+
37+
export function getTrustLevel(status: ProvenanceStatus): number {
38+
switch (status) {
39+
case 'trusted':
40+
return 2;
41+
case 'provenance':
42+
return 1;
43+
case 'none':
44+
return 0;
45+
default:
46+
return 0;
47+
}
48+
}
49+
50+
export async function getProvenanceForPackageVersions(
51+
packageName: string,
52+
versions: Set<string>
53+
): Promise<Map<string, ProvenanceStatus>> {
54+
const result = new Map<string, ProvenanceStatus>();
55+
for (const version of versions) {
56+
const metadata = await fetchPackageMetadata(packageName, version);
57+
if (metadata) {
58+
result.set(version, getProvenance(metadata));
59+
}
60+
}
61+
return result;
62+
}
63+
64+
export interface MinTrustLevelResult {
65+
level: number;
66+
status: ProvenanceStatus;
67+
}
68+
69+
export function getMinTrustLevel(
70+
statuses: Iterable<ProvenanceStatus>
71+
): MinTrustLevelResult {
72+
const result: MinTrustLevelResult = {level: 2, status: 'trusted'};
73+
for (const status of statuses) {
74+
const level = getTrustLevel(status);
75+
if (level < result.level) {
76+
result.level = level;
77+
result.status = status;
78+
}
79+
}
80+
return result;
81+
}
82+
83+
type MaybePromise<T> = T | Promise<T>;
84+
85+
export const metaCache = new Map<
86+
string,
87+
MaybePromise<PackageMetadata | null>
88+
>();
89+
90+
async function fetchPackageMetadataImmediate(
1791
packageName: string,
1892
version: string
1993
): Promise<PackageMetadata | null> {
@@ -30,6 +104,22 @@ export async function fetchPackageMetadata(
30104
}
31105
}
32106

107+
export async function fetchPackageMetadata(
108+
packageName: string,
109+
version: string
110+
): Promise<PackageMetadata | null> {
111+
const cacheKey = `${packageName}@${version}`;
112+
const cached = metaCache.get(cacheKey);
113+
if (cached) {
114+
return cached;
115+
}
116+
const meta = fetchPackageMetadataImmediate(packageName, version);
117+
metaCache.set(cacheKey, meta);
118+
const result = await meta;
119+
metaCache.set(cacheKey, result);
120+
return result;
121+
}
122+
33123
export async function calculateTotalDependencySizeIncrease(
34124
newVersions: Array<{name: string; version: string}>
35125
): Promise<{totalSize: number; packageSizes: Map<string, number>} | null> {

test/npm_test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
type MockInstance,
99
expect
1010
} from 'vitest';
11-
import {fetchPackageMetadata} from '../src/npm.js';
11+
import {fetchPackageMetadata, metaCache} from '../src/npm.js';
1212

1313
describe('fetchPackageMetadata', () => {
1414
let fetchMock: MockInstance<typeof globalThis.fetch>;
@@ -28,6 +28,7 @@ describe('fetchPackageMetadata', () => {
2828
afterEach(() => {
2929
fetchMock.mockRestore();
3030
vi.clearAllMocks();
31+
metaCache.clear();
3132
});
3233

3334
it('should return null if request fails', async () => {

tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"compilerOptions": {
33
"noEmit": true,
4-
"target": "es2022",
4+
"target": "esnext",
55
"module": "node18",
66
"moduleResolution": "node16",
77
"types": ["node"],

0 commit comments

Comments
 (0)