js-green-licenses
Version: 
JavaScript package.json license checker
445 lines • 17.7 kB
JavaScript
;
// Copyright 2017 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
    if (mod && mod.__esModule) return mod;
    var result = {};
    if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
    __setModuleDefault(result, mod);
    return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.LicenseChecker = exports.GitHubRepository = void 0;
const events_1 = require("events");
const fs = __importStar(require("fs"));
const npm_package_arg_1 = __importDefault(require("npm-package-arg"));
const package_json_1 = __importDefault(require("package-json"));
const path = __importStar(require("path"));
const spdx_correct_1 = __importDefault(require("spdx-correct"));
const spdx_satisfies_1 = __importDefault(require("spdx-satisfies"));
const util_1 = require("util");
const semver_1 = __importDefault(require("semver"));
const config = __importStar(require("./config"));
const github_1 = require("./github");
const package_json_file_1 = require("./package-json-file");
var github_2 = require("./github");
Object.defineProperty(exports, "GitHubRepository", { enumerable: true, get: function () { return github_2.GitHubRepository; } });
const fsAccess = (0, util_1.promisify)(fs.access);
const fsReadDir = (0, util_1.promisify)(fs.readdir);
const fsReadFile = (0, util_1.promisify)(fs.readFile);
// Valid license IDs defined in https://spdx.org/licenses/ must be used whenever
// possible. When adding new licenses, please consult the relevant documents.
const DEFAULT_GREEN_LICENSES = [
    '0BSD',
    'AFL-2.1',
    'AFL-3.0',
    'APSL-2.0',
    'Apache-1.1',
    'Apache-2.0',
    'Artistic-1.0',
    'Artistic-2.0',
    'BSD-2-Clause',
    'BSD-3-Clause',
    'BSL-1.0',
    'CC-BY-1.0',
    'CC-BY-2.0',
    'CC-BY-2.5',
    'CC-BY-3.0',
    'CC-BY-4.0',
    'CC0-1.0',
    'CDDL-1.0',
    'CDDL-1.1',
    'CPL-1.0',
    'EPL-1.0',
    'FTL',
    'IPL-1.0',
    'ISC',
    'LGPL-2.0',
    'LGPL-2.1',
    'LGPL-3.0',
    'LPL-1.02',
    'MIT',
    'MPL-1.0',
    'MPL-1.1',
    'MPL-2.0',
    'MS-PL',
    'NCSA',
    'OpenSSL',
    'PHP-3.0',
    'Ruby',
    'Unlicense',
    'W3C',
    'Xnet',
    'ZPL-2.0',
    'Zend-2.0',
    'Zlib',
    'libtiff',
];
class LicenseChecker extends events_1.EventEmitter {
    constructor({ dev = false, verbose = false } = {}) {
        super();
        // Cache for packageName@version's that are already processed.
        this.processedPackages = new Set();
        // Cache for packageName@version's that failed for fetching.
        this.failedPackages = new Set();
        // Local packages, for monorepo
        this.localPackages = new Set();
        this.config = {};
        // Licenses in this expression must be valid license IDs defined in
        // https://spdx.org/licenses/.
        this.greenLicenseExpr = '';
        // List of license names that are not SPDX-conforming IDs but are allowed.
        this.allowlistedLicenses = [];
        this.opts = { dev, verbose };
    }
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    on(event, listener) {
        return super.on(event, listener);
    }
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    emit(event, ...args) {
        return super.emit(event, ...args);
    }
    init(cfg) {
        this.config = cfg || {};
        const greenLicenses = this.config.greenLicenses || DEFAULT_GREEN_LICENSES;
        const validGreenLicenses = [];
        const invalidGreenLicenses = [];
        for (const license of greenLicenses) {
            const corrected = this.correctLicenseName(license);
            if (corrected) {
                validGreenLicenses.push(corrected);
            }
            else {
                invalidGreenLicenses.push(license);
            }
        }
        this.greenLicenseExpr = `(${validGreenLicenses.join(' OR ')})`;
        this.allowlistedLicenses = invalidGreenLicenses;
        this.processedPackages.clear();
        this.failedPackages.clear();
    }
    getLicense(pkgJson) {
        // Some package.json files have incorrect license fields, and old packages
        // may have legacy licence field format. See
        // https://docs.npmjs.com/files/package.json#license for details. The code
        // below is a little complicated to deal with those cases.
        const license = pkgJson.license || pkgJson.licenses;
        if (!license) {
            if (pkgJson.private) {
                return 'private';
            }
            return null;
        }
        if (typeof license === 'string') {
            return license;
        }
        if (Array.isArray(license)) {
            if (license.length === 0) {
                return null;
            }
            const types = license.map(x => x.type).filter(x => !!x);
            return types.length === 1 ? types[0] : `(${types.join(' OR ')})`;
        }
        return license.type || null;
    }
    correctLicenseName(license) {
        // NPM specific value.
        if (license === 'UNLICENSED' || license === 'UNLICENCED') {
            console.warn(`Unlicensed package, specified license: ${license}`);
            return 'UNLICENSED';
        }
        const corrected = (0, spdx_correct_1.default)(license);
        if (this.opts.verbose && corrected && corrected !== license) {
            console.warn(`Correcting ${license} to ${corrected}`);
        }
        return corrected;
    }
    isPackageAllowlisted(packageName) {
        return (!!this.config.packageAllowlist &&
            this.config.packageAllowlist.some(p => p === packageName));
    }
    isGreenLicense(license) {
        if (!license)
            return false;
        const correctedName = this.correctLicenseName(license);
        // `license` is not a valid or correctable SPDX id. Check the allowlist.
        if (!correctedName) {
            return this.allowlistedLicenses.some(l => l === license);
        }
        try {
            return (0, spdx_satisfies_1.default)(correctedName, this.greenLicenseExpr);
        }
        catch (e) {
            const err = e;
            // Most likely because license is not recognized. Just return false.
            if (this.opts.verbose) {
                console.error(err.message || err);
            }
            return false;
        }
    }
    async getPackageJson(packageName, versionSpec, localDirectory) {
        // If this has a relative URL, and is a local package, find the package json from the
        // indicated directory
        if (versionSpec.startsWith('file:') && localDirectory) {
            const relativePath = versionSpec.slice('file:'.length);
            const packageJsonPath = path.join(localDirectory, relativePath, 'package.json');
            this.emit('package.json', packageJsonPath);
            const contents = await fsReadFile(packageJsonPath, 'utf8');
            return JSON.parse(contents);
        }
        return (0, package_json_1.default)(packageName, {
            version: versionSpec,
            fullMetadata: true,
        });
    }
    async checkLicenses(packageName, versionSpec, localDirectory, ...parents) {
        const spec = `${packageName}@${versionSpec}`;
        if (this.failedPackages.has(spec))
            return;
        // remove tilde/caret to check for an exact version, ^0.5.0-rc.0 becomes 0.5.0-rc.0
        const version = versionSpec.replace(/^[^~]/, '');
        // if the dependency is a local package then skip verification at this step. will be checked independently
        if (this.localPackages.has(`${packageName}@${version}`))
            return;
        try {
            const json = await this.getPackageJson(packageName, versionSpec, localDirectory);
            await this.checkPackageJson(json, packageName, localDirectory, ...parents);
        }
        catch (e) {
            const err = e;
            this.failedPackages.add(spec);
            this.emit('error', {
                err,
                packageName,
                versionSpec,
                parentPackages: parents,
            });
        }
    }
    async checkLicensesForDeps(deps, localDirectory, ...parents) {
        if (!deps)
            return;
        for (const pkg of Object.keys(deps)) {
            const depVersion = deps[pkg];
            await this.checkLicenses(pkg, depVersion, localDirectory, ...parents);
        }
    }
    async checkPackageJson(json, packageName, localDirectory, ...parents) {
        packageName = (packageName || json.name || 'undefined');
        const isAllowlisted = this.isPackageAllowlisted(packageName);
        if (isAllowlisted) {
            json.version = semver_1.default.valid(json.version) ? json.version : '0.0.0';
        }
        else {
            (0, package_json_file_1.ensurePackageJson)(json);
        }
        if (json.name !== packageName) {
            console.warn(`Package name mismatch. Expected ${packageName}, but got ${json.name}`);
        }
        const pkgVersion = json.version;
        const packageAndVersion = `${packageName}@${pkgVersion}`;
        if (this.processedPackages.has(packageAndVersion))
            return;
        this.processedPackages.add(packageAndVersion);
        if (this.isPackageAllowlisted(packageName)) {
            console.log(`${packageName} is allowlisted.`);
        }
        else {
            const license = this.getLicense(json);
            if (!this.isGreenLicense(license)) {
                this.emit('non-green-license', {
                    packageName,
                    version: pkgVersion || 'undefined',
                    licenseName: license,
                    parentPackages: parents,
                });
            }
        }
        await this.checkLicensesForDeps(json.dependencies, localDirectory, ...parents, packageAndVersion);
        if (this.opts.dev) {
            await this.checkLicensesForDeps(json.devDependencies, localDirectory, ...parents, packageAndVersion);
        }
    }
    async checkPackageJsonContent(content, localDirectory) {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        let json = null;
        try {
            json = JSON.parse(content);
            await this.checkPackageJson(json, json.name, localDirectory);
        }
        catch (e) {
            const err = e;
            const packageName = (json === null || json === void 0 ? void 0 : json.name) || '(unknown package)';
            const versionSpec = (json === null || json === void 0 ? void 0 : json.version) || '(unknown version)';
            this.emit('error', {
                err,
                packageName,
                versionSpec,
                parentPackages: [],
            });
        }
    }
    async getLocalPackageJsonFiles(directory) {
        const packageJsons = [];
        const addPackageJson = async (dir) => {
            try {
                const pj = path.join(dir, 'package.json');
                await fsAccess(pj);
                packageJsons.push(pj);
            }
            catch (_a) {
                // package.json doesn't exist. Ignore.
            }
        };
        // Find the top-level package.json first.
        await addPackageJson(directory);
        // Find `packages/<name>/package.json` files in case this is a monorepo.
        try {
            const packages = path.join(directory, 'packages');
            const subpackages = await fsReadDir(packages);
            // This is a monorepo. Find package.json from each directory under
            // `packages`.
            for (const dir of subpackages) {
                await addPackageJson(path.join(packages, dir));
            }
        }
        catch (_a) {
            // The `packages` directory doesn't exist. Not a monorepo. Return just the
            // top-level package.json.
        }
        return packageJsons;
    }
    async checkLocalDirectory(directory) {
        this.init(await config.getLocalConfig(directory));
        const packageJsons = await this.getLocalPackageJsonFiles(directory);
        if (packageJsons.length === 0) {
            console.log('No package.json files have been found.');
        }
        for (const pj of packageJsons) {
            const content = await fsReadFile(pj, 'utf8');
            const json = JSON.parse(content);
            if (json && json.name && json.version) {
                this.localPackages.add(`${json.name}@${json.version}`);
            }
        }
        for (const pj of packageJsons) {
            this.emit('package.json', pj);
            const content = await fsReadFile(pj, 'utf8');
            await this.checkPackageJsonContent(content, path.dirname(pj));
        }
        this.emit('end');
    }
    async checkRemotePackage(pkg) {
        // For checking remote packages, use config file in the current directory.
        this.init(await config.getLocalConfig(process.cwd()));
        const pkgArgs = (0, npm_package_arg_1.default)(pkg);
        const pkgType = pkgArgs.type;
        if (!['tag', 'version', 'range'].some(type => type === pkgType)) {
            throw new Error(`Unsupported package spec: ${pkg}`);
        }
        if (!pkgArgs.name || !pkgArgs.fetchSpec) {
            throw new Error(`Invalid package spec: ${pkg}`);
        }
        await this.checkLicenses(pkgArgs.name, pkgArgs.fetchSpec, null);
        this.emit('end');
    }
    /** @param prPath Must be in a form of <owner>/<repo>/pull/<id>. */
    prPathToGitHubRepoAndId(prPath) {
        const regexp = /^([^/]+)\/([^/]+)\/pull\/(\d+)$/;
        const matched = regexp.exec(prPath);
        if (!matched) {
            throw new Error(`Invalid github pull request path: ${prPath}. ` +
                'Must be in the form <owner>/<repo>/pull/<id>.');
        }
        const [, owner, repoName, prId] = matched;
        return { repo: new github_1.GitHubRepository(owner, repoName), prId: Number(prId) };
    }
    async checkGitHubPR(repo, mergeCommitSha) {
        this.init(await config.getGitHubConfig(repo, mergeCommitSha));
        const packageJsons = await repo.getPackageJsonFiles(mergeCommitSha);
        if (packageJsons.length === 0) {
            console.log('No package.json files have been found.');
        }
        for (const pj of packageJsons) {
            this.emit('package.json', pj.filePath);
            await this.checkPackageJsonContent(pj.content, null);
        }
        this.emit('end');
    }
    /** set default event handlers for CLI output. */
    setDefaultHandlers(options = {}) {
        let nonGreenCount = 0;
        let errorCount = 0;
        this.on('non-green-license', ({ packageName, version, licenseName, parentPackages }) => {
            nonGreenCount++;
            const licenseDisplay = licenseName || '(no license)';
            const packageAndVersion = `${packageName}@${version}`;
            console.log(`${licenseDisplay}: ${packageAndVersion}`);
            console.log(`  ${[...parentPackages, packageAndVersion].join(' -> ')}`);
            console.log();
        })
            .on('package.json', filePath => {
            console.log(`Checking ${filePath}...`);
            console.log();
        })
            .on('error', ({ err, packageName, versionSpec, parentPackages }) => {
            errorCount++;
            const packageAndVersion = `${packageName}@${versionSpec}`;
            console.log(`Error while checking ${packageAndVersion}:`);
            console.log(`  ${[...parentPackages, packageAndVersion].join(' -> ')}`);
            console.log();
            console.log(`${(0, util_1.inspect)(err)}`);
            console.log();
        })
            .on('end', () => {
            if (nonGreenCount > 0 || errorCount > 0) {
                if (options.setExitCode) {
                    process.exitCode = 1;
                }
                if (nonGreenCount > 0) {
                    console.log(`${nonGreenCount} non-green licenses found.`);
                }
                if (errorCount > 0) {
                    console.log(`${errorCount} errors found.`);
                }
            }
            else {
                console.log('All green!');
            }
        });
    }
}
exports.LicenseChecker = LicenseChecker;
//# sourceMappingURL=checker.js.map