diff --git a/README.md b/README.md index bc61787..290304f 100644 --- a/README.md +++ b/README.md @@ -214,16 +214,16 @@ You can set the minimum package age through multiple sources (in order of priori ### Excluding Packages -Exclude trusted packages from minimum age filtering via environment variable or config file (both are merged): +Exclude trusted packages from minimum age filtering via environment variable or config file (both are merged). Supports wildcard patterns with trailing `*`: ```shell -export SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS="react,@aikidosec/safe-chain" +export SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS="@aikidosec/*,react-*,lodash" ``` ```json { "npm": { - "minimumPackageAgeExclusions": ["react", "@aikidosec/safe-chain"] + "minimumPackageAgeExclusions": ["@aikidosec/*", "react-*", "lodash"] } } ``` diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js index 3407397..9a36207 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -68,7 +68,7 @@ export function modifyNpmInfoResponse(body, headers) { // Check if this package is excluded from minimum age filtering const packageName = bodyJson.name; const exclusions = getNpmMinimumPackageAgeExclusions(); - if (packageName && exclusions.includes(packageName)) { + if (packageName && exclusions.some((pattern) => matchesExclusionPattern(packageName, pattern))) { ui.writeVerbose( `Safe-chain: ${packageName} is excluded from minimum package age filtering (minimumPackageAgeExclusions setting).` ); @@ -187,3 +187,17 @@ function getMostRecentTag(tagList) { export function getHasSuppressedVersions() { return state.hasSuppressedVersions; } + +/** + * Checks if a package name matches an exclusion pattern. + * Supports trailing wildcard (*) for prefix matching. + * @param {string} packageName + * @param {string} pattern + * @returns {boolean} + */ +function matchesExclusionPattern(packageName, pattern) { + if (pattern.endsWith("*")) { + return packageName.startsWith(pattern.slice(0, -1)); + } + return packageName === pattern; +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js index ed00909..82fed71 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js @@ -481,6 +481,87 @@ describe("npmInterceptor minimum package age", async () => { assert.equal(Object.keys(modifiedJson.versions).length, 2); }); + it("Should exclude packages matching wildcard pattern @scope/*", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = false; + minimumPackageAgeExclusionsSetting = ["@aikidosec/*"]; + + const packageUrl = "https://registry.npmjs.org/@aikidosec/safe-chain"; + + const originalBody = JSON.stringify({ + name: "@aikidosec/safe-chain", + ["dist-tags"]: { latest: "2.0.0" }, + versions: { ["1.0.0"]: {}, ["2.0.0"]: {} }, + time: { + created: getDate(-365 * 24), + modified: getDate(-1), + ["1.0.0"]: getDate(-100), + ["2.0.0"]: getDate(-1), // Would normally be filtered + }, + }); + + const modifiedBody = await runModifyNpmInfoRequest(packageUrl, originalBody); + const modifiedJson = JSON.parse(modifiedBody); + + // All versions should remain since @aikidosec/* matches @aikidosec/safe-chain + assert.equal(Object.keys(modifiedJson.versions).length, 2); + assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0")); + assert.ok(Object.keys(modifiedJson.versions).includes("2.0.0")); + }); + + it("Should exclude packages matching wildcard pattern prefix-*", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = false; + minimumPackageAgeExclusionsSetting = ["react-*"]; + + const packageUrl = "https://registry.npmjs.org/react-dom"; + + const originalBody = JSON.stringify({ + name: "react-dom", + ["dist-tags"]: { latest: "18.0.0" }, + versions: { ["17.0.0"]: {}, ["18.0.0"]: {} }, + time: { + created: getDate(-365 * 24), + modified: getDate(-1), + ["17.0.0"]: getDate(-100), + ["18.0.0"]: getDate(-1), // Would normally be filtered + }, + }); + + const modifiedBody = await runModifyNpmInfoRequest(packageUrl, originalBody); + const modifiedJson = JSON.parse(modifiedBody); + + // All versions should remain since react-* matches react-dom + assert.equal(Object.keys(modifiedJson.versions).length, 2); + }); + + it("Should NOT exclude packages that don't match wildcard pattern", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = false; + minimumPackageAgeExclusionsSetting = ["@aikidosec/*"]; + + const packageUrl = "https://registry.npmjs.org/@other/package"; + + const originalBody = JSON.stringify({ + name: "@other/package", + ["dist-tags"]: { latest: "2.0.0" }, + versions: { ["1.0.0"]: {}, ["2.0.0"]: {} }, + time: { + created: getDate(-365 * 24), + modified: getDate(-1), + ["1.0.0"]: getDate(-100), + ["2.0.0"]: getDate(-1), + }, + }); + + const modifiedBody = await runModifyNpmInfoRequest(packageUrl, originalBody); + const modifiedJson = JSON.parse(modifiedBody); + + // Version 2.0.0 should be filtered since @other/package doesn't match @aikidosec/* + assert.equal(Object.keys(modifiedJson.versions).length, 1); + assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0")); + }); + it("Should reset exclusions between tests", async () => { minimumPackageAgeSettings = 5; skipMinimumPackageAgeSetting = false;