diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2292ae0..8748013 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,7 @@ on: workflow_dispatch: push: tags: - - 'v*.*.*' + - "v*.*.*" jobs: deploy: @@ -16,7 +16,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.9.x' + python-version: "3.9.x" - name: Install dependencies run: | @@ -29,7 +29,7 @@ jobs: - name: Run Local Tests run: | which scanoss-check-undeclared-code - scanoss-check-undeclared-code --version + scanoss-check-undeclared-code --help scanoss-check-undeclared-code - name: Dev Uninstall diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 35229f6..d081cb1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,5 +35,5 @@ jobs: - name: Run Local Tests run: | which scanoss-check-undeclared-code - scanoss-check-undeclared-code --version + scanoss-check-undeclared-code --help scanoss-check-undeclared-code diff --git a/.gitignore b/.gitignore index cfa65c8..0051565 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ docs/build !.devcontainer/*.example.json .scanoss +.env diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 9b1aa6e..fd38042 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -5,4 +5,5 @@ language: python stages: [pre-commit, pre-push, manual] pass_filenames: false - + require_serial: true + verbose: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ea1850..a7265a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Upcoming changes... +## [0.3.0] - 2025-10-24 +### Added +- CLI arguments support: `--api-url`, `--api-key`, `--proxy`, `--pac`, `--ca-cert`, `--output`, `--debug`, `--ignore-cert-errors`, `--rest` +- Support for custom output path for scan results +- Improved logging with configurable debug mode +- Sensitive information sanitization in command logging +- Click library for enhanced CLI experience + +### Changed +- Refactored from argparse to click for better CLI argument handling +- Consolidated utility functions into main module (removed utils.py) +- Enhanced error handling and user feedback +- Updated GitHub Actions workflows to use `--help` instead of `--version` + +### Fixed +- Pre-commit hook behavior when committing files with no matches +- Release workflow improvements + ## [0.2.0] - 2025-03-21 ### Added - Added version details @@ -23,3 +41,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [0.1.0]: https://github.com/scanoss/pre-commit-hooks/compare/v0.0.1...v0.1.0 [0.2.0]: https://github.com/scanoss/pre-commit-hooks/compare/v0.1.0...v0.2.0 +[0.3.0]: https://github.com/scanoss/pre-commit-hooks/compare/v0.2.0...v0.3.0 diff --git a/README.md b/README.md index 5d16c1a..abe2389 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ - [License](#license) - [Bugs/Features](#bugsfeatures) - [Contributing](#contributing) +- [Release and Deployment](#release-and-deployment) - [Changelog](#changelog) ## Available Hooks @@ -128,5 +129,53 @@ To request features or alert about bugs, please do so [here](https://github.com/ ## Contributing We welcome contributions to this project! Please clone the repository and submit a pull request with your changes. Ensure that your code passes all pre-commit checks before submitting. +## Release and Deployment + +This project uses automated GitHub Actions workflows to manage releases. The package is distributed through GitHub Releases and the pre-commit framework (not PyPI). + +### Release Process + +1. **Update Version**: Modify `__version__` in `src/hooks/__init__.py` following semantic versioning (MAJOR.MINOR.PATCH) + +2. **Create Tag**: Run the `tag-version.yml` workflow manually: + - Go to Actions → "Tag Version" → "Run workflow" + - The workflow compares the Python package version with the latest Git tag + - If versions differ, it creates and pushes a new tag (e.g., `v0.3.0`) + +3. **Automated Release**: The `release.yml` workflow triggers automatically when a tag is pushed: + - Builds the package in a clean environment + - Runs verification tests (binary check, `--help`, basic execution) + - Creates a draft GitHub Release + +4. **Publish Release**: A maintainer reviews and publishes the draft release manually + +### Version Management + +- **Current Version Source**: `src/hooks/__init__.py` +- **Versioning Strategy**: Semantic Versioning (SemVer) +- **Tag Format**: `v0.3.0` (with 'v' prefix) +- **Major Version Tags**: The repository maintains `v0` and `v1` tags that point to the latest patch release, allowing users to pin to a major version and automatically receive updates + +### Distribution + +Users reference this package in their `.pre-commit-config.yaml`: + +```yaml +repos: +- repo: https://github.com/scanoss/pre-commit-hooks + rev: v0 # Pin to major version, or use v0.3.0 for specific version + hooks: + - id: scanoss-check-undeclared-code +``` + +The pre-commit framework installs directly from the Git repository—no PyPI publishing required. + +### Key Workflows + +- `.github/workflows/tag-version.yml` - Manual workflow for version tagging +- `.github/workflows/release.yml` - Automated draft release creation +- `.github/workflows/test.yml` - Continuous testing on main branch and PRs +- `.github/workflows/update-main-version.yml` - Major version tag maintenance + ## Changelog Details of major changes to the library can be found in [CHANGELOG.md](CHANGELOG.md). diff --git a/requirements.txt b/requirements.txt index cb3d700..2e52785 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ scanoss>=1.20.5 -rich>=13.9.3 \ No newline at end of file +rich>=13.9.3 +click==8.1.8 diff --git a/scanoss.json b/scanoss.json index 0843038..8c23a3a 100755 --- a/scanoss.json +++ b/scanoss.json @@ -2,9 +2,7 @@ "settings": { "skip": { "patterns": { - "scanning": [ - "src/hooks/__init__.py" - ] + "scanning": ["src/hooks/__init__.py"] }, "sizes": {} } @@ -24,4 +22,4 @@ } ] } -} \ No newline at end of file +} diff --git a/setup.cfg b/setup.cfg index 7408817..4ae62b4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -28,6 +28,7 @@ python_requires = >=3.9 install_requires = scanoss>=1.20.5 rich>=13.9.3 + click==8.1.8 [options.packages.find] where = src diff --git a/src/hooks/__init__.py b/src/hooks/__init__.py index cd57cb1..87e0d97 100644 --- a/src/hooks/__init__.py +++ b/src/hooks/__init__.py @@ -1,2 +1,25 @@ +""" +SPDX-License-Identifier: MIT -__version__ = '0.2.0' + Copyright (c) 2024, SCANOSS + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +""" + +__version__ = "0.3.0" diff --git a/src/hooks/check_undeclared_software.py b/src/hooks/check_undeclared_software.py index 68b821b..0ea4a10 100644 --- a/src/hooks/check_undeclared_software.py +++ b/src/hooks/check_undeclared_software.py @@ -21,79 +21,106 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations -import argparse import json import logging +import os import subprocess -from collections.abc import Sequence +from pathlib import Path +from typing import List +import click from rich.console import Console from rich.table import Table -from . import __version__ -from .utils import ( - get_staged_files, - log_and_exit, - maybe_remove_old_results, - maybe_setup_results_dir -) +DEFAULT_SCANOSS_SCAN_RESULTS_FILE = Path(".scanoss") / "results.json" -# Default settings file and results location -DEFAULT_RESULTS_DIR = ".scanoss" -DEFAULT_RESULTS_FILENAME = "results.json" -DEFAULT_RESULTS_PATH = f"{DEFAULT_RESULTS_DIR}/{DEFAULT_RESULTS_FILENAME}" +EXIT_SUCCESS = 0 +EXIT_FAILURE = 1 -console = Console() -logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" -) +LOG_FORMAT = "%(asctime)s - %(levelname)s - %(message)s" +console = Console() -def run_scan(scan_cmd: list[str]) -> None: - """Run the SCANOSS scan command. - Args: - scan_cmd (list[str]): SCANOSS scan command +def configure_logging(debug: bool) -> None: """ - try: - subprocess.run(scan_cmd, check=True) - except subprocess.CalledProcessError as e: - log_and_exit(f"SCANOSS scan failed: {e}", 1) + Configure global logging level based on the --debug flag. + """ + log_level = logging.DEBUG if debug else logging.INFO + logging.basicConfig(level=log_level, format=LOG_FORMAT, force=True) + logging.getLogger("scanoss").setLevel(log_level) + logging.debug("Debug mode enabled") -def present_results() -> None: - """Present the SCANOSS scan results.""" +def get_staged_files() -> list[str]: + """Get the list of staged files in the current git repository. + + Returns: + list[str]: list of staged files or an empty list if no files are staged. + """ try: - cmd_result = subprocess.run( - [ - "scanoss-py", - "results", - DEFAULT_RESULTS_PATH, - "--has-pending", - "--format", - "json", - ], + result = subprocess.run( + ["git", "diff", "--staged", "--name-only", "--diff-filter=ACMR"], capture_output=True, text=True, + check=True, ) - scan_results = cmd_result.stdout - # If the return code is 1, SCANOSS detected pending potential Open Source software that needs to be reviewed. - if cmd_result.returncode == 1: - scan_results_json = json.loads(scan_results) - present_results_table(scan_results_json) - exit(1) + staged_files = result.stdout.strip().split("\n") + return [f for f in staged_files if f] + except subprocess.CalledProcessError as e: + logging.error(f"Git command failed: {e}") except Exception as e: - log_and_exit(f"SCANOSS results command failed: {e}", 1) + logging.error(f"{e}") + return [] + + +def run_subcommand( + command: list[str], check: bool = True +) -> subprocess.CompletedProcess[str]: + """Run a command safely and return the completed process. + + Args: + command (list[str]): Command to run + check (bool): Whether to check the return code of the command + """ + return subprocess.run(command, check=check, capture_output=True, text=True) -def present_results_table(results: dict) -> None: +def sanitize_scan_command(command: List[str]) -> List[str]: + """ + Return a copy with sensitive flag values redacted. + + Args: + command (List[str]): The command arguments to sanitize. + + Returns: + List[str]: The sanitized command arguments. + """ + SENSITIVE_FLAGS = {"--key", "--proxy"} # proxy may embed creds + + sanitized = command.copy() + + for i, arg in enumerate(command): + for flag in SENSITIVE_FLAGS: + if arg == flag: + if i + 1 < len(sanitized): + sanitized[i + 1] = "*****" + elif arg.startswith(f"{flag}="): + sanitized[i] = f"{flag}=*****" + return sanitized + + +def present_results_table(results: dict, output_path: Path) -> None: """Present the files pending identification in a table. Args: results (dict): files pending identification + output_path (Path): path to the output file containing scan results """ + table = Table( show_header=True, header_style="bold magenta", @@ -106,7 +133,7 @@ def present_results_table(results: dict) -> None: table.add_column("Purl") table.add_column("License") - for file in results.get("results"): + for file in results.get("results", []): table.add_row( file.get("file"), file.get("status"), @@ -120,53 +147,188 @@ def present_results_table(results: dict) -> None: f"[bold red]SCANOSS detected [cyan]{results.get('total')}[/cyan] files containing potential Open Source Software:[/bold red]" ) console.print(table) - console.print( - "Run [green]'scanoss-cc'[/green] in the terminal to view the results in more detail." - ) + # Determine the appropriate command to show based on whether a custom output path was used + if output_path == DEFAULT_SCANOSS_SCAN_RESULTS_FILE: + command_msg = "Run [green]'scanoss-cc'[/green] in the terminal to view the results in more detail." + else: + command_msg = f"Run [green]'scanoss-cc --input {output_path}'[/green] in the terminal to view the results in more detail." -def ver(*_): - """Display version details - Args: - *_: ignore/unused - """ - print(f'Version: {__version__}') + console.print(command_msg) -def main(argv: Sequence[str] | None = None) -> int: - """Run the check undeclared OSS file/snippet hook +@click.command(context_settings={"help_option_names": ["-h", "--help"]}) +@click.option( + "--api-url", + type=click.STRING, + envvar="SCANOSS_SCAN_URL", + help="SCANOSS API URL (can also be set via environment variable SCANOSS_SCAN_URL).", +) +@click.option( + "--api-key", + type=click.STRING, + envvar="SCANOSS_API_KEY", + help="SCANOSS API key (can also be set via environment variable SCANOSS_API_KEY).", +) +@click.option( + "--proxy", + type=click.STRING, + envvar="HTTPS_PROXY", + help="Proxy URL to use for connections (optional). " + 'Can also use the environment variable "HTTPS_PROXY=:" ' + 'and "grcp_proxy=:" for gRPC', +) +@click.option( + "--pac", + type=click.STRING, + help='Proxy auto configuration (optional). Specify a file, http url or "auto" to try to discover it.', +) +@click.option( + "--ca-cert", + type=click.STRING, + envvar=["REQUESTS_CA_BUNDLE", "GRPC_DEFAULT_SSL_ROOTS_FILE_PATH"], + help="Alternative certificate PEM file (optional). " + "Can also use the environment variable " + '"REQUESTS_CA_BUNDLE=/path/to/cacert.pem" and ' + '"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH=/path/to/cacert.pem" for gRPC', +) +@click.option("--ignore-cert-errors", is_flag=True, help="Ignore certificate errors") +@click.option("--rest", is_flag=True, help="Use REST instead of gRPC") +@click.option( + "-o", + "--output", + type=click.Path(path_type=Path), + default=DEFAULT_SCANOSS_SCAN_RESULTS_FILE, + help=f"Output file for scan results (default: {DEFAULT_SCANOSS_SCAN_RESULTS_FILE})", +) +@click.option( + "-d", + "--debug", + is_flag=True, + default=os.environ.get("SCANOSS_DEBUG", "").lower() == "true", + help="Enable debug messages (can also be set via environment variable SCANOSS_DEBUG).", +) +@click.pass_context +def main( + ctx: click.Context, + api_url: str | None, + api_key: str | None, + proxy: str | None, + pac: str | None, + ca_cert: str | None, + ignore_cert_errors: bool, + rest: bool, + output: Path, + debug: bool, +) -> None: + """Check for potential undeclared open source software in staged files. - Returns: 0 on success 1 otherwise + This pre-commit hook scans staged files using SCANOSS to detect undeclared open source code. """ - # Setup args parser and display version details - parser = argparse.ArgumentParser() - parser.add_argument('--version', '-v', action='store_true', help='Display version details') - args = parser.parse_args(argv) - if args.version: - ver(parser, args) - return 0 - # Standard scanoss-py starting scan commands - maybe_setup_results_dir(DEFAULT_RESULTS_DIR) - maybe_remove_old_results(DEFAULT_RESULTS_PATH) + # TODO: Warn users if .scanoss is not in .gitignore + configure_logging(debug) + # Get the list of pending files to be scanned staged_files = get_staged_files() if not staged_files: - log_and_exit("No files to scan. Skipping SCANOSS.", 0) # Nothing to do + logging.info("No files to scan. Skipping SCANOSS") + ctx.exit(EXIT_SUCCESS) + + logging.debug(f"Staged files to scan: {staged_files}") scanoss_scan_cmd = [ "scanoss-py", "scan", "--no-wfp-output", - "--output", - DEFAULT_RESULTS_PATH, "--files", - *staged_files + *staged_files, + ] + + if api_url is not None: + scanoss_scan_cmd.extend(["--apiurl", api_url]) + + if api_key is not None: + scanoss_scan_cmd.extend(["--key", api_key]) + + if proxy is not None: + scanoss_scan_cmd.extend(["--proxy", proxy]) + + if pac is not None: + scanoss_scan_cmd.extend(["--pac", pac]) + + if ca_cert is not None: + scanoss_scan_cmd.extend(["--ca-cert", ca_cert]) + + if debug: + scanoss_scan_cmd.append("--debug") + + if ignore_cert_errors: + scanoss_scan_cmd.append("--ignore-cert-errors") + + if rest: + scanoss_scan_cmd.append("--rest") + + process_status = console.status("[bold green] Running SCANOSS scan...") + logging.debug(f"Executing command: {sanitize_scan_command(scanoss_scan_cmd)}") + + process_status.start() + + try: + scan_result = run_subcommand(scanoss_scan_cmd) + except subprocess.CalledProcessError as e: + logging.error( + f"Error running scanoss command with return code {e.returncode}: {e.stderr}" + ) + ctx.exit(EXIT_FAILURE) + + try: + output.parent.mkdir(parents=True, exist_ok=True) + logging.debug(f"Ensuring output directory exists: {output.parent}") + except OSError as e: + logging.error(f"Failed to create output directory {output.parent}: {e}") + process_status.stop() + ctx.exit(EXIT_FAILURE) + + try: + with open(output, "w") as output_file: + output_file.write(scan_result.stdout) + logging.debug(f"Scan results saved to {output}") + except OSError as e: + logging.error(f"Failed to write scan results to {output}: {e}") + process_status.stop() + ctx.exit(EXIT_FAILURE) + + scanoss_has_pending_command = [ + "scanoss-py", + "results", + str(output), + "--has-pending", + "--format", + "json", ] - # TODO add support for supplying a file-list file - run_scan(scanoss_scan_cmd) - present_results() - return 0 + + process_status.update("[bold green] Checking for pending results...") + + has_pending_results = run_subcommand(scanoss_has_pending_command, check=False) + if has_pending_results.returncode == 1: + try: + payload = json.loads(has_pending_results.stdout) + process_status.stop() + present_results_table(payload, output) + ctx.exit(EXIT_FAILURE) + except json.JSONDecodeError: + logging.error("Failed to parse JSON response from SCANOSS") + ctx.exit(EXIT_FAILURE) + if has_pending_results.returncode != 0: + logging.error( + f"SCANOSS 'results' command failed with exit code {has_pending_results.returncode}: {has_pending_results.stderr}" + ) + ctx.exit(has_pending_results.returncode) + + process_status.stop() + console.print("[bold green] ✅ No pending results found. It's safe to commit.") + ctx.exit(EXIT_SUCCESS) if __name__ == "__main__": - SystemExit(main()) + main() diff --git a/src/hooks/utils.py b/src/hooks/utils.py deleted file mode 100644 index 62f754e..0000000 --- a/src/hooks/utils.py +++ /dev/null @@ -1,86 +0,0 @@ -""" -SPDX-License-Identifier: MIT - - Copyright (c) 2024, SCANOSS - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in - all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - THE SOFTWARE. -""" - -import logging -import subprocess -from pathlib import Path - - -def maybe_setup_results_dir(results_dir: str): - """Create the results directory if it does not exist. - - Args: - results_dir: Results directory path - """ - if results_dir: - results_dir_path = Path(results_dir) - results_dir_path.mkdir(exist_ok=True) # TODO what about exceptions? - - -def maybe_remove_old_results(results_path: str): - """Remove old results directory if it exists. - Args: - results_path: Results file path - """ - if results_path: - results_file = Path(results_path) - if results_file.is_file(): - results_file.unlink(missing_ok=True) # TODO what about exceptions? - - -def log_and_exit(message: str, exit_code: int) -> None: - """Log a message and exit with the given code. - - Args: - message (str): message to log - exit_code (int): exit code - """ - if exit_code == 0: - logging.info(message) - else: - logging.error(message) - exit(exit_code) - - -def get_staged_files() -> list[str]: - """Get the list of staged files in the current git repository. - - Returns: - list[str]: list of staged files or an empty list if no files are staged. - """ - # TODO might be possible to use pre-commit git library for running git commands? - try: - result = subprocess.run( - ["git", "diff", "--staged", "--name-only", "--diff-filter=ACMR"], - capture_output=True, - text=True, - check=True, - ) - staged_files = result.stdout.strip().split("\n") - return [f for f in staged_files if f] - except subprocess.CalledProcessError as e: - logging.error(f"Git command failed: {e}") - except Exception as e: - logging.error(f"{e}") - return []