Add minimum package age check for pypi

This commit is contained in:
Reinier Criel 2026-03-28 10:15:13 -07:00
parent 2c8a1b4972
commit fd6fb456b4
22 changed files with 516 additions and 273 deletions

View file

@ -4,7 +4,7 @@ import {
getEcoSystem,
} from "../../config/settings.js";
import { npmInterceptorForUrl } from "./npm/npmInterceptor.js";
import { pipInterceptorForUrl } from "./pipInterceptor.js";
import { pipInterceptorForUrl } from "./pip/pipInterceptor.js";
/**
* @param {string} url

View file

@ -0,0 +1,33 @@
import { getMinimumPackageAgeExclusions, getEcoSystem } from "../../config/settings.js";
import { getEquivalentPackageNames } from "../../scanning/packageNameVariants.js";
/**
* Checks if a package name matches an exclusion pattern.
* Supports trailing wildcard (*) for prefix matching.
* @param {string} packageName
* @param {string} pattern
* @returns {boolean}
*/
export function matchesExclusionPattern(packageName, pattern) {
if (pattern.endsWith("/*")) {
return packageName.startsWith(pattern.slice(0, -1));
}
return packageName === pattern;
}
/**
* @param {string | undefined} packageName
* @returns {boolean}
*/
export function isExcludedFromMinimumPackageAge(packageName) {
if (!packageName) {
return false;
}
const exclusions = getMinimumPackageAgeExclusions();
const candidateNames = getEquivalentPackageNames(packageName, getEcoSystem());
return exclusions.some((pattern) =>
candidateNames.some((name) => matchesExclusionPattern(name, pattern))
);
}

View file

@ -196,17 +196,3 @@ export function getPackageNameFromMetadataResponse(body, headers) {
return undefined;
}
}
/**
* Checks if a package name matches an exclusion pattern.
* Supports trailing wildcard (*) for prefix matching.
* @param {string} packageName
* @param {string} pattern
* @returns {boolean}
*/
export function matchesExclusionPattern(packageName, pattern) {
if (pattern.endsWith("/*")) {
return packageName.startsWith(pattern.slice(0, -1));
}
return packageName === pattern;
}

View file

@ -1,6 +1,5 @@
import {
getNpmCustomRegistries,
getNpmMinimumPackageAgeExclusions,
skipMinimumPackageAge,
} from "../../../config/settings.js";
import { isMalwarePackage } from "../../../scanning/audit/index.js";
@ -8,12 +7,14 @@ import { interceptRequests } from "../interceptorBuilder.js";
import {
getPackageNameFromMetadataResponse,
isPackageInfoUrl,
matchesExclusionPattern,
modifyNpmInfoRequestHeaders,
modifyNpmInfoResponse,
} from "./modifyNpmInfo.js";
import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js";
import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.js";
import {
isExcludedFromMinimumPackageAge,
} from "../minimumPackageAgeExclusions.js";
const knownJsRegistries = [
"registry.npmjs.org",
@ -81,17 +82,6 @@ function buildNpmInterceptor(registry) {
});
}
/**
* @param {string} packageName
* @returns {boolean}
*/
function isExcludedFromMinimumPackageAge(packageName) {
const exclusions = getNpmMinimumPackageAgeExclusions();
return exclusions.some((pattern) =>
matchesExclusionPattern(packageName, pattern)
);
}
/**
* @param {Buffer} body
* @param {NodeJS.Dict<string | string[]> | undefined} headers

View file

@ -14,7 +14,7 @@ describe("npmInterceptor minimum package age", async () => {
getMinimumPackageAgeHours: () => minimumPackageAgeSettings,
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
getNpmCustomRegistries: () => [],
getNpmMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting,
getMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting,
getEcoSystem: () => "js",
},
});

View file

@ -28,7 +28,7 @@ mock.module("../../../config/settings.js", {
setEcoSystem: () => {},
getMinimumPackageAgeHours: () => 24,
getNpmCustomRegistries: () => customRegistries,
getNpmMinimumPackageAgeExclusions: () => [],
getMinimumPackageAgeExclusions: () => [],
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
},
});

View file

@ -0,0 +1,64 @@
/**
* @param {string} url
* @param {string} registry
* @returns {{packageName: string | undefined, version: string | undefined}}
*/
export function parsePipPackageFromUrl(url, registry) {
let packageName, version;
if (!registry || typeof url !== "string") {
return { packageName, version };
}
let urlObj;
try {
urlObj = new URL(url);
} catch {
return { packageName, version };
}
const lastSegment = urlObj.pathname.split("/").filter(Boolean).pop();
if (!lastSegment) {
return { packageName, version };
}
const filename = decodeURIComponent(lastSegment);
const wheelExtRe = /\.whl(?:\.metadata)?$/;
if (wheelExtRe.test(filename)) {
const base = filename.replace(wheelExtRe, "");
const firstDash = base.indexOf("-");
if (firstDash > 0) {
const dist = base.slice(0, firstDash);
const rest = base.slice(firstDash + 1);
const secondDash = rest.indexOf("-");
const rawVersion = secondDash >= 0 ? rest.slice(0, secondDash) : rest;
packageName = dist;
version = rawVersion;
if (version === "latest" || !packageName || !version) {
return { packageName: undefined, version: undefined };
}
return { packageName, version };
}
}
const sdistExtWithMetadataRe = /\.(tar\.gz|zip|tar\.bz2|tar\.xz)(\.metadata)?$/i;
if (sdistExtWithMetadataRe.test(filename)) {
const base = filename.replace(sdistExtWithMetadataRe, "");
const lastDash = base.lastIndexOf("-");
if (lastDash > 0 && lastDash < base.length - 1) {
packageName = base.slice(0, lastDash);
version = base.slice(lastDash + 1);
if (version === "latest" || !packageName || !version) {
return { packageName: undefined, version: undefined };
}
return { packageName, version };
}
}
return { packageName: undefined, version: undefined };
}

View file

@ -6,13 +6,25 @@ describe("pipInterceptor custom registries", async () => {
let malwareResponse = false;
let customRegistries = [];
mock.module("../../config/settings.js", {
mock.module("../../../config/settings.js", {
namedExports: {
ECOSYSTEM_PY: "py",
getEcoSystem: () => "py",
getMinimumPackageAgeExclusions: () => [],
getPipCustomRegistries: () => customRegistries,
skipMinimumPackageAge: () => false,
},
});
mock.module("../../scanning/audit/index.js", {
mock.module("../../../scanning/newPackagesListCache.js", {
namedExports: {
openNewPackagesDatabase: async () => ({
isNewlyReleasedPackage: () => false,
}),
},
});
mock.module("../../../scanning/audit/index.js", {
namedExports: {
isMalwarePackage: async (packageName, version) => {
lastPackage = { packageName, version };
@ -30,10 +42,7 @@ describe("pipInterceptor custom registries", async () => {
const interceptor = pipInterceptorForUrl(url);
assert.ok(
interceptor,
"Interceptor should be created for custom registry"
);
assert.ok(interceptor);
});
it("should parse package from custom registry URL", async () => {
@ -42,7 +51,7 @@ describe("pipInterceptor custom registries", async () => {
"https://my-custom-registry.example.com/packages/xx/yy/foobar-1.2.3.tar.gz";
const interceptor = pipInterceptorForUrl(url);
assert.ok(interceptor, "Interceptor should be created");
assert.ok(interceptor);
await interceptor.handleRequest(url);
@ -58,7 +67,7 @@ describe("pipInterceptor custom registries", async () => {
"https://private-pypi.internal.com/packages/foo_bar-2.0.0-py3-none-any.whl";
const interceptor = pipInterceptorForUrl(url);
assert.ok(interceptor, "Interceptor should be created");
assert.ok(interceptor);
await interceptor.handleRequest(url);
@ -82,11 +91,8 @@ describe("pipInterceptor custom registries", async () => {
const interceptor1 = pipInterceptorForUrl(url1);
const interceptor2 = pipInterceptorForUrl(url2);
assert.ok(interceptor1, "Interceptor should be created for first registry");
assert.ok(
interceptor2,
"Interceptor should be created for second registry"
);
assert.ok(interceptor1);
assert.ok(interceptor2);
});
it("should block malicious package from custom registry", async () => {
@ -97,21 +103,13 @@ describe("pipInterceptor custom registries", async () => {
"https://my-custom-registry.example.com/packages/malicious_package-1.0.0.tar.gz";
const interceptor = pipInterceptorForUrl(url);
assert.ok(interceptor, "Interceptor should be created");
assert.ok(interceptor);
const result = await interceptor.handleRequest(url);
assert.ok(result.blockResponse, "Should contain a blockResponse");
assert.equal(
result.blockResponse.statusCode,
403,
"Block response should have status code 403"
);
assert.equal(
result.blockResponse.message,
"Forbidden - blocked by safe-chain",
"Block response should have correct status message"
);
assert.ok(result.blockResponse);
assert.equal(result.blockResponse.statusCode, 403);
assert.equal(result.blockResponse.message, "Forbidden - blocked by safe-chain");
malwareResponse = false;
});
@ -124,10 +122,7 @@ describe("pipInterceptor custom registries", async () => {
const interceptor = pipInterceptorForUrl(url);
assert.ok(
interceptor,
"Interceptor should be created for known registry even with custom registries set"
);
assert.ok(interceptor);
await interceptor.handleRequest(url);
@ -143,11 +138,7 @@ describe("pipInterceptor custom registries", async () => {
const interceptor = pipInterceptorForUrl(url);
assert.equal(
interceptor,
undefined,
"Interceptor should be undefined for unknown registry"
);
assert.equal(interceptor, undefined);
});
it("should handle empty custom registries array", () => {
@ -157,11 +148,7 @@ describe("pipInterceptor custom registries", async () => {
const interceptor = pipInterceptorForUrl(url);
assert.equal(
interceptor,
undefined,
"Interceptor should be undefined when no custom registries are configured"
);
assert.equal(interceptor, undefined);
});
it("should parse .whl.metadata from custom registry", async () => {
@ -170,7 +157,7 @@ describe("pipInterceptor custom registries", async () => {
"https://private-pypi.internal.com/packages/foo_bar-2.0.0-py3-none-any.whl.metadata";
const interceptor = pipInterceptorForUrl(url);
assert.ok(interceptor, "Interceptor should be created");
assert.ok(interceptor);
await interceptor.handleRequest(url);
@ -186,7 +173,7 @@ describe("pipInterceptor custom registries", async () => {
"https://private-pypi.internal.com/packages/foo_bar-2.0.0.tar.gz.metadata";
const interceptor = pipInterceptorForUrl(url);
assert.ok(interceptor, "Interceptor should be created");
assert.ok(interceptor);
await interceptor.handleRequest(url);
@ -196,4 +183,3 @@ describe("pipInterceptor custom registries", async () => {
});
});
});

View file

@ -0,0 +1,80 @@
import {
getPipCustomRegistries,
skipMinimumPackageAge,
} from "../../../config/settings.js";
import { isMalwarePackage } from "../../../scanning/audit/index.js";
import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.js";
import { interceptRequests } from "../interceptorBuilder.js";
import { isExcludedFromMinimumPackageAge } from "../minimumPackageAgeExclusions.js";
import { parsePipPackageFromUrl } from "./parsePipPackageUrl.js";
const knownPipRegistries = [
"files.pythonhosted.org",
"pypi.org",
"pypi.python.org",
"pythonhosted.org",
];
/**
* @param {string} url
* @returns {import("../interceptorBuilder.js").Interceptor | undefined}
*/
export function pipInterceptorForUrl(url) {
const customRegistries = getPipCustomRegistries();
const registries = [...knownPipRegistries, ...customRegistries];
const registry = registries.find((reg) => url.includes(reg));
if (registry) {
return buildPipInterceptor(registry);
}
return undefined;
}
/**
* @param {string} registry
* @returns {import("../interceptorBuilder.js").Interceptor | undefined}
*/
function buildPipInterceptor(registry) {
return interceptRequests(async (reqContext) => {
const { packageName, version } = parsePipPackageFromUrl(
reqContext.targetUrl,
registry
);
// PyPI treats hyphens and underscores as equivalent distribution names.
const hyphenName = packageName?.includes("_")
? packageName.replace(/_/g, "-")
: packageName;
const isMalicious =
await isMalwarePackage(packageName, version) ||
await isMalwarePackage(hyphenName, version);
if (isMalicious) {
reqContext.blockMalware(packageName, version);
return;
}
if (
packageName &&
version &&
!skipMinimumPackageAge() &&
!isExcludedFromMinimumPackageAge(packageName)
) {
const newPackagesDatabase = await openNewPackagesDatabase();
const isNewlyReleased = newPackagesDatabase.isNewlyReleasedPackage(
packageName,
version
);
if (isNewlyReleased) {
reqContext.blockMinimumAgeRequest(
packageName,
version,
`Forbidden - blocked by safe-chain direct download minimum package age (${packageName}@${version})`
);
}
}
});
}

View file

@ -0,0 +1,103 @@
import { describe, it, mock } from "node:test";
import assert from "node:assert";
describe("pipInterceptor minimum package age", async () => {
let skipMinimumPackageAgeSetting = false;
let newlyReleasedPackageResponse = false;
let minimumPackageAgeExclusionsSetting = [];
mock.module("../../../scanning/audit/index.js", {
namedExports: {
isMalwarePackage: async () => false,
},
});
mock.module("../../../scanning/newPackagesListCache.js", {
namedExports: {
openNewPackagesDatabase: async () => ({
isNewlyReleasedPackage: (packageName, version) => {
return newlyReleasedPackageResponse &&
(packageName === "foo-bar" ||
packageName === "foo_bar" ||
packageName === "foo.bar") &&
version === "2.0.0";
},
}),
},
});
mock.module("../../../config/settings.js", {
namedExports: {
ECOSYSTEM_PY: "py",
getEcoSystem: () => "py",
getMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting,
getPipCustomRegistries: () => [],
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
},
});
const { pipInterceptorForUrl } = await import("./pipInterceptor.js");
it("should block newly released package downloads", async () => {
const url =
"https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl";
newlyReleasedPackageResponse = true;
const interceptor = pipInterceptorForUrl(url);
const result = await interceptor.handleRequest(url);
assert.ok(result.blockResponse);
assert.equal(result.blockResponse.statusCode, 403);
assert.equal(
result.blockResponse.message,
"Forbidden - blocked by safe-chain direct download minimum package age (foo_bar@2.0.0)"
);
newlyReleasedPackageResponse = false;
});
it("should not block newly released package downloads when skipMinimumPackageAge is enabled", async () => {
const url =
"https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl";
newlyReleasedPackageResponse = true;
skipMinimumPackageAgeSetting = true;
const interceptor = pipInterceptorForUrl(url);
const result = await interceptor.handleRequest(url);
assert.equal(result.blockResponse, undefined);
skipMinimumPackageAgeSetting = false;
newlyReleasedPackageResponse = false;
});
it("should not block newly released package downloads when the package is excluded", async () => {
const url =
"https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl";
newlyReleasedPackageResponse = true;
minimumPackageAgeExclusionsSetting = ["foo-bar"];
const interceptor = pipInterceptorForUrl(url);
const result = await interceptor.handleRequest(url);
assert.equal(result.blockResponse, undefined);
minimumPackageAgeExclusionsSetting = [];
newlyReleasedPackageResponse = false;
});
it("should not block newly released package downloads when a dot-name package matches a hyphen exclusion", async () => {
const url =
"https://files.pythonhosted.org/packages/xx/yy/foo.bar-2.0.0.tar.gz";
newlyReleasedPackageResponse = true;
minimumPackageAgeExclusionsSetting = ["foo-bar"];
const interceptor = pipInterceptorForUrl(url);
const result = await interceptor.handleRequest(url);
assert.equal(result.blockResponse, undefined);
minimumPackageAgeExclusionsSetting = [];
newlyReleasedPackageResponse = false;
});
});

View file

@ -5,7 +5,7 @@ describe("pipInterceptor", async () => {
let lastPackage;
let malwareResponse = false;
mock.module("../../scanning/audit/index.js", {
mock.module("../../../scanning/audit/index.js", {
namedExports: {
isMalwarePackage: async (packageName, version) => {
lastPackage = { packageName, version };
@ -14,10 +14,27 @@ describe("pipInterceptor", async () => {
},
});
mock.module("../../../scanning/newPackagesListCache.js", {
namedExports: {
openNewPackagesDatabase: async () => ({
isNewlyReleasedPackage: () => false,
}),
},
});
mock.module("../../../config/settings.js", {
namedExports: {
ECOSYSTEM_PY: "py",
getEcoSystem: () => "py",
getMinimumPackageAgeExclusions: () => [],
getPipCustomRegistries: () => [],
skipMinimumPackageAge: () => false,
},
});
const { pipInterceptorForUrl } = await import("./pipInterceptor.js");
const parserCases = [
// Valid pip URLs
{
url: "https://files.pythonhosted.org/packages/xx/yy/foobar-1.2.3.tar.gz",
expected: { packageName: "foobar", version: "1.2.3" },
@ -35,7 +52,6 @@ describe("pipInterceptor", async () => {
expected: { packageName: "foo-bar", version: "2.0.0" },
},
{
// Poetry preflight metadata alongside wheel (.whl.metadata)
url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl.metadata",
expected: { packageName: "foo-bar", version: "2.0.0" },
},
@ -52,7 +68,6 @@ describe("pipInterceptor", async () => {
expected: { packageName: "foo-bar", version: "2.0.0b1" },
},
{
// sdist with metadata sidecar (.tar.gz.metadata)
url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0.tar.gz.metadata",
expected: { packageName: "foo-bar", version: "2.0.0" },
},
@ -76,7 +91,6 @@ describe("pipInterceptor", async () => {
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0-cp38-cp38-manylinux1_x86_64.whl",
expected: { packageName: "foo-bar", version: "2.0.0" },
},
// Invalid pip URLs
{
url: "https://pypi.org/simple/",
expected: { packageName: undefined, version: undefined },
@ -98,10 +112,7 @@ describe("pipInterceptor", async () => {
parserCases.forEach(({ url, expected }, index) => {
it(`should parse URL ${index + 1}: ${url}`, async () => {
const interceptor = pipInterceptorForUrl(url);
assert.ok(
interceptor,
"Interceptor should be created for known npm registry"
);
assert.ok(interceptor, "Interceptor should be created for known pip registry");
await interceptor.handleRequest(url);
@ -111,14 +122,8 @@ describe("pipInterceptor", async () => {
it("should not create interceptor for unknown registry", () => {
const url = "https://example.com/packages/xx/yy/foobar-1.2.3.tar.gz";
const interceptor = pipInterceptorForUrl(url);
assert.equal(
interceptor,
undefined,
"Interceptor should be undefined for unknown registry"
);
assert.equal(interceptor, undefined);
});
it("should block malicious package", async () => {
@ -127,19 +132,15 @@ describe("pipInterceptor", async () => {
malwareResponse = true;
const interceptor = pipInterceptorForUrl(url);
const result = await interceptor.handleRequest(url);
assert.ok(result.blockResponse, "Should contain a blockResponse");
assert.equal(
result.blockResponse.statusCode,
403,
"Block response should have status code 403"
);
assert.ok(result.blockResponse);
assert.equal(result.blockResponse.statusCode, 403);
assert.equal(
result.blockResponse.message,
"Forbidden - blocked by safe-chain",
"Block response should have correct status message"
"Forbidden - blocked by safe-chain"
);
malwareResponse = false;
});
});

View file

@ -1,132 +0,0 @@
import { getPipCustomRegistries } from "../../config/settings.js";
import { isMalwarePackage } from "../../scanning/audit/index.js";
import { interceptRequests } from "./interceptorBuilder.js";
const knownPipRegistries = [
"files.pythonhosted.org",
"pypi.org",
"pypi.python.org",
"pythonhosted.org",
];
/**
* @param {string} url
* @returns {import("./interceptorBuilder.js").Interceptor | undefined}
*/
export function pipInterceptorForUrl(url) {
const customRegistries = getPipCustomRegistries();
const registries = [...knownPipRegistries, ...customRegistries];
const registry = registries.find((reg) => url.includes(reg));
if (registry) {
return buildPipInterceptor(registry);
}
return undefined;
}
/**
* @param {string} registry
* @returns {import("./interceptorBuilder.js").Interceptor | undefined}
*/
function buildPipInterceptor(registry) {
return interceptRequests(async (reqContext) => {
const { packageName, version } = parsePipPackageFromUrl(
reqContext.targetUrl,
registry
);
// Normalize underscores to hyphens for DB matching, as PyPI allows underscores in distribution names.
// Per python, packages that differ only by hyphen vs underscore are considered the same.
const hyphenName = packageName?.includes("_") ? packageName.replace(/_/g, "-") : packageName;
const isMalicious =
await isMalwarePackage(packageName, version)
|| await isMalwarePackage(hyphenName, version);
if (isMalicious) {
reqContext.blockMalware(packageName, version);
}
});
}
/**
* @param {string} url
* @param {string} registry
* @returns {{packageName: string | undefined, version: string | undefined}}
*/
function parsePipPackageFromUrl(url, registry) {
let packageName, version;
// Basic validation
if (!registry || typeof url !== "string") {
return { packageName, version };
}
// Quick sanity check on the URL + parse
let urlObj;
try {
urlObj = new URL(url);
} catch {
return { packageName, version };
}
// Get the last path segment (filename) and decode it (strip query & fragment automatically)
const lastSegment = urlObj.pathname.split("/").filter(Boolean).pop();
if (!lastSegment) {
return { packageName, version };
}
const filename = decodeURIComponent(lastSegment);
// Parse Python package downloads from PyPI/pythonhosted.org
// Example wheel: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1-py3-none-any.whl
// Example sdist: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1.tar.gz
// Wheel (.whl) and Poetry's preflight metadata (.whl.metadata)
// Examples:
// foo_bar-2.0.0-py3-none-any.whl
// foo_bar-2.0.0-py3-none-any.whl.metadata
const wheelExtRe = /\.whl(?:\.metadata)?$/;
const wheelExtMatch = filename.match(wheelExtRe);
if (wheelExtMatch) {
const base = filename.replace(wheelExtRe, "");
const firstDash = base.indexOf("-");
if (firstDash > 0) {
const dist = base.slice(0, firstDash); // may contain underscores
const rest = base.slice(firstDash + 1); // version + the rest of tags
const secondDash = rest.indexOf("-");
const rawVersion = secondDash >= 0 ? rest.slice(0, secondDash) : rest;
packageName = dist;
version = rawVersion;
// Reject "latest" as it's a placeholder, not a real version
// When version is "latest", this signals the URL doesn't contain actual version info
// Returning undefined allows the request (see registryProxy.js isAllowedUrl)
if (version === "latest" || !packageName || !version) {
return { packageName: undefined, version: undefined };
}
return { packageName, version };
}
}
// Source dist (sdist) and potential metadata sidecars (e.g., .tar.gz.metadata)
const sdistExtWithMetadataRe = /\.(tar\.gz|zip|tar\.bz2|tar\.xz)(\.metadata)?$/i;
const sdistExtMatch = filename.match(sdistExtWithMetadataRe);
if (sdistExtMatch) {
const base = filename.replace(sdistExtWithMetadataRe, "");
const lastDash = base.lastIndexOf("-");
if (lastDash > 0 && lastDash < base.length - 1) {
packageName = base.slice(0, lastDash);
version = base.slice(lastDash + 1);
// Reject "latest" as it's a placeholder, not a real version
// When version is "latest", this signals the URL doesn't contain actual version info
// Returning undefined allows the request (see registryProxy.js isAllowedUrl)
if (version === "latest" || !packageName || !version) {
return { packageName: undefined, version: undefined };
}
return { packageName, version };
}
}
// Unknown file type or invalid
return { packageName: undefined, version: undefined };
}