diff --git a/depaudit/checks/licenses.py b/depaudit/checks/licenses.py new file mode 100644 index 0000000..08b1e02 --- /dev/null +++ b/depaudit/checks/licenses.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from depaudit.checks import LicenseInfo + +SPDX_LICENSES = { + "MIT": "mit", + "Apache-2.0": "apache-2.0", + "Apache-2": "apache-2.0", + "BSD-2-Clause": "bsd-2-clause", + "BSD-3-Clause": "bsd-3-clause", + "BSD": "bsd", + "ISC": "isc", + "GPL-2.0": "gpl-2.0", + "GPL-3.0": "gpl-3.0", + "GPL-3": "gpl-3.0", + "LGPL-2.1": "lgpl-2.1", + "LGPL-3.0": "lgpl-3.0", + "MPL-2.0": "mpl-2.0", + "MPL-2": "mpl-2.0", + "AGPL-3.0": "agpl-3.0", + "AGPL-3": "agpl-3.0", + "OSL-3.0": "osl-3.0", + "CC0": "cc0-1.0", + "CC-BY-4.0": "cc-by-4.0", + "CC-BY-SA-4.0": "cc-by-sa-4.0", +} + +RESTRICTIVE_LICENSES = { + "GPL-1.0", + "GPL-2.0", + "GPL-3.0", + "AGPL-1.0", + "AGPL-3.0", + "SSPL", + "OSL-1.0", + "OSL-2.0", + "OSL-3.0", + "QPL-1.0", + "CPOL-1.02", +} + +PERMISSIVE_LICENSES = { + "MIT", + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "MPL-2.0", + "CC0", + "Unlicense", +} + + +def normalize_license(license_str: str) -> str: + license_str = license_str.strip() + + if "OR" in license_str or "AND" in license_str: + licenses = [] + for lic in license_str.replace("(", "").replace(")", "").split(): + if lic in SPDX_LICENSES: + licenses.append(SPDX_LICENSES[lic]) + elif lic.upper() in SPDX_LICENSES: + licenses.append(SPDX_LICENSES[lic.upper()]) + return " AND ".join(licenses) if licenses else license_str + + if license_str.upper() in SPDX_LICENSES: + return SPDX_LICENSES[license_str.upper()] + + for spdx_name, alias in SPDX_LICENSES.items(): + if license_str.lower() == spdx_name.lower() or license_str.lower() == alias.lower(): + return alias + + return license_str + + +def check_license( + package_name: str, + license_str: str | None, + source: str = "unknown", + allowlist: list[str] | None = None, + blocklist: list[str] | None = None, +) -> LicenseInfo: + allowlist = allowlist or [] + blocklist = blocklist or [] + + normalized = None + if license_str: + normalized = normalize_license(license_str) + + is_spdx = normalized in SPDX_LICENSES.values() if normalized else False + + license_info = LicenseInfo( + package_name=package_name, + license_type=normalized or license_str, + license_expression=None, + source=source, + is_spdx_compliant=is_spdx, + ) + + if normalized in RESTRICTIVE_LICENSES: + license_info.license_expression = "restricted" + + return license_info + + +def validate_license_compliance( + license_info: LicenseInfo, + allowlist: list[str], + blocklist: list[str], +) -> tuple[bool, str]: + license_type = license_info.license_type + + if license_type is None: + return False, "Unknown license" + + normalized = normalize_license(license_type) + normalized_blocklist = {normalize_license(lic) for lic in blocklist} + normalized_allowlist = {normalize_license(lic) for lic in allowlist} + + if normalized in normalized_blocklist: + return False, f"License {normalized} is in blocklist" + + if normalized in normalized_allowlist: + return True, "License is allowed" + + if normalized in SPDX_LICENSES.values(): + return True, "SPDX compliant license" + + return False, f"License {license_type} is not in allowlist"