diff --git a/src/fosslight_dependency/_analyze_dependency.py b/src/fosslight_dependency/_analyze_dependency.py index a0af8b0b..57fafad7 100644 --- a/src/fosslight_dependency/_analyze_dependency.py +++ b/src/fosslight_dependency/_analyze_dependency.py @@ -20,6 +20,7 @@ from fosslight_dependency.package_manager.Helm import Helm from fosslight_dependency.package_manager.Unity import Unity from fosslight_dependency.package_manager.Cargo import Cargo +from fosslight_dependency.package_manager.Pnpm import Pnpm import fosslight_util.constant as constant logger = logging.getLogger(constant.LOGGER_NAME) @@ -60,6 +61,8 @@ def analyze_dependency(package_manager_name, input_dir, output_dir, pip_activate package_manager = Unity(input_dir, output_dir) elif package_manager_name == const.CARGO: package_manager = Cargo(input_dir, output_dir) + elif package_manager_name == const.PNPM: + package_manager = Pnpm(input_dir, output_dir) else: logger.error(f"Not supported package manager name: {package_manager_name}") ret = False @@ -84,7 +87,10 @@ def analyze_dependency(package_manager_name, input_dir, output_dir, pip_activate else: logger.error(f"Failed to open input file: {f_name}") ret = False - + if package_manager_name == const.PNPM: + logger.info("Parse oss information for pnpm") + package_manager.parse_oss_information_for_pnpm() + package_dep_item_list.extend(package_manager.dep_items) if ret: logger.warning(f"### Complete to analyze: {package_manager_name}") if package_manager.cover_comment: diff --git a/src/fosslight_dependency/_help.py b/src/fosslight_dependency/_help.py index 73d14af5..81d200b7 100644 --- a/src/fosslight_dependency/_help.py +++ b/src/fosslight_dependency/_help.py @@ -15,6 +15,7 @@ Gradle (Java) Maven (Java) NPM (Node.js) + PNPM (Node.js) PIP (Python) Pub (Dart with flutter) Cocoapods (Swift/Obj-C) @@ -32,7 +33,7 @@ -v\t\t\t\t Print the version of the script. -m \t Enter the package manager. \t(npm, maven, gradle, pypi, pub, cocoapods, android, swift, carthage, - \t go, nuget, helm, unity, cargo) + \t go, nuget, helm, unity, cargo, pnpm) -p \t\t Enter the path where the script will be run. -e \t\t Enter the path where the analysis will not be performed. -o \t\t Output path diff --git a/src/fosslight_dependency/constant.py b/src/fosslight_dependency/constant.py index 64653b42..9a4f21c5 100644 --- a/src/fosslight_dependency/constant.py +++ b/src/fosslight_dependency/constant.py @@ -24,10 +24,12 @@ HELM = 'helm' UNITY = 'unity' CARGO = 'cargo' +PNPM = 'pnpm' # Supported package name and manifest file SUPPORT_PACKAE = { PYPI: ['requirements.txt', 'setup.py', 'pyproject.toml'], + PNPM: 'pnpm-lock.yaml', NPM: 'package.json', MAVEN: 'pom.xml', GRADLE: 'build.gradle', diff --git a/src/fosslight_dependency/package_manager/Npm.py b/src/fosslight_dependency/package_manager/Npm.py index 6f750a51..8113a3ee 100644 --- a/src/fosslight_dependency/package_manager/Npm.py +++ b/src/fosslight_dependency/package_manager/Npm.py @@ -46,7 +46,7 @@ def start_license_checker(self): ret = True license_checker_cmd = f'license-checker --production --json --out {self.input_file_name}' custom_path_option = ' --customPath ' - npm_install_cmd = 'npm install --production' + npm_install_cmd = 'npm install --production --ignore-scripts' if os.path.isdir(node_modules) != 1: logger.info(f"node_modules directory is not existed. So it executes '{npm_install_cmd}'.") diff --git a/src/fosslight_dependency/package_manager/Pnpm.py b/src/fosslight_dependency/package_manager/Pnpm.py new file mode 100644 index 00000000..9b58d61f --- /dev/null +++ b/src/fosslight_dependency/package_manager/Pnpm.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Copyright (c) 2025 LG Electronics Inc. +# SPDX-License-Identifier: Apache-2.0 + +import os +import logging +import subprocess +import json +import shutil +import fosslight_util.constant as constant +import fosslight_dependency.constant as const +from fosslight_dependency._package_manager import PackageManager, get_url_to_purl +from fosslight_dependency.dependency_item import DependencyItem, change_dependson_to_purl +from fosslight_dependency.package_manager.Npm import check_multi_license +from fosslight_util.oss_item import OssItem + +logger = logging.getLogger(constant.LOGGER_NAME) +node_modules = 'node_modules' + + +class Pnpm(PackageManager): + package_manager_name = const.PNPM + + dn_url = 'https://www.npmjs.com/package/' + input_file_name = 'tmp_pnpm_license_output.json' + flag_tmp_node_modules = False + project_name_list = [] + pkg_list = {} + + def __init__(self, input_dir, output_dir): + super().__init__(self.package_manager_name, self.dn_url, input_dir, output_dir) + + def __del__(self): + if os.path.isfile(self.input_file_name): + os.remove(self.input_file_name) + if self.flag_tmp_node_modules: + shutil.rmtree(node_modules, ignore_errors=True) + + def run_plugin(self): + ret = True + + pnpm_install_cmd = 'pnpm install --prod --ignore-scripts --ignore-pnpmfile' + if os.path.isdir(node_modules) != 1: + logger.info(f"node_modules directory is not existed. So it executes '{pnpm_install_cmd}'.") + self.flag_tmp_node_modules = True + cmd_ret = subprocess.call(pnpm_install_cmd, shell=True) + if cmd_ret != 0: + logger.error(f"{pnpm_install_cmd} returns an error") + ret = False + if ret: + project_cmd = 'pnpm ls -r --depth -1 -P --json' + ret_txt = subprocess.check_output(project_cmd, text=True, shell=True) + if ret_txt is not None: + deps_l = json.loads(ret_txt) + for items in deps_l: + self.project_name_list.append(items["name"]) + return ret + + def parse_direct_dependencies(self): + if not self.direct_dep: + return + try: + direct_cmd = 'pnpm ls -r --depth 0 -P --json' + ret_txt = subprocess.check_output(direct_cmd, text=True, shell=True) + if ret_txt is not None: + deps_l = json.loads(ret_txt) + for item in deps_l: + if 'dependencies' in item and isinstance(item['dependencies'], dict): + self.direct_dep_list.extend(item['dependencies'].keys()) + else: + self.direct_dep = False + logger.warning('Cannot print direct/transitive dependency') + except Exception as e: + logger.warning(f'Fail to print direct/transitive dependency: {e}') + self.direct_dep = False + if self.direct_dep: + self.direct_dep_list = list(filter(lambda dep: dep not in self.project_name_list, self.direct_dep_list)) + + def extract_dependencies(self, dependencies, purl_dict): + dep_item_list = [] + for dep_name, dep_info in dependencies.items(): + if dep_name not in self.project_name_list: + if dep_name in self.pkg_list.keys(): + if dep_info.get('version') in self.pkg_list[dep_name]: + continue + self.pkg_list.setdefault(dep_name, []).append(dep_info.get('version')) + dep_item = DependencyItem() + oss_item = OssItem() + oss_item.name = f'npm:{dep_name}' + oss_item.version = dep_info.get('version') + + license_name = dep_info.get('license') + if license_name: + multi_license, license_comment, multi_flag = check_multi_license(license_name, '') + if multi_flag: + oss_item.comment = license_comment + license_name = multi_license + else: + license_name = license_name.replace(",", "") + oss_item.license = license_name + + oss_item.homepage = f'{self.dn_url}{dep_name}' + oss_item.download_location = dep_info.get('repository') + if oss_item.download_location: + if oss_item.download_location.endswith('.git'): + oss_item.download_location = oss_item.download_location[:-4] + if oss_item.download_location.startswith('git://'): + oss_item.download_location = 'https://' + oss_item.download_location[6:] + elif oss_item.download_location.startswith('git+https://'): + oss_item.download_location = 'https://' + oss_item.download_location[12:] + elif oss_item.download_location.startswith('git+ssh://git@'): + oss_item.download_location = 'https://' + oss_item.download_location[14:] + else: + oss_item.download_location = f'{self.dn_url}{dep_name}/v/{oss_item.version}' + + dn_loc = f'{oss_item.homepage}/v/{oss_item.version}' + dep_item.purl = get_url_to_purl(dn_loc, 'npm') + purl_dict[f'{dep_name}({oss_item.version})'] = dep_item.purl + + if dep_name in self.direct_dep_list: + oss_item.comment = 'direct' + else: + oss_item.comment = 'transitive' + + if 'dependencies' in dep_info: + for dn, di in dep_info.get('dependencies').items(): + if dn not in self.project_name_list: + dep_item.depends_on_raw.append(f"{dn}({di['version']})") + + dep_item.oss_items.append(oss_item) + dep_item_list.append(dep_item) + + if 'dependencies' in dep_info: + dep_item_list_inner, purl_dict_inner = self.extract_dependencies(dep_info['dependencies'], purl_dict) + dep_item_list.extend(dep_item_list_inner) + purl_dict.update(purl_dict_inner) + + return dep_item_list, purl_dict + + def parse_oss_information_for_pnpm(self): + project_cmd = 'pnpm ls --json -r --depth Infinity -P --long' + ret_txt = subprocess.check_output(project_cmd, text=True, shell=True) + if ret_txt is not None: + deps_l = json.loads(ret_txt) + purl_dict = {} + for items in deps_l: + if 'dependencies' in items: + dep_item_list_inner, purl_dict_inner = self.extract_dependencies(items['dependencies'], purl_dict) + self.dep_items.extend(dep_item_list_inner) + purl_dict.update(purl_dict_inner) + if self.direct_dep: + self.dep_items = change_dependson_to_purl(purl_dict, self.dep_items) + else: + logger.warning(f'No output for {project_cmd}') diff --git a/src/fosslight_dependency/run_dependency_scanner.py b/src/fosslight_dependency/run_dependency_scanner.py index dcb0bdb3..140105b7 100755 --- a/src/fosslight_dependency/run_dependency_scanner.py +++ b/src/fosslight_dependency/run_dependency_scanner.py @@ -100,8 +100,11 @@ def find_package_manager(input_dir, abs_path_to_exclude=[], manifest_file_name=[ if value == f_idx: found_package_manager[key] = [f_idx] + # both npm and pnpm are detected, remove npm. + if 'npm' in found_package_manager and 'pnpm' in found_package_manager: + del found_package_manager['npm'] if len(found_package_manager) >= 1: - manifest_file_w_path = map(lambda x: os.path.join(input_dir, x), found_manifest_file) + manifest_file_w_path = [os.path.join(input_dir, file) for pkg, files in found_package_manager.items() for file in files] logger.info(f"Found the manifest file({','.join(manifest_file_w_path)}) automatically.") logger.warning(f"### Set Package Manager = {', '.join(found_package_manager.keys())}") else: