diff --git a/Makefile b/Makefile index 02b627e29..3ec852abf 100644 --- a/Makefile +++ b/Makefile @@ -270,6 +270,7 @@ test-go: go test ./golang/... # Run the integration tests. +# Note: to disable npm tests set `NO_NPM` environment variable. .PHONY: integration-test integration-test: scripts/dev_scripts/integration_tests.sh $(REPO_PATH) "${HOME}" diff --git a/scripts/dev_scripts/integration_tests.sh b/scripts/dev_scripts/integration_tests.sh index cf59200de..7a85817a7 100755 --- a/scripts/dev_scripts/integration_tests.sh +++ b/scripts/dev_scripts/integration_tests.sh @@ -38,6 +38,13 @@ function check_or_update_expected_output() { fi } +# Check if npm-related tests should be disabled. +if [[ ! -z "$NO_NPM" ]]; then + echo "Note: NO_NPM environment variable is set, so npm tests will be skipped." +fi +NO_NPM_TEST=$NO_NPM + + function log_fail() { printf "Error: FAILED integration test (line ${BASH_LINENO}) %s\n" $@ RESULT_CODE=1 @@ -66,6 +73,17 @@ $RUN_MACARON analyze -rp https://github.com/micronaut-projects/micronaut-core -b check_or_update_expected_output $COMPARE_JSON_OUT $JSON_RESULT $JSON_EXPECTED || log_fail +if [[ -z "$NO_NPM_TEST" ]]; then + echo -e "\n----------------------------------------------------------------------------------" + echo "sigstore/mock@0.1.0: Analyzing the PURL when automatic dependency resolution is skipped." + echo -e "----------------------------------------------------------------------------------\n" + JSON_EXPECTED=$WORKSPACE/tests/e2e/expected_results/purl/npm/sigstore/mock/mock.json + JSON_RESULT=$WORKSPACE/output/reports/npm/_sigstore/mock/mock.json + $RUN_MACARON analyze -purl pkg:npm/@sigstore/mock@0.1.0 --skip-deps || log_fail + + check_or_update_expected_output $COMPARE_JSON_OUT $JSON_RESULT $JSON_EXPECTED || log_fail +fi + echo -e "\n----------------------------------------------------------------------------------" echo "gitlab.com/tinyMediaManager/tinyMediaManager: Analyzing the repo path and the branch name when automatic dependency resolution is skipped." echo -e "----------------------------------------------------------------------------------\n" diff --git a/src/macaron/config/defaults.ini b/src/macaron/config/defaults.ini index 63d57b834..12033a330 100644 --- a/src/macaron/config/defaults.ini +++ b/src/macaron/config/defaults.ini @@ -465,3 +465,12 @@ hostname = search.maven.org # The search REST API. See https://central.sonatype.org/search/rest-api-guide/ search_endpoint = solrsearch/select request_timeout = 20 + +[package_registry.npm] +# Set `enabled=False` to disable making REST API calls to the npm registry. +enabled = True +# npm registry host name. +hostname = registry.npmjs.org +# The attestation REST API. +attestation_endpoint = -/npm/v1/attestations +request_timeout = 20 diff --git a/src/macaron/slsa_analyzer/analyze_context.py b/src/macaron/slsa_analyzer/analyze_context.py index 3a6e79661..0c5c19c32 100644 --- a/src/macaron/slsa_analyzer/analyze_context.py +++ b/src/macaron/slsa_analyzer/analyze_context.py @@ -8,6 +8,7 @@ import logging import os +from collections import defaultdict from typing import TypedDict from macaron.database.table_definitions import Component, SLSALevel @@ -16,6 +17,8 @@ from macaron.slsa_analyzer.git_service.base_git_service import NoneGitService from macaron.slsa_analyzer.levels import SLSALevels from macaron.slsa_analyzer.provenance.expectations.expectation import Expectation +from macaron.slsa_analyzer.provenance.intoto.v01 import InTotoV01Statement +from macaron.slsa_analyzer.provenance.intoto.v1 import InTotoV1Statement from macaron.slsa_analyzer.slsa_req import ReqName, SLSAReqStatus, create_requirement_status_dict from macaron.slsa_analyzer.specs.build_spec import BuildSpec from macaron.slsa_analyzer.specs.ci_spec import CIInfo @@ -91,25 +94,27 @@ def __init__( ) @property - def provenances(self) -> dict: + def provenances(self) -> dict[str, list[InTotoV01Statement | InTotoV1Statement]]: """Return the provenances data as a dictionary. Returns ------- - dict + dict[str : list[InTotoV01Statement]] A dictionary in which each key is a CI service's name and each value is the corresponding provenance payload. """ try: ci_services = self.dynamic_data["ci_services"] - result = {} + + # By default, initialize every key with an empty list. + result: dict[str, list[InTotoV01Statement | InTotoV1Statement]] = defaultdict(lambda: []) for ci_info in ci_services: - result[ci_info["service"].name] = [payload.statement for payload in ci_info["provenances"]] + result[ci_info["service"].name].extend(payload.statement for payload in ci_info["provenances"]) package_registry_entries = self.dynamic_data["package_registries"] for package_registry_entry in package_registry_entries: - result[package_registry_entry.package_registry.name] = [ + result[package_registry_entry.package_registry.name].extend( provenance.payload.statement for provenance in package_registry_entry.provenances - ] + ) return result except KeyError: return {} diff --git a/src/macaron/slsa_analyzer/analyzer.py b/src/macaron/slsa_analyzer/analyzer.py index a260fc8e4..0be509f6c 100644 --- a/src/macaron/slsa_analyzer/analyzer.py +++ b/src/macaron/slsa_analyzer/analyzer.py @@ -6,6 +6,7 @@ import logging import os import sys +import urllib.parse from datetime import datetime, timezone from pathlib import Path from typing import Any, NamedTuple @@ -540,6 +541,14 @@ def to_analysis_target(config: Configuration, available_domains: list[str]) -> A except ValueError as error: raise InvalidPURLError(f"Invalid input PURL: {config.get_value('purl')}") from error + # Validate the purl object, which is user-controllable. + if parsed_purl: + purl_str = parsed_purl.to_string() + # The urlparse API sanitizes unsafe characters. + parsed_url = urllib.parse.urlparse(purl_str) + if purl_str != parsed_url.geturl(): + raise ValueError("The PURL provided as input has unsafe characters.") + repo_path_input: str = config.get_value("path") input_branch: str = config.get_value("branch") input_digest: str = config.get_value("digest") diff --git a/src/macaron/slsa_analyzer/checks/provenance_available_check.py b/src/macaron/slsa_analyzer/checks/provenance_available_check.py index 06ec813f7..ad9d338f5 100644 --- a/src/macaron/slsa_analyzer/checks/provenance_available_check.py +++ b/src/macaron/slsa_analyzer/checks/provenance_available_check.py @@ -13,19 +13,23 @@ from sqlalchemy.sql.sqltypes import String from macaron.config.defaults import defaults -from macaron.database.table_definitions import CheckFacts +from macaron.database.table_definitions import CheckFacts, Component from macaron.errors import MacaronError from macaron.slsa_analyzer.analyze_context import AnalyzeContext from macaron.slsa_analyzer.asset import AssetLocator from macaron.slsa_analyzer.build_tool.gradle import Gradle +from macaron.slsa_analyzer.build_tool.npm import NPM +from macaron.slsa_analyzer.build_tool.yarn import Yarn from macaron.slsa_analyzer.checks.base_check import BaseCheck from macaron.slsa_analyzer.checks.check_result import CheckResultData, CheckResultType, Justification, ResultTables from macaron.slsa_analyzer.ci_service.base_ci_service import NoneCIService from macaron.slsa_analyzer.ci_service.github_actions import GitHubActions from macaron.slsa_analyzer.package_registry import JFrogMavenRegistry from macaron.slsa_analyzer.package_registry.jfrog_maven_registry import JFrogMavenAsset +from macaron.slsa_analyzer.package_registry.npm_registry import NPMAttestationAsset, NPMRegistry from macaron.slsa_analyzer.provenance.intoto import InTotoPayload from macaron.slsa_analyzer.provenance.loader import LoadIntotoAttestationError, load_provenance_payload +from macaron.slsa_analyzer.provenance.slsa import SLSAProvenanceData from macaron.slsa_analyzer.provenance.witness import ( WitnessProvenanceData, extract_repo_url, @@ -81,8 +85,7 @@ def __init__(self) -> None: def find_provenance_assets_on_package_registries( self, - repo_fs_path: str, - repo_remote_path: str, + component: Component, package_registry_info_entries: list[PackageRegistryInfo], provenance_extensions: list[str], ) -> Sequence[AssetLocator]: @@ -93,10 +96,8 @@ def find_provenance_assets_on_package_registries( Parameters ---------- - repo_fs_path : str - The path to the repo on the local file system. - repo_remote_path : str - The URL to the remote repository. + component: Component + The target component under analysis. package_registry_info_entries : list[PackageRegistryInfo] A list of package registry info entries. provenance_extensions : list[str] @@ -121,10 +122,21 @@ def find_provenance_assets_on_package_registries( build_tool=Gradle() as gradle, package_registry=JFrogMavenRegistry() as jfrog_registry, ) as info_entry: + # The current provenance discovery mechanism for JFrog Maven registry requires a + # repository to be available. Moreover, the repository path in Witness provenance + # contents are checked to match the target repository path. + # TODO: handle cases where a PURL string is provided for a software component but + # no repository is available. + if not component.repository: + logger.debug( + "Unable to find a provenance because a repository was not found for %s.", component.purl + ) + return [] + # Triples of group id, artifact id, version. gavs: list[tuple[str, str, str]] = [] - group_ids = gradle.get_group_ids(repo_fs_path) + group_ids = gradle.get_group_ids(component.repository.fs_path) for group_id in group_ids: artifact_ids = jfrog_registry.fetch_artifact_ids(group_id) @@ -182,7 +194,7 @@ def find_provenance_assets_on_package_registries( provenances = self.obtain_witness_provenances( provenance_assets=provenance_assets, - repo_remote_path=repo_remote_path, + repo_remote_path=component.repository.remote_path, ) witness_provenance_assets = [] @@ -195,7 +207,50 @@ def find_provenance_assets_on_package_registries( # Persist the provenance assets in the package registry info entry. info_entry.provenances.extend(provenances) return provenance_assets - + case PackageRegistryInfo( + build_tool=NPM() | Yarn(), + package_registry=NPMRegistry() as npm_registry, + ) as npm_info_entry: + if not npm_registry.enabled: + logger.debug("Calling REST API calls to npm registry is disabled.") + return [] + if not component.version: + logger.debug( + "Unable to find provenance because artifact version is not available in %s.", component.purl + ) + return [] + + namespace = component.namespace + artifact_id = component.name + version = component.version + npm_provenance_assets = [] + + # The size of the asset (in bytes) is added to match the AssetLocator + # protocol and is not used because npm API registry does not provide it, so it is set to zero. + npm_provenance_asset = NPMAttestationAsset( + namespace=namespace, + artifact_id=artifact_id, + version=version, + npm_registry=npm_registry, + size_in_bytes=0, + ) + try: + with tempfile.TemporaryDirectory() as temp_dir: + download_path = os.path.join(temp_dir, f"{artifact_id}.intoto.jsonl") + if not npm_provenance_asset.download(download_path): + logger.debug("Unable to find an npm provenance for %s@%s", artifact_id, version) + return [] + try: + npm_provenance_payload = load_provenance_payload(download_path) + except LoadIntotoAttestationError as loadintotoerror: + logger.error("Error while loading provenance %s", loadintotoerror) + npm_info_entry.provenances.append( + SLSAProvenanceData(asset=npm_provenance_asset, payload=npm_provenance_payload) + ) + npm_provenance_assets.append(npm_provenance_asset) + except OSError as error: + logger.error("Error while storing provenance in the temporary directory: %s", error) + return npm_provenance_assets return [] def obtain_witness_provenances( @@ -220,38 +275,41 @@ def obtain_witness_provenances( provenances = [] witness_verifier_config = load_witness_verifier_config() - with tempfile.TemporaryDirectory() as temp_dir: - for provenance_asset in provenance_assets: - provenance_filepath = os.path.join(temp_dir, provenance_asset.name) - if not provenance_asset.download(provenance_filepath): - logger.debug( - "Could not download the provenance %s. Skip verifying...", - provenance_asset.name, - ) - continue + try: + with tempfile.TemporaryDirectory() as temp_dir: + for provenance_asset in provenance_assets: + provenance_filepath = os.path.join(temp_dir, provenance_asset.name) + if not provenance_asset.download(provenance_filepath): + logger.debug( + "Could not download the provenance %s. Skip verifying...", + provenance_asset.name, + ) + continue - try: - provenance_payload = load_provenance_payload(provenance_filepath) - except LoadIntotoAttestationError as error: - logger.error("Error while loading provenance: %s", error) - continue + try: + provenance_payload = load_provenance_payload(provenance_filepath) + except LoadIntotoAttestationError as error: + logger.error("Error while loading provenance: %s", error) + continue - if not is_witness_provenance_payload( - provenance_payload, - witness_verifier_config.predicate_types, - ): - continue + if not is_witness_provenance_payload( + provenance_payload, + witness_verifier_config.predicate_types, + ): + continue - repo_url = extract_repo_url(provenance_payload) - if repo_url != repo_remote_path: - continue + repo_url = extract_repo_url(provenance_payload) + if repo_url != repo_remote_path: + continue - provenances.append( - WitnessProvenanceData( - asset=provenance_asset, - payload=provenance_payload, + provenances.append( + WitnessProvenanceData( + asset=provenance_asset, + payload=provenance_payload, + ) ) - ) + except OSError as error: + logger.error("Error while storing provenance in the temporary directory: %s", error) return provenances @@ -308,7 +366,7 @@ def download_provenances_from_jfrog_maven_package_registry( def find_provenance_assets_on_ci_services( self, - repo_full_name: str, + component: Component, ci_info_entries: list[CIInfo], provenance_extensions: list[str], ) -> Sequence[AssetLocator]: @@ -322,8 +380,8 @@ def find_provenance_assets_on_ci_services( Parameters ---------- - repo_full_name: str - The full name of the repo, in the format of ``owner/repo_name``. + component: Component + The target component under analysis. package_registry_info_entries : list[PackageRegistryInfo] A list of package registry info entries. provenance_extensions : list[str] @@ -335,6 +393,11 @@ def find_provenance_assets_on_ci_services( Sequence[Asset] A sequence of assets found on the given CI services. """ + if not component.repository: + logger.debug("Unable to find a provenance because a repository was not found for %s.", component.purl) + return [] + + repo_full_name = component.repository.full_name for ci_info in ci_info_entries: ci_service = ci_info["service"] @@ -387,45 +450,48 @@ def download_provenances_from_github_actions_ci_service(self, ci_info: CIInfo) - ci_service = ci_info["service"] prov_assets = ci_info["provenance_assets"] - with tempfile.TemporaryDirectory() as temp_path: - downloaded_provs = [] - for prov_asset in prov_assets: - # Check the size before downloading. - if prov_asset.size_in_bytes > defaults.getint( - "slsa.verifier", - "max_download_size", - fallback=1000000, - ): - logger.info( - "Skip verifying the provenance %s: asset size too large.", - prov_asset.name, - ) - continue + try: + with tempfile.TemporaryDirectory() as temp_path: + downloaded_provs = [] + for prov_asset in prov_assets: + # Check the size before downloading. + if prov_asset.size_in_bytes > defaults.getint( + "slsa.verifier", + "max_download_size", + fallback=1000000, + ): + logger.info( + "Skip verifying the provenance %s: asset size too large.", + prov_asset.name, + ) + continue - provenance_filepath = os.path.join(temp_path, prov_asset.name) + provenance_filepath = os.path.join(temp_path, prov_asset.name) - if not ci_service.api_client.download_asset( - prov_asset.url, - provenance_filepath, - ): - logger.debug( - "Could not download the provenance %s. Skip verifying...", - prov_asset.name, - ) - continue + if not ci_service.api_client.download_asset( + prov_asset.url, + provenance_filepath, + ): + logger.debug( + "Could not download the provenance %s. Skip verifying...", + prov_asset.name, + ) + continue - # Read the provenance. - try: - payload = load_provenance_payload(provenance_filepath) - except LoadIntotoAttestationError as error: - logger.error("Error logging provenance: %s", error) - continue + # Read the provenance. + try: + payload = load_provenance_payload(provenance_filepath) + except LoadIntotoAttestationError as error: + logger.error("Error logging provenance: %s", error) + continue - # Add the provenance file. - downloaded_provs.append(payload) + # Add the provenance file. + downloaded_provs.append(payload) - # Persist the provenance payloads into the CIInfo object. - ci_info["provenances"] = downloaded_provs + # Persist the provenance payloads into the CIInfo object. + ci_info["provenances"] = downloaded_provs + except OSError as error: + logger.error("Error while storing provenance in the temporary directory: %s", error) def run_check(self, ctx: AnalyzeContext) -> CheckResultData: """Implement the check in this method. @@ -448,22 +514,13 @@ def run_check(self, ctx: AnalyzeContext) -> CheckResultData: # We look for the provenances in the package registries first, then CI services. # (Note the short-circuit evaluation with OR.) - # The current provenance discovery mechanism for package registries requires a - # repository to be available. Moreover, the repository path in Witness provenance - # contents are checked to match the target repository path. - # TODO: handle cases where a PURL string is provided for a software component but - # no repository is available. - if not ctx.component.repository: - failed_msg = "Unable to find provenances because no repository is available." - return CheckResultData(justification=[failed_msg], result_tables=[], result_type=CheckResultType.FAILED) try: provenance_assets = self.find_provenance_assets_on_package_registries( - repo_fs_path=ctx.component.repository.fs_path, - repo_remote_path=ctx.component.repository.remote_path, + component=ctx.component, package_registry_info_entries=ctx.dynamic_data["package_registries"], provenance_extensions=provenance_extensions, ) or self.find_provenance_assets_on_ci_services( - repo_full_name=ctx.component.repository.full_name, + component=ctx.component, ci_info_entries=ctx.dynamic_data["ci_services"], provenance_extensions=provenance_extensions, ) @@ -478,7 +535,7 @@ def run_check(self, ctx: AnalyzeContext) -> CheckResultData: justification.append("Found provenance in release assets:") justification.extend( - [asset.name for asset in provenance_assets], + [asset.url for asset in provenance_assets], ) # We only write the result to the database when the check is PASSED. result_tables: ResultTables = [ diff --git a/src/macaron/slsa_analyzer/package_registry/__init__.py b/src/macaron/slsa_analyzer/package_registry/__init__.py index 1c073063a..d0affe74c 100644 --- a/src/macaron/slsa_analyzer/package_registry/__init__.py +++ b/src/macaron/slsa_analyzer/package_registry/__init__.py @@ -5,6 +5,7 @@ from macaron.slsa_analyzer.package_registry.jfrog_maven_registry import JFrogMavenRegistry from macaron.slsa_analyzer.package_registry.maven_central_registry import MavenCentralRegistry +from macaron.slsa_analyzer.package_registry.npm_registry import NPMRegistry from macaron.slsa_analyzer.package_registry.package_registry import PackageRegistry -PACKAGE_REGISTRIES: list[PackageRegistry] = [JFrogMavenRegistry(), MavenCentralRegistry()] +PACKAGE_REGISTRIES: list[PackageRegistry] = [JFrogMavenRegistry(), MavenCentralRegistry(), NPMRegistry()] diff --git a/src/macaron/slsa_analyzer/package_registry/maven_central_registry.py b/src/macaron/slsa_analyzer/package_registry/maven_central_registry.py index cf550505a..3b3040d50 100644 --- a/src/macaron/slsa_analyzer/package_registry/maven_central_registry.py +++ b/src/macaron/slsa_analyzer/package_registry/maven_central_registry.py @@ -35,7 +35,7 @@ def __init__( Parameters ---------- hostname : str - The hostname of the JFrog instance. + The hostname of the Maven Central service. search_endpoint : str | None The search REST API to find artifacts. request_timeout : int | None diff --git a/src/macaron/slsa_analyzer/package_registry/npm_registry.py b/src/macaron/slsa_analyzer/package_registry/npm_registry.py new file mode 100644 index 000000000..8cd6af6ea --- /dev/null +++ b/src/macaron/slsa_analyzer/package_registry/npm_registry.py @@ -0,0 +1,265 @@ +# Copyright (c) 2023 - 2023, Oracle and/or its affiliates. All rights reserved. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/. + +"""The module provides abstractions for the npm package registry.""" + +import json +import logging +from typing import NamedTuple +from urllib.parse import SplitResult, urlunsplit + +import requests + +from macaron.config.defaults import defaults +from macaron.errors import ConfigurationError, InvalidHTTPResponseError, InvalidPURLError +from macaron.slsa_analyzer.build_tool.base_build_tool import BaseBuildTool +from macaron.slsa_analyzer.build_tool.npm import NPM +from macaron.slsa_analyzer.build_tool.yarn import Yarn +from macaron.slsa_analyzer.package_registry.package_registry import PackageRegistry +from macaron.util import send_get_http_raw + +logger: logging.Logger = logging.getLogger(__name__) + + +class NPMRegistry(PackageRegistry): + """This class implements the npm package registry. + + There is no complete and up-to-date API documentation for the npm registry and the endpoints + are discovered by manual inspection of links on https://www.npmjs.com. + """ + + def __init__( + self, + hostname: str | None = None, + attestation_endpoint: str | None = None, + request_timeout: int | None = None, + enabled: bool = True, + ) -> None: + """ + Initialize the npm Registry instance. + + Parameters + ---------- + hostname : str + The hostname of the npm registry. + attestation_endpoint : str | None + The attestation REST API. + request_timeout : int | None + The timeout (in seconds) for requests made to the package registry. + enabled: bool + Shows whether making REST API calls to npm registry is enabled. + """ + self.hostname = hostname or "" + self.attestation_endpoint = attestation_endpoint or "" + self.request_timeout = request_timeout or 10 + self.enabled = enabled + super().__init__("npm Registry") + + def load_defaults(self) -> None: + """Load the .ini configuration for the current package registry. + + Raises + ------ + ConfigurationError + If there is a schema violation in the ``npm registry`` section. + """ + section_name = "package_registry.npm" + if not defaults.has_section(section_name): + self.enabled = False + return + section = defaults[section_name] + + if not section.getboolean("enabled", fallback=True): + self.enabled = False + logger.debug("npm registry is disabled in section [{section_name}] of the .ini configuration file.") + return + + self.hostname = section.get("hostname") + if not self.hostname: + raise ConfigurationError( + f'The "hostname" key is missing in section [{section_name}] of the .ini configuration file.' + ) + + self.attestation_endpoint = section.get("attestation_endpoint", "-/npm/v1/attestations") + + if not self.attestation_endpoint: + raise ConfigurationError( + f'The "attestation_endpoint" key is missing in section [{section_name}] of the .ini configuration file.' + ) + + try: + self.request_timeout = section.getint("request_timeout", fallback=10) + except ValueError as error: + raise ConfigurationError( + f'The "request_timeout" value in section [{section_name}]' + f"of the .ini configuration file is invalid: {error}", + ) from error + + def is_detected(self, build_tool: BaseBuildTool) -> bool: + """Detect if artifacts of the repo under analysis can possibly be published to this package registry. + + The detection here is based on the repo's detected build tools. + If the package registry is compatible with the given build tools, it can be a + possible place where the artifacts produced from the repo are published. + + ``NPMRegistry`` is compatible with npm and Yarn build tools. + + Parameters + ---------- + build_tool : BaseBuildTool + A detected build tool of the repository under analysis. + + Returns + ------- + bool + ``True`` if the repo under analysis can be published to this package registry, + based on the given build tool. + """ + compatible_build_tool_classes = [NPM, Yarn] + for build_tool_class in compatible_build_tool_classes: + if isinstance(build_tool, build_tool_class): + return True + return False + + def download_attestation_payload(self, url: str, download_path: str) -> bool: + """Download the npm attestation from npm registry. + + Each npm package has two types of attestations: + + * publish with "https://github.com/npm/attestation/tree/main/specs/publish/v0.1" predicateType + * SLSA with "https://slsa.dev/provenance/v0.2" predicateType + + We download the SLSA provenance in this method. + + Parameters + ---------- + url: str + The attestation URL. + download_path: srt + The download path for the asset. + + Returns + ------- + bool + ``True`` if the asset is downloaded successfully; ``False`` if not. + """ + response = send_get_http_raw(url, headers=None, timeout=self.request_timeout) + if not response or response.status_code != 200: + logger.debug("Unable to find attestation at %s", url) + return False + try: + res_obj = response.json() + except requests.exceptions.JSONDecodeError as error: + raise InvalidHTTPResponseError(f"Failed to process response from Maven central for {url}.") from error + if not res_obj: + raise InvalidHTTPResponseError(f"Empty response returned by {url} .") + if not res_obj.get("attestations"): + raise InvalidHTTPResponseError(f"The response returned by {url} misses `attestations` attribute.") + + # Download the SLSA provenance only. + for att in res_obj.get("attestations"): + if not att.get("predicateType"): + logger.debug("predicateType attribute is missing for %s", url) + continue + if att.get("predicateType") != "https://slsa.dev/provenance/v0.2": + logger.debug("predicateType %s is not accepted. Skipping...", att.get("predicateType")) + continue + if not (bundle := att.get("bundle")): + logger.debug("bundle attribute in the attestation is missing. Skipping...") + continue + if not (dsse_env := bundle.get("dsseEnvelope")): + logger.debug("dsseEnvelope attribute in the bundle is missing. Skipping...") + continue + + try: + with open(download_path, "w", encoding="utf-8") as file: + json.dump(dsse_env, file) + return True + except OSError as error: + logger.debug( + "Failed to write the downloaded attestation from %s to %s. Error: %s", + url, + download_path, + error, + ) + + return False + + +class NPMAttestationAsset(NamedTuple): + """An attestation asset hosted on the npm registry. + + The API Documentation can be found here: + """ + + #: The namespace of the artifact on npm. + namespace: str | None + + #: The artifact ID. + artifact_id: str + + #: The version of the asset. + version: str + + #: The npm registry. + npm_registry: NPMRegistry + + #: The size of the asset (in bytes). This attribute is added to match the AssetLocator + #: protocol and is not used because npm API registry does not provide it. + size_in_bytes: int + + @property + def name(self) -> str: + """Get the asset name.""" + return self.artifact_id + + @property + def url(self) -> str: + """Get the download URL of the asset. + + Note: we assume that the path parameters used to construct the URL are sanitized already. + + Returns + ------- + str + """ + # Build the path parameters. + path_params = [self.npm_registry.attestation_endpoint] + if self.namespace: + path_params.append(self.namespace) + path_params.append(self.artifact_id) + path = f'{"/".join(path_params)}' + + # Check that version is not an empty string. + if self.version: + path = f"{path}@{self.version}" + + return urlunsplit( + SplitResult( + scheme="https", + netloc=self.npm_registry.hostname, + path=path, + query="", + fragment="", + ) + ) + + def download(self, dest: str) -> bool: + """Download the asset. + + Parameters + ---------- + dest : str + The local destination where the asset is downloaded to. + Note that this must include the file name. + + Returns + ------- + bool + ``True`` if the asset is downloaded successfully; ``False`` if not. + """ + try: + return self.npm_registry.download_attestation_payload(self.url, dest) + except InvalidPURLError as error: + logger.debug(error) + return False diff --git a/src/macaron/slsa_analyzer/provenance/slsa/__init__.py b/src/macaron/slsa_analyzer/provenance/slsa/__init__.py new file mode 100644 index 000000000..ce60d94ed --- /dev/null +++ b/src/macaron/slsa_analyzer/provenance/slsa/__init__.py @@ -0,0 +1,18 @@ +# Copyright (c) 2023 - 2023, Oracle and/or its affiliates. All rights reserved. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/. + +"""This module implements SLSA provenance abstractions.""" + +from typing import NamedTuple + +from macaron.slsa_analyzer.asset import AssetLocator +from macaron.slsa_analyzer.provenance.intoto import InTotoPayload + + +class SLSAProvenanceData(NamedTuple): + """SLSA provenance data.""" + + #: The provenance asset. + asset: AssetLocator + #: The provenance payload. + payload: InTotoPayload diff --git a/tests/conftest.py b/tests/conftest.py index c82414a88..7e725bce2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,7 @@ from macaron.config.defaults import create_defaults, defaults, load_defaults from macaron.database.table_definitions import Analysis, Component, Repository from macaron.slsa_analyzer.analyze_context import AnalyzeContext +from macaron.slsa_analyzer.build_tool.base_build_tool import BaseBuildTool from macaron.slsa_analyzer.build_tool.docker import Docker from macaron.slsa_analyzer.build_tool.go import Go from macaron.slsa_analyzer.build_tool.gradle import Gradle @@ -228,6 +229,34 @@ def go_tool(setup_test) -> Go: # type: ignore # pylint: disable=unused-argument return go +@pytest.fixture(name="build_tools") +def get_build_tools( + npm_tool: BaseBuildTool, + yarn_tool: BaseBuildTool, + go_tool: BaseBuildTool, + maven_tool: BaseBuildTool, + gradle_tool: BaseBuildTool, + pip_tool: BaseBuildTool, + poetry_tool: BaseBuildTool, + docker_tool: BaseBuildTool, +) -> dict[str, BaseBuildTool]: + """Create a dictionary to look up build tool fixtures. + + `pytest.mark.parametrize` does not accept fixtures as arguments. This fixture is created as + a workaround to parametrize tests with build tool fixtures. + """ + return { + "npm": npm_tool, + "yarn": yarn_tool, + "go": go_tool, + "maven": maven_tool, + "gradle": gradle_tool, + "pip": pip_tool, + "poetry": poetry_tool, + "docker": docker_tool, + } + + class MockGitHubActions(GitHubActions): """Mock the GitHubActions class.""" diff --git a/tests/e2e/expected_results/purl/npm/sigstore/mock/mock.json b/tests/e2e/expected_results/purl/npm/sigstore/mock/mock.json new file mode 100644 index 000000000..5411e3708 --- /dev/null +++ b/tests/e2e/expected_results/purl/npm/sigstore/mock/mock.json @@ -0,0 +1,314 @@ +{ + "metadata": { + "timestamps": "2023-11-09 14:15:52", + "has_passing_check": true + }, + "target": { + "info": { + "full_name": "pkg:npm/%40sigstore/mock@0.1.0", + "local_cloned_path": "git_repos/github_com/sigstore/sigstore-js", + "remote_path": "https://github.com/sigstore/sigstore-js", + "branch": "main", + "commit_hash": "bd4cd43b7fc31cb3365674de751e621b411fa184", + "commit_date": "2023-11-08T11:08:03-08:00" + }, + "provenances": { + "is_inferred": false, + "content": { + "github_actions": [ + { + "_type": "https://in-toto.io/Statement/v0.1", + "subject": [], + "predicateType": "https://slsa.dev/provenance/v0.2", + "predicate": { + "builder": { + "id": "" + }, + "buildType": "", + "invocation": { + "configSource": { + "uri": "", + "digest": { + "sha1": "" + }, + "entryPoint": "" + }, + "parameters": {}, + "environment": {} + }, + "buildConfig": { + "jobID": "", + "stepID": "" + }, + "metadata": { + "buildInvocationId": "", + "buildStartedOn": "", + "buildFinishedOn": "", + "completeness": { + "parameters": "false", + "environment": "false", + "materials": "false" + }, + "reproducible": "false" + }, + "materials": [ + { + "uri": "", + "digest": {} + } + ] + } + } + ], + "npm Registry": [ + { + "_type": "https://in-toto.io/Statement/v0.1", + "subject": [ + { + "name": "pkg:npm/%40sigstore/mock@0.1.0", + "digest": { + "sha512": "2b9cd6c43c73c1f5eaf0fa2b2899f17822c49d95a3c42f545ee28c081a3e413d044e2095fe26e3d5b2a9e7337ed95371fa88e417b560893959c7710c5991b0c2" + } + } + ], + "predicateType": "https://slsa.dev/provenance/v0.2", + "predicate": { + "buildType": "https://github.com/npm/cli/gha/v2", + "builder": { + "id": "https://github.com/actions/runner" + }, + "invocation": { + "configSource": { + "uri": "git+https://github.com/sigstore/sigstore-js@refs/heads/main", + "digest": { + "sha1": "ebdcfdfbdfeb9c9aeee6df53674ef230613629f5" + }, + "entryPoint": ".github/workflows/release.yml" + }, + "parameters": {}, + "environment": { + "GITHUB_EVENT_NAME": "push", + "GITHUB_REF": "refs/heads/main", + "GITHUB_REPOSITORY": "sigstore/sigstore-js", + "GITHUB_REPOSITORY_ID": "495574555", + "GITHUB_REPOSITORY_OWNER_ID": "71096353", + "GITHUB_RUN_ATTEMPT": "1", + "GITHUB_RUN_ID": "5425054346", + "GITHUB_SHA": "ebdcfdfbdfeb9c9aeee6df53674ef230613629f5", + "GITHUB_WORKFLOW_REF": "sigstore/sigstore-js/.github/workflows/release.yml@refs/heads/main", + "GITHUB_WORKFLOW_SHA": "ebdcfdfbdfeb9c9aeee6df53674ef230613629f5" + } + }, + "metadata": { + "buildInvocationId": "5425054346-1", + "completeness": { + "parameters": false, + "environment": false, + "materials": false + }, + "reproducible": false + }, + "materials": [ + { + "uri": "git+https://github.com/sigstore/sigstore-js@refs/heads/main", + "digest": { + "sha1": "ebdcfdfbdfeb9c9aeee6df53674ef230613629f5" + } + } + ] + } + } + ] + } + }, + "checks": { + "summary": { + "DISABLED": 0, + "FAILED": 4, + "PASSED": 5, + "SKIPPED": 0, + "UNKNOWN": 1 + }, + "results": [ + { + "check_id": "mcn_provenance_expectation_1", + "check_description": "Check whether the SLSA provenance for the produced artifact conforms to the expected value.", + "slsa_requirements": [ + "Provenance conforms with expectations - SLSA Level 3" + ], + "justification": [ + "No expectation defined for this repository." + ], + "result_type": "UNKNOWN" + }, + { + "check_id": "mcn_build_as_code_1", + "check_description": "The build definition and configuration executed by the build service is verifiably derived from text file definitions stored in a version control system.", + "slsa_requirements": [ + "Build as code - SLSA Level 3" + ], + "justification": [ + { + "The target repository uses build tool npm to deploy": "https://github.com/sigstore/sigstore-js/blob/bd4cd43b7fc31cb3365674de751e621b411fa184/.github/workflows/release.yml", + "The build is triggered by": "https://github.com/sigstore/sigstore-js/blob/bd4cd43b7fc31cb3365674de751e621b411fa184/.github/workflows/release.yml" + }, + "Deploy action: changesets/action", + "However, could not find a passing workflow run.", + "The target repository does not use yarn to deploy." + ], + "result_type": "PASSED" + }, + { + "check_id": "mcn_build_script_1", + "check_description": "Check if the target repo has a valid build script.", + "slsa_requirements": [ + "Scripted Build - SLSA Level 1" + ], + "justification": [ + "Check mcn_build_script_1 is set to PASSED because mcn_build_service_1 PASSED." + ], + "result_type": "PASSED" + }, + { + "check_id": "mcn_build_service_1", + "check_description": "Check if the target repo has a valid build service.", + "slsa_requirements": [ + "Build service - SLSA Level 2" + ], + "justification": [ + "Check mcn_build_service_1 is set to PASSED because mcn_build_as_code_1 PASSED." + ], + "result_type": "PASSED" + }, + { + "check_id": "mcn_provenance_available_1", + "check_description": "Check whether the target has intoto provenance.", + "slsa_requirements": [ + "Provenance - Available - SLSA Level 1", + "Provenance content - Identifies build instructions - SLSA Level 1", + "Provenance content - Identifies artifacts - SLSA Level 1", + "Provenance content - Identifies builder - SLSA Level 1" + ], + "justification": [ + "Found provenance in release assets:", + "https://registry.npmjs.org/-/npm/v1/attestations/@sigstore/mock@0.1.0" + ], + "result_type": "PASSED" + }, + { + "check_id": "mcn_version_control_system_1", + "check_description": "Check whether the target repo uses a version control system.", + "slsa_requirements": [ + "Version controlled - SLSA Level 2" + ], + "justification": [ + { + "This is a Git repository": "https://github.com/sigstore/sigstore-js" + } + ], + "result_type": "PASSED" + }, + { + "check_id": "mcn_infer_artifact_pipeline_1", + "check_description": "Detects potential pipelines from which an artifact is published.", + "slsa_requirements": [ + "Build as code - SLSA Level 3" + ], + "justification": [ + "Unable to find a publishing timestamp for the artifact." + ], + "result_type": "FAILED" + }, + { + "check_id": "mcn_provenance_level_three_1", + "check_description": "Check whether the target has SLSA provenance level 3.", + "slsa_requirements": [ + "Provenance - Non falsifiable - SLSA Level 3", + "Provenance content - Includes all build parameters - SLSA Level 3", + "Provenance content - Identifies entry point - SLSA Level 3", + "Provenance content - Identifies source code - SLSA Level 2" + ], + "justification": [ + "Could not verify level 3 provenance." + ], + "result_type": "FAILED" + }, + { + "check_id": "mcn_provenance_witness_level_one_1", + "check_description": "Check whether the target has a level-1 witness provenance.", + "slsa_requirements": [ + "Provenance - Available - SLSA Level 1", + "Provenance content - Identifies build instructions - SLSA Level 1", + "Provenance content - Identifies artifacts - SLSA Level 1", + "Provenance content - Identifies builder - SLSA Level 1" + ], + "justification": [ + "Failed to discover any witness provenance." + ], + "result_type": "FAILED" + }, + { + "check_id": "mcn_trusted_builder_level_three_1", + "check_description": "Check whether the target uses a trusted SLSA level 3 builder.", + "slsa_requirements": [ + "Hermetic - SLSA Level 4", + "Isolated - SLSA Level 3", + "Parameterless - SLSA Level 4", + "Ephemeral environment - SLSA Level 3" + ], + "justification": [ + "Could not find a trusted level 3 builder as a GitHub Actions workflow." + ], + "result_type": "FAILED" + } + ] + } + }, + "dependencies": { + "analyzed_deps": 0, + "unique_dep_repos": 0, + "checks_summary": [ + { + "check_id": "mcn_provenance_expectation_1", + "num_deps_pass": 0 + }, + { + "check_id": "mcn_provenance_witness_level_one_1", + "num_deps_pass": 0 + }, + { + "check_id": "mcn_provenance_available_1", + "num_deps_pass": 0 + }, + { + "check_id": "mcn_infer_artifact_pipeline_1", + "num_deps_pass": 0 + }, + { + "check_id": "mcn_build_as_code_1", + "num_deps_pass": 0 + }, + { + "check_id": "mcn_version_control_system_1", + "num_deps_pass": 0 + }, + { + "check_id": "mcn_trusted_builder_level_three_1", + "num_deps_pass": 0 + }, + { + "check_id": "mcn_build_script_1", + "num_deps_pass": 0 + }, + { + "check_id": "mcn_provenance_level_three_1", + "num_deps_pass": 0 + }, + { + "check_id": "mcn_build_service_1", + "num_deps_pass": 0 + } + ], + "dep_status": [] + } +} diff --git a/tests/slsa_analyzer/checks/test_provenance_available_check.py b/tests/slsa_analyzer/checks/test_provenance_available_check.py index 37c1742c7..5f85ce01f 100644 --- a/tests/slsa_analyzer/checks/test_provenance_available_check.py +++ b/tests/slsa_analyzer/checks/test_provenance_available_check.py @@ -4,12 +4,15 @@ """This modules contains tests for the provenance available check.""" +import os +import shutil from pathlib import Path import pytest from macaron.code_analyzer.call_graph import BaseNode, CallGraph from macaron.database.table_definitions import Repository +from macaron.slsa_analyzer.build_tool.base_build_tool import BaseBuildTool from macaron.slsa_analyzer.checks.check_result import CheckResultType from macaron.slsa_analyzer.checks.provenance_available_check import ProvenanceAvailableCheck from macaron.slsa_analyzer.ci_service.circleci import CircleCI @@ -18,7 +21,9 @@ from macaron.slsa_analyzer.ci_service.jenkins import Jenkins from macaron.slsa_analyzer.ci_service.travis import Travis from macaron.slsa_analyzer.git_service.api_client import GhAPIClient +from macaron.slsa_analyzer.package_registry.npm_registry import NPMRegistry from macaron.slsa_analyzer.specs.ci_spec import CIInfo +from macaron.slsa_analyzer.specs.package_registry_spec import PackageRegistryInfo from tests.conftest import MockAnalyzeContext @@ -50,6 +55,20 @@ def download_asset(self, url: str, download_path: str) -> bool: return False +class MockNPMRegistry(NPMRegistry): + """Mocj NPMRegistry class.""" + + resource_valid_prov_dir: str + + def download_attestation_payload(self, url: str, download_path: str) -> bool: + src_path = os.path.join(self.resource_valid_prov_dir, "sigstore-mock.payload.json") + try: + shutil.copy2(src_path, download_path) + except shutil.Error: + return False + return True + + @pytest.mark.parametrize( ("repository", "expected"), [ @@ -131,3 +150,41 @@ def test_provenance_available_check_on_ci(macaron_path: Path) -> None: # Test GitLab CI. ci_info["service"] = gitlab_ci assert check.run_check(ctx).result_type == CheckResultType.FAILED + + +@pytest.mark.parametrize( + ( + "build_tool_name", + "expected", + ), + [ + ("npm", CheckResultType.PASSED), + ("yarn", CheckResultType.PASSED), + ("go", CheckResultType.FAILED), + ("maven", CheckResultType.FAILED), + ], +) +def test_provenance_available_check_on_npm_registry( + macaron_path: Path, + test_dir: Path, + build_tool_name: str, + expected: CheckResultType, + build_tools: dict[str, BaseBuildTool], +) -> None: + """Test npm provenances published on npm registry.""" + check = ProvenanceAvailableCheck() + ctx = MockAnalyzeContext(macaron_path=macaron_path, output_dir="") + ctx.component.purl = "pkg:npm/@sigstore/mock@0.1.0" + npm_registry = MockNPMRegistry() + npm_registry.resource_valid_prov_dir = os.path.join( + test_dir, "slsa_analyzer", "provenance", "resources", "valid_provenances" + ) + npm_registry.load_defaults() + ctx.dynamic_data["package_registries"] = [ + PackageRegistryInfo( + build_tool=build_tools[build_tool_name], + package_registry=npm_registry, + ) + ] + + assert check.run_check(ctx).result_type == expected diff --git a/tests/slsa_analyzer/package_registry/test_npm_registry.py b/tests/slsa_analyzer/package_registry/test_npm_registry.py new file mode 100644 index 000000000..efaf39d7c --- /dev/null +++ b/tests/slsa_analyzer/package_registry/test_npm_registry.py @@ -0,0 +1,131 @@ +# Copyright (c) 2023 - 2023, Oracle and/or its affiliates. All rights reserved. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/. + +"""Tests for the npm registry.""" + +import os +from pathlib import Path + +import pytest + +from macaron.config.defaults import load_defaults +from macaron.errors import ConfigurationError +from macaron.slsa_analyzer.build_tool.base_build_tool import BaseBuildTool +from macaron.slsa_analyzer.package_registry.npm_registry import NPMAttestationAsset, NPMRegistry + + +def _create_npm_registry() -> NPMRegistry: + """Create an npm registry instance. + + This private function is used as parameter for Hypothesis, which otherwise does not accept a fixture. + """ + return NPMRegistry( + hostname="registry.npmjs.org", attestation_endpoint="-/npm/v1/attestations", request_timeout=20, enabled=True + ) + + +@pytest.fixture(name="npm_registry") +def create_npm_registry() -> NPMRegistry: + """Create an npm registry instance.""" + return _create_npm_registry() + + +def test_disable_npm_registry(npm_registry: NPMRegistry, tmp_path: Path) -> None: + """Test disabling npm registry.""" + config = """ + [package_registry.npm] + enabled = False + """ + config_path = os.path.join(tmp_path, "test_config.ini") + with open(config_path, mode="w", encoding="utf-8") as config_file: + config_file.write(config) + load_defaults(config_path) + npm_registry.load_defaults() + + assert npm_registry.enabled is False + + +@pytest.mark.parametrize( + "config", + [ + """ + [package_registry.npm] + hostname = + """, + """ + [package_registry.npm] + attestation_endpoint = + """, + """ + [package_registry.npm] + request_timeout = foo + """, + ], +) +def test_npm_registry_invalid_config(npm_registry: NPMRegistry, tmp_path: Path, config: str) -> None: + """Test loading invalid npm registry configuration.""" + config_path = os.path.join(tmp_path, "test_config.ini") + with open(config_path, mode="w", encoding="utf-8") as config_file: + config_file.write(config) + load_defaults(config_path) + with pytest.raises(ConfigurationError): + npm_registry.load_defaults() + + +@pytest.mark.parametrize( + ( + "build_tool_name", + "expected", + ), + [ + ("npm", True), + ("yarn", True), + ("go", False), + ("maven", False), + ], +) +def test_is_detected( + npm_registry: NPMRegistry, build_tools: dict[str, BaseBuildTool], build_tool_name: str, expected: bool +) -> None: + """Test that the registry is correctly detected for a build tool.""" + npm_registry.load_defaults() + assert npm_registry.is_detected(build_tool=build_tools[build_tool_name]) == expected + + +@pytest.mark.parametrize( + ( + "namespace", + "artifact_id", + "version", + "expected", + ), + [ + ( + "@foo", + "foo", + "1.0.0", + "@foo/foo@1.0.0", + ), + ( + None, + "foo", + "1.0.0", + "foo@1.0.0", + ), + ( + None, + "foo", + "", + "foo", + ), + ], +) +def test_npm_attestation_asset_url( + npm_registry: NPMRegistry, namespace: str | None, artifact_id: str, version: str, expected: str +) -> None: + """Test that the npm attestation url is correctly constructed.""" + asset = NPMAttestationAsset( + namespace=namespace, artifact_id=artifact_id, version=version, npm_registry=npm_registry, size_in_bytes=0 + ) + assert asset.name == artifact_id + assert asset.url == f"https://{npm_registry.hostname}/{npm_registry.attestation_endpoint}/{expected}" diff --git a/tests/slsa_analyzer/provenance/resources/valid_provenances/sigstore-mock.payload.json b/tests/slsa_analyzer/provenance/resources/valid_provenances/sigstore-mock.payload.json new file mode 100644 index 000000000..7e55bff3e --- /dev/null +++ b/tests/slsa_analyzer/provenance/resources/valid_provenances/sigstore-mock.payload.json @@ -0,0 +1,10 @@ +{ + "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInN1YmplY3QiOlt7Im5hbWUiOiJwa2c6bnBtLyU0MHNpZ3N0b3JlL21vY2tAMC4xLjAiLCJkaWdlc3QiOnsic2hhNTEyIjoiMmI5Y2Q2YzQzYzczYzFmNWVhZjBmYTJiMjg5OWYxNzgyMmM0OWQ5NWEzYzQyZjU0NWVlMjhjMDgxYTNlNDEzZDA0NGUyMDk1ZmUyNmUzZDViMmE5ZTczMzdlZDk1MzcxZmE4OGU0MTdiNTYwODkzOTU5Yzc3MTBjNTk5MWIwYzIifX1dLCJwcmVkaWNhdGVUeXBlIjoiaHR0cHM6Ly9zbHNhLmRldi9wcm92ZW5hbmNlL3YwLjIiLCJwcmVkaWNhdGUiOnsiYnVpbGRUeXBlIjoiaHR0cHM6Ly9naXRodWIuY29tL25wbS9jbGkvZ2hhL3YyIiwiYnVpbGRlciI6eyJpZCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9hY3Rpb25zL3J1bm5lciJ9LCJpbnZvY2F0aW9uIjp7ImNvbmZpZ1NvdXJjZSI6eyJ1cmkiOiJnaXQraHR0cHM6Ly9naXRodWIuY29tL3NpZ3N0b3JlL3NpZ3N0b3JlLWpzQHJlZnMvaGVhZHMvbWFpbiIsImRpZ2VzdCI6eyJzaGExIjoiZWJkY2ZkZmJkZmViOWM5YWVlZTZkZjUzNjc0ZWYyMzA2MTM2MjlmNSJ9LCJlbnRyeVBvaW50IjoiLmdpdGh1Yi93b3JrZmxvd3MvcmVsZWFzZS55bWwifSwicGFyYW1ldGVycyI6e30sImVudmlyb25tZW50Ijp7IkdJVEhVQl9FVkVOVF9OQU1FIjoicHVzaCIsIkdJVEhVQl9SRUYiOiJyZWZzL2hlYWRzL21haW4iLCJHSVRIVUJfUkVQT1NJVE9SWSI6InNpZ3N0b3JlL3NpZ3N0b3JlLWpzIiwiR0lUSFVCX1JFUE9TSVRPUllfSUQiOiI0OTU1NzQ1NTUiLCJHSVRIVUJfUkVQT1NJVE9SWV9PV05FUl9JRCI6IjcxMDk2MzUzIiwiR0lUSFVCX1JVTl9BVFRFTVBUIjoiMSIsIkdJVEhVQl9SVU5fSUQiOiI1NDI1MDU0MzQ2IiwiR0lUSFVCX1NIQSI6ImViZGNmZGZiZGZlYjljOWFlZWU2ZGY1MzY3NGVmMjMwNjEzNjI5ZjUiLCJHSVRIVUJfV09SS0ZMT1dfUkVGIjoic2lnc3RvcmUvc2lnc3RvcmUtanMvLmdpdGh1Yi93b3JrZmxvd3MvcmVsZWFzZS55bWxAcmVmcy9oZWFkcy9tYWluIiwiR0lUSFVCX1dPUktGTE9XX1NIQSI6ImViZGNmZGZiZGZlYjljOWFlZWU2ZGY1MzY3NGVmMjMwNjEzNjI5ZjUifX0sIm1ldGFkYXRhIjp7ImJ1aWxkSW52b2NhdGlvbklkIjoiNTQyNTA1NDM0Ni0xIiwiY29tcGxldGVuZXNzIjp7InBhcmFtZXRlcnMiOmZhbHNlLCJlbnZpcm9ubWVudCI6ZmFsc2UsIm1hdGVyaWFscyI6ZmFsc2V9LCJyZXByb2R1Y2libGUiOmZhbHNlfSwibWF0ZXJpYWxzIjpbeyJ1cmkiOiJnaXQraHR0cHM6Ly9naXRodWIuY29tL3NpZ3N0b3JlL3NpZ3N0b3JlLWpzQHJlZnMvaGVhZHMvbWFpbiIsImRpZ2VzdCI6eyJzaGExIjoiZWJkY2ZkZmJkZmViOWM5YWVlZTZkZjUzNjc0ZWYyMzA2MTM2MjlmNSJ9fV19fQ==", + "payloadType": "application/vnd.in-toto+json", + "signatures": [ + { + "sig": "MEYCIQCric95iFcV1vgU1a/YLYDwlhs9qoSZgI/OC575Ofx2IwIhAICx3fm+1tuiDyt+7EH95McmqDmvADFRWm+F7/GJIu7J", + "keyid": "" + } + ] +} diff --git a/tests/slsa_analyzer/test_analyzer.py b/tests/slsa_analyzer/test_analyzer.py index 1ae7d9194..ffdd06327 100644 --- a/tests/slsa_analyzer/test_analyzer.py +++ b/tests/slsa_analyzer/test_analyzer.py @@ -5,7 +5,10 @@ from pathlib import Path +import hypothesis.provisional as st_pr +import hypothesis.strategies as st import pytest +from hypothesis import given from packageurl import PackageURL from macaron.config.target_config import Configuration @@ -103,6 +106,41 @@ def test_resolve_analysis_target( assert Analyzer.to_analysis_target(config, available_domains) == expect +@given( + purl_type=st.one_of(st.text(), st.sampled_from(["maven", "npm", "pypi", "github.com"])), + namespace=st.one_of(st.none(), st.text()), + artifact_id=st.text(), + version=st.text(), + url=st_pr.urls(), + branch=st.text(), + digest=st.text(), + available_domains=st.just(["github.com", "gitlab.com", "bitbucket.org"]), +) +def test_invalid_analysis_target( + purl_type: str, + namespace: str | None, + artifact_id: str, + version: str, + url: str, + branch: str, + digest: str, + available_domains: list[str], +) -> None: + """Test the analysis target resolution with valid inputs.""" + config = Configuration( + { + "purl": f"pkg:{purl_type}/{namespace}/{artifact_id}@{version}", + "path": url, + "branch": branch, + "digest": digest, + } + ) + try: + Analyzer.to_analysis_target(config, available_domains) + except InvalidPURLError: + pass + + @pytest.mark.parametrize( ("config"), [