From 1f5ca9383efd26f517287ca5dd4ae249172564c3 Mon Sep 17 00:00:00 2001 From: yenner123 <> Date: Wed, 27 May 2026 17:46:01 -0500 Subject: [PATCH 1/8] feat: Add Grant license scanner and entry point for license scanning - Implemented GrantScan class for handling license scanning using the Grant tool. - Created entry point for the engine_license module to generate SBOM and process license reports. - Added unit tests for the GrantScan functionality and entry point logic. - Established directory structure for infrastructure and test cases related to the new license scanning feature. --- .../docs/life_cycle/getting_started.md | 7 +- .../life_cycle/remote_config_structure.md | 8 + docs/Docusaurus/docs/modules/engine_core.md | 10 + .../docs/modules/engine_sca/engine_license.md | 198 +++++++++++++++ .../engine_core/ConfigTool.json | 5 + .../engine_sca/engine_license/ConfigTool.json | 16 ++ .../src/applications/runner_engine_core.py | 3 + .../src/domain/usecases/handle_scan.py | 13 + .../test/domain/usecases/test_handle_scan.py | 88 +++++++ .../engine_sca/engine_license/__init__.py | 0 .../engine_sca/engine_license/src/__init__.py | 0 .../src/applications/__init__.py | 0 .../src/applications/runner_license_scan.py | 72 ++++++ .../engine_license/src/domain/__init__.py | 0 .../src/domain/model/__init__.py | 0 .../src/domain/model/gateways/__init__.py | 0 .../src/domain/model/gateways/tool_gateway.py | 18 ++ .../src/domain/usecases/__init__.py | 0 .../domain/usecases/build_license_report.py | 179 +++++++++++++ .../src/domain/usecases/license_policy.py | 240 ++++++++++++++++++ .../src/infrastructure/__init__.py | 0 .../driven_adapters/__init__.py | 0 .../driven_adapters/grant_tool/__init__.py | 0 .../grant_tool/grant_manager_scan.py | 212 ++++++++++++++++ .../infrastructure/entry_points/__init__.py | 0 .../entry_points/entry_point_tool.py | 88 +++++++ .../engine_license/test/__init__.py | 0 .../test/applications/__init__.py | 0 .../applications/test_runner_license_scan.py | 94 +++++++ .../engine_license/test/domain/__init__.py | 0 .../test/domain/usecases/__init__.py | 0 .../usecases/test_build_license_report.py | 238 +++++++++++++++++ .../domain/usecases/test_license_policy.py | 165 ++++++++++++ .../test/infrastructure/__init__.py | 0 .../driven_adapters/__init__.py | 0 .../driven_adapters/grant_tool/__init__.py | 0 .../grant_tool/test_grant_manager_scan.py | 132 ++++++++++ .../infrastructure/entry_points/__init__.py | 0 .../entry_points/test_entry_point_tool.py | 216 ++++++++++++++++ 39 files changed, 2001 insertions(+), 1 deletion(-) create mode 100644 docs/Docusaurus/docs/modules/engine_sca/engine_license.md create mode 100644 example_remote_config_local/engine_sca/engine_license/ConfigTool.json create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/__init__.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/src/__init__.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/src/applications/__init__.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/src/applications/runner_license_scan.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/__init__.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/model/__init__.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/model/gateways/__init__.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/model/gateways/tool_gateway.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/__init__.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/build_license_report.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/license_policy.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/__init__.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/__init__.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/grant_tool/__init__.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/grant_tool/grant_manager_scan.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/entry_points/__init__.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/entry_points/entry_point_tool.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/test/__init__.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/test/applications/__init__.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/test/applications/test_runner_license_scan.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/__init__.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/__init__.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_build_license_report.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_license_policy.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/__init__.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/__init__.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/grant_tool/__init__.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/grant_tool/test_grant_manager_scan.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/entry_points/__init__.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/entry_points/test_entry_point_tool.py diff --git a/docs/Docusaurus/docs/life_cycle/getting_started.md b/docs/Docusaurus/docs/life_cycle/getting_started.md index 6026d80e6..cc1a808a1 100644 --- a/docs/Docusaurus/docs/life_cycle/getting_started.md +++ b/docs/Docusaurus/docs/life_cycle/getting_started.md @@ -81,6 +81,11 @@ For more information about structure remote config visit [Structure Remote Confi TRIVY Free + + + ENGINE_LICENSE + GRANT + Free ENGINE_FUNCTION @@ -101,7 +106,7 @@ For more information about structure remote config visit [Structure Remote Confi ### Scan running - (CLI) - Flags ```bash -devsecops-engine-tools --platform_devops ["local","azure","github"] --remote_config_source ["local","azure","github"] --remote_config_repo ["remote_config_repo"] --remote_config_branch ["remote_config_branch"] --module ["engine_iac", "engine_dast", "engine_secret", "engine_dependencies", "engine_container", "engine_risk", "engine_code", "engine_function"] --tool ["nuclei", "bearer", "checkov", "kics", "kubescape", "trufflehog", "gitleaks", "prisma", "trivy", "xray", "dependency_check"] --folder_path ["Folder path scan engine_iac, engine_code, engine_dependencies and engine_secret"] --platform ["k8s","cloudformation","docker", "openapi", "terraform"] --use_secrets_manager ["false", "true"] --use_vulnerability_management ["false", "true"] --send_metrics ["false", "true"] --token_cmdb ["token_cmdb"] --token_vulnerability_management ["token_vulnerability_management"] --token_engine_container ["token_engine_container"] --token_engine_dependencies ["token_engine_dependencies"] --token_external_checks ["token_external_checks"] --xray_mode ["scan", "audit","build-scan"] --image_to_scan ["image_to_scan"] --dast_file_path ["dast_file_path"] --context ["false", "true"] --terraform_repo_root ["terraform_files_repo"] --docker_address ["docker addres to engine_container with Prisma tool"] +devsecops-engine-tools --platform_devops ["local","azure","github"] --remote_config_source ["local","azure","github"] --remote_config_repo ["remote_config_repo"] --remote_config_branch ["remote_config_branch"] --module ["engine_iac", "engine_dast", "engine_secret", "engine_dependencies", "engine_license", "engine_container", "engine_risk", "engine_code", "engine_function"] --tool ["nuclei", "bearer", "checkov", "kics", "kubescape", "trufflehog", "gitleaks", "prisma", "trivy", "xray", "dependency_check", "grant"] --folder_path ["Folder path scan engine_iac, engine_code, engine_dependencies and engine_secret"] --platform ["k8s","cloudformation","docker", "openapi", "terraform"] --use_secrets_manager ["false", "true"] --use_vulnerability_management ["false", "true"] --send_metrics ["false", "true"] --token_cmdb ["token_cmdb"] --token_vulnerability_management ["token_vulnerability_management"] --token_engine_container ["token_engine_container"] --token_engine_dependencies ["token_engine_dependencies"] --token_external_checks ["token_external_checks"] --xray_mode ["scan", "audit","build-scan"] --image_to_scan ["image_to_scan"] --dast_file_path ["dast_file_path"] --context ["false", "true"] --terraform_repo_root ["terraform_files_repo"] --docker_address ["docker addres to engine_container with Prisma tool"] ``` ### Scan running sample (CLI) - Local diff --git a/docs/Docusaurus/docs/life_cycle/remote_config_structure.md b/docs/Docusaurus/docs/life_cycle/remote_config_structure.md index 93461872c..84c257e46 100644 --- a/docs/Docusaurus/docs/life_cycle/remote_config_structure.md +++ b/docs/Docusaurus/docs/life_cycle/remote_config_structure.md @@ -41,6 +41,8 @@ sidebar_position: 2 โ”ƒ โ”— ๐Ÿ“‚engine_function โ”ƒ โ”— ๐Ÿ“œConfigTool.json โ”ƒ โ”— ๐Ÿ“œExclusions.json + โ”ƒ โ”— ๐Ÿ“‚engine_license + โ”ƒ โ”— ๐Ÿ“œConfigTool.json ``` ## **engine_core** @@ -104,6 +106,12 @@ Software Composition Analysis (SCA) is a process that detects compromised or vul For more information about structure remote config for this module, [engine dependencies](../modules/engine_sca/engine_dependencies.md) +### **engine_license** + +Software license compliance scanning. Detects denied / unlicensed dependencies in source folders, container images, and SBOMs, and enforces a configurable policy. + +For more information about structure remote config for this module, [engine license](../modules/engine_sca/engine_license.md) + ## **vulnerability_management** ### **vulnerability_management/cmdb_mapping** diff --git a/docs/Docusaurus/docs/modules/engine_core.md b/docs/Docusaurus/docs/modules/engine_core.md index a6dd7cefd..cd3f931df 100644 --- a/docs/Docusaurus/docs/modules/engine_core.md +++ b/docs/Docusaurus/docs/modules/engine_core.md @@ -234,6 +234,11 @@ Configuration of the driven adapters in the main layer and management of on/off "TOOL": "XRAY|DEPENDENCY_CHECK|TRIVY", "PRIORITY": "STANDARD|DISCREET" }, + "ENGINE_LICENSE": { + "ENABLED": true, + "TOOL": "GRANT", + "PRIORITY": "STANDARD|DISCREET" + }, "ENGINE_CODE": { "ENABLED": true, "TOOL": "BEARER|KIUWAN", @@ -440,6 +445,11 @@ Configuration of the driven adapters in the main layer and management of on/off - TOOL: XRAY | DEPENDENCY_CHECK | TRIVY - PRIORITY: STANDARD | DISCREET +- **ENGINE_LICENSE**: Configuration for the engine_license tool + - ENABLED: true or false + - TOOL: GRANT + - PRIORITY: STANDARD | DISCREET + - **ENGINE_FUNCTION**: Configuration for the engine_function tool - ENABLED: true or false - TOOL: PRISMA diff --git a/docs/Docusaurus/docs/modules/engine_sca/engine_license.md b/docs/Docusaurus/docs/modules/engine_sca/engine_license.md new file mode 100644 index 000000000..44701261f --- /dev/null +++ b/docs/Docusaurus/docs/modules/engine_sca/engine_license.md @@ -0,0 +1,198 @@ +# Module Engine License + +## Overview + +The `engine_license` module is a **standalone** license-compliance reporter inside the DevSecOps Engine Tools platform. It always scans the local repository, generates a fresh CycloneDX SBOM, runs the [Anchore Grant](https://github.com/anchore/grant) license inspector against that SBOM, classifies every dependency against a policy declared in remote configuration, and emits a single artifact: `{pipeline_name}_LICENSE.json`. + +Unlike other engines, `engine_license` does **not**: + +- Participate in `THRESHOLD` / break-build decisions. +- Use `Exclusions.json`. +- Push findings to vulnerability management or risk score. +- Reuse SBOMs from previous runs. +- Scan container images (the artifact is repository-only). + +The intent is to ship an audit-friendly, policy-driven JSON that downstream consumers can analyse out-of-band of build pipelines. + +> **Platform support:** Anchore Grant only ships binaries for **Linux (amd64, arm64)** and **macOS (amd64, arm64)**. Windows is not supported by upstream; the scanner is skipped with a logged warning. + +## Flow + +```mermaid +flowchart TD + A[handle_scan: engine_license branch] --> B[runner_engine_license] + B --> C[init_engine_license entry_point_tool] + C --> D[SbomManager.get_components
fresh {pipeline}_SBOM.json] + D --> E[GrantScan.run_tool_license_sca
analyses the SBOM] + E --> F[BuildLicenseReport
applies LICENSE_POLICY] + F --> G["Writes {pipeline}_LICENSE.json in CWD"] + G --> H[runner returns findings=[], input_core, sbom_components] + H --> I[handle_scan forwards findings, input_core] +``` + +The runner returns the standard `(findings_list, input_core, sbom_components)` triple to keep parity with other engines, but `findings_list` is always empty: the report itself lives in the LICENSE.json file referenced by `input_core.path_file_results`. + +## Configuration Structure + +Only one configuration file is consumed: `engine_sca/engine_license/ConfigTool.json`. There is no `Exclusions.json` and no `.grant.yaml`; the policy is declared inline. + +### ConfigTool.json + +```json +{ + "GRANT": { + "GRANT_VERSION": "0.6.4", + "OUTPUT_FORMAT": "json", + "QUIET": true, + "DEBUG_PIPELINES": [], + "LICENSE_POLICY": { + "fail": ["AGPL-*", "SSPL-*"], + "warn": ["BUSL-*", "EPL-*", "LGPL-3.0*"], + "synonyms": { + }, + "unlicensed_action": "ignore", + "unknown_action": "ignore" + } + } +} +``` + +#### `GRANT` block + +- **GRANT_VERSION**: Anchore Grant version to download. If `grant` is on `PATH`, it is reused. +- **OUTPUT_FORMAT**: Required to be `"json"` so the deserializer can parse Grant's report. Other values disable the report builder. +- **QUIET**: When `true`, passes `--quiet` to Grant. +- **DEBUG_PIPELINES**: Pipeline names that should log Grant's stdout/stderr for troubleshooting. + +#### `LICENSE_POLICY` block + +Declarative policy applied to every dependency Grant identifies. **All keys are required** for `engine_license` to produce a report; if `LICENSE_POLICY` is missing, no report is generated. + +- **fail**: Glob patterns (case-insensitive `fnmatch`) whose match marks the package as `fail` (severity `critical`). +- **warn**: Glob patterns whose match marks the package as `warn` (severity `medium`). +- **synonyms**: Object that rewrites raw license identifiers (e.g. `{"BSD": "BSD-3-Clause"}`) before matching. +- **unlicensed_action**: Action assigned to packages with no detected license. One of `fail` | `warn` | `info` | `ignore`. Controls only the severity tier; the package is always listed in the report under the `unlicensed` bucket. +- **unknown_action**: Action assigned to packages whose license label does not look like a valid SPDX identifier and matches no policy pattern. Same value set as above. The package is always listed under the `unknown` bucket. + +## Output Artifact: `{pipeline_name}_LICENSE.json` + +The report is written to the current working directory and uses a hybrid layout: a `metadata` block plus a flat `dependencies` array. + +```json +{ + "metadata": { + "pipeline_name": "AP0008001_CargaMasiva_HB_Lambda", + "scan_date": "2026-05-27T16:30:00", + "tool": "GRANT", + "policy_used": { + "fail": ["AGPL-*", "SSPL-*"], + "warn": ["BUSL-*", "EPL-*", "LGPL-3.0*"], + "synonyms": {}, + "unlicensed_action": "ignore", + "unknown_action": "ignore" + }, + "summary": { + "total_dependencies": 50, + "ok": 45, + "fail": 1, + "warn": 2, + "unlicensed": 1, + "unknown": 1 + } + }, + "dependencies": [ + { + "name": "lodash", + "version": "4.17.21", + "licenses": ["MIT"], + "policy_applied": "ok", + "policy_reason": "compliant SPDX license", + "policy_pattern_matched": null + } + ] +} +``` + +### Field reference + +#### `metadata` +- **pipeline_name**: Value of the `pipeline_name` DevOps platform variable. +- **scan_date**: ISO-8601 timestamp (seconds resolution) when the report was assembled. +- **tool**: Always `"GRANT"`. +- **policy_used**: Verbatim deep copy of `GRANT.LICENSE_POLICY` for auditability. +- **summary.total_dependencies**: Number of entries in `dependencies` (root project is excluded). +- **summary.{ok,fail,warn,unlicensed,unknown}**: Count per `policy_applied` bucket. + +#### `dependencies[]` +- **name / version**: Package identifiers as reported by Grant. +- **licenses**: List of normalized license identifiers (after applying `synonyms`). +- **policy_applied**: Bucket of the package โ€” one of `ok`, `fail`, `warn`, `unlicensed`, `unknown`. +- **policy_reason**: Human-readable explanation (e.g. `matches FAIL pattern 'AGPL-*'`). +- **policy_pattern_matched**: Original policy pattern that matched, or `null`. + +### Classification rules + +For each package Grant reports: + +1. If the package has **no licenses** โ†’ bucket `unlicensed`. +2. Otherwise the licenses are normalized through `synonyms` and each is classified: + - Match against `fail` patterns โ†’ `fail`. + - Match against `warn` patterns โ†’ `warn`. + - Otherwise, looks like an SPDX id โ†’ `ok`. + - Otherwise โ†’ `unknown`. +3. **Dual-license semantics:** if any license on the package classifies as `ok`, the package is `ok` (the consumer can legally pick the permissive option). +4. Otherwise the most permissive non-OK bucket is reported in this rank order: `warn` < `fail` < `unknown` (lighter restriction reported when both exist). +5. The package whose name equals the SBOM's source root (heuristically derived from the source `ref`) is dropped to avoid reporting the project against itself. + +## Main Responsibilities + +- **SBOM Generation:** Always invokes the SBOM manager (`cdxgen`) to produce `{pipeline_name}_SBOM.json` in CWD; previous SBOM files are overwritten. +- **License Scan:** Runs `grant list` against the freshly generated SBOM and saves Grant's JSON report. +- **Policy Application:** Classifies every package using `LICENSE_POLICY` from remote config. +- **Report Emission:** Writes the hybrid `{pipeline_name}_LICENSE.json` artifact in CWD. +- **Platform Detection:** Linux/macOS ร— amd64/arm64; Windows logs a warning and aborts the license step. + +## Key Components + +- `applications/runner_license_scan.py`: Entry point invoked by `engine_core/handle_scan`. Selects the tool (currently only Grant), runs the standalone flow, and assembles the minimal `InputCore` returned to downstream consumers. +- `infrastructure/entry_points/entry_point_tool.py`: Use case orchestrator: fetch remote config โ†’ fresh SBOM โ†’ Grant scan โ†’ build report. +- `infrastructure/driven_adapters/grant_tool/grant_manager_scan.py`: Driven adapter that downloads/installs the Grant binary and runs `grant list `. +- `domain/usecases/license_policy.py`: Pure helpers โ€” `build_policy_from_remote_config`, `classify_package`, `looks_like_spdx_id`, etc. No I/O, fully unit-testable. +- `domain/usecases/build_license_report.py`: `BuildLicenseReport` use case that reads Grant's JSON, classifies every dependency through `license_policy`, and writes the LICENSE.json artifact. + +## Supported Tools and Features + +- **Anchore Grant** (`grant list`) over CycloneDX SBOMs. +- **Multi-platform:** Linux amd64/arm64, macOS amd64/arm64. +- **Policy as configuration:** allow / fail / warn / ignore rules declared inline in `LICENSE_POLICY` (no separate `.grant.yaml`). +- **Audit-friendly artifact:** the LICENSE.json includes the original policy and per-dependency justification. + +## Example Usage + +### Repository (default โ€” folder mode) +```sh +devsecops-engine-tools \ + --platform_devops local \ + --remote_config_source local \ + --remote_config_repo example_remote_config_local \ + --module engine_license \ + --tool grant \ + --folder_path path/to/project +``` + +If `--folder_path` is omitted, the current working directory is scanned. + +> Container image scanning is intentionally not part of this module; it relies on the SBOM produced from the repository. + +> Windows runners will log a warning and skip the scan; configure your pipeline to run `engine_license` on Linux or macOS agents. + +## Configuration Guidelines + +- Pin `GRANT_VERSION` to a tested release of Anchore Grant. +- Keep `LICENSE_POLICY` in remote configuration so policy changes are reviewed and auditable; the report echoes it verbatim under `metadata.policy_used`. +- Use specific SPDX identifiers in `fail` / `warn` patterns when possible (e.g. `AGPL-3.0`, `LGPL-3.0*`); use globs sparingly. +- Use `synonyms` to canonicalize ambiguous labels coming from the SBOM (e.g. `"BSD"` โ†’ `"BSD-3-Clause"`). +- Choose `unlicensed_action` and `unknown_action` deliberately: + - `ignore` keeps the package in the report (so the auditor sees it) but assigns `info` severity. + - `warn` / `fail` raise the severity attached to those buckets. +- `engine_license` is intended for audit/reporting workflows and should typically run on its own pipeline (or a separate stage) rather than gating builds. diff --git a/example_remote_config_local/engine_core/ConfigTool.json b/example_remote_config_local/engine_core/ConfigTool.json index 21bbaab6b..e705d0708 100644 --- a/example_remote_config_local/engine_core/ConfigTool.json +++ b/example_remote_config_local/engine_core/ConfigTool.json @@ -217,6 +217,11 @@ "ENABLED": true, "TOOL": "XRAY|DEPENDENCY_CHECK|TRIVY" }, + "ENGINE_LICENSE": { + "ENABLED": true, + "TOOL": "GRANT", + "PRIORITY": "DISCREET" + }, "ENGINE_CODE": { "ENABLED": true, "TOOL": "BEARER|KIUWAN", diff --git a/example_remote_config_local/engine_sca/engine_license/ConfigTool.json b/example_remote_config_local/engine_sca/engine_license/ConfigTool.json new file mode 100644 index 000000000..f35fe3cf0 --- /dev/null +++ b/example_remote_config_local/engine_sca/engine_license/ConfigTool.json @@ -0,0 +1,16 @@ +{ + "GRANT": { + "GRANT_VERSION": "0.6.4", + "OUTPUT_FORMAT": "json", + "QUIET": true, + "DEBUG_PIPELINES": [], + "LICENSE_POLICY": { + "fail": ["AGPL-*", "SSPL-*"], + "warn": ["BUSL-*", "EPL-*", "LGPL-3.0*"], + "synonyms": { + }, + "unlicensed_action": "ignore", + "unknown_action": "ignore" + } + } +} diff --git a/tools/devsecops_engine_tools/engine_core/src/applications/runner_engine_core.py b/tools/devsecops_engine_tools/engine_core/src/applications/runner_engine_core.py index 64fa38c26..7f74d06cd 100755 --- a/tools/devsecops_engine_tools/engine_core/src/applications/runner_engine_core.py +++ b/tools/devsecops_engine_tools/engine_core/src/applications/runner_engine_core.py @@ -116,6 +116,7 @@ def get_inputs_from_cli(args): "xray", "dependency_check", "kiuwan", + "grant", "all_tools", ], type=str, @@ -132,6 +133,7 @@ def get_inputs_from_cli(args): "engine_secret", "engine_dependencies", "engine_container", + "engine_license", "engine_risk", "engine_function", ], @@ -266,6 +268,7 @@ def get_inputs_from_cli(args): "engine_secret": ["trufflehog", "gitleaks", "all_tools"], "engine_container": ["prisma", "trivy"], "engine_dependencies": ["xray", "dependency_check", "trivy"], + "engine_license": ["grant"], "engine_code": ["bearer", "kiuwan"], "engine_dast": ["nuclei"], "engine_risk": None, diff --git a/tools/devsecops_engine_tools/engine_core/src/domain/usecases/handle_scan.py b/tools/devsecops_engine_tools/engine_core/src/domain/usecases/handle_scan.py index 4732c3e38..67df44c57 100644 --- a/tools/devsecops_engine_tools/engine_core/src/domain/usecases/handle_scan.py +++ b/tools/devsecops_engine_tools/engine_core/src/domain/usecases/handle_scan.py @@ -46,6 +46,9 @@ from devsecops_engine_tools.engine_sca.engine_dependencies.src.applications.runner_dependencies_scan import ( runner_engine_dependencies, ) +from devsecops_engine_tools.engine_sca.engine_license.src.applications.runner_license_scan import ( + runner_engine_license, +) from devsecops_engine_tools.engine_sca.engine_function.src.applications.runner_function_scan import ( runner_engine_function, ) @@ -222,6 +225,16 @@ def process(self, dict_args: any, config_tool: any): self.risk_score_gateway.get_risk_score(findings_list, config_tool, dict_args["module"]) return findings_list, input_core + elif "engine_license" in dict_args["module"]: + findings_list, input_core, _sbom_components = runner_engine_license( + dict_args, + config_tool, + secret_tool, + self.devops_platform_gateway, + self.remote_config_source_gateway, + self.sbom_tool_gateway, + ) + return findings_list, input_core def _use_vulnerability_management( self, diff --git a/tools/devsecops_engine_tools/engine_core/test/domain/usecases/test_handle_scan.py b/tools/devsecops_engine_tools/engine_core/test/domain/usecases/test_handle_scan.py index 949ea1a40..eabd34a93 100644 --- a/tools/devsecops_engine_tools/engine_core/test/domain/usecases/test_handle_scan.py +++ b/tools/devsecops_engine_tools/engine_core/test/domain/usecases/test_handle_scan.py @@ -984,3 +984,91 @@ def test_define_threshold_quality_vuln_no_matching_pt(self, mock_runner_engine_c self.handle_scan.process(dict_args, config_tool) + + @mock.patch( + "devsecops_engine_tools.engine_core.src.domain.usecases.handle_scan.runner_engine_license" + ) + def test_process_with_engine_license_returns_runner_input_core( + self, mock_runner_engine_license + ): + """engine_license is standalone: runner builds the InputCore and the + handle_scan branch just forwards it. No VM, no risk_score.""" + dict_args = { + "use_secrets_manager": "false", + "module": "engine_license", + "use_vulnerability_management": "true", + "remote_config_repo": "test_repo", + "remote_config_branch": "", + } + config_tool = { + "VULNERABILITY_MANAGER": {}, + "ENGINE_LICENSE": {"ENABLED": "true", "TOOL": "GRANT"}, + } + + runner_input_core = InputCore( + totalized_exclusions=[], + threshold_defined=self.threshold, + path_file_results="/abs/svc_LICENSE.json", + custom_message_break_build="License scan completed", + scope_pipeline="svc", + scope_service="svc", + stage_pipeline="Build", + ) + mock_runner_engine_license.return_value = ( + [], + runner_input_core, + ["sbom_component"], + ) + + findings, input_core = self.handle_scan.process(dict_args, config_tool) + + self.assertEqual(findings, []) + self.assertIs(input_core, runner_input_core) + + self.vulnerability_management.send_vulnerability_management.assert_not_called() + self.risk_score_gateway.get_risk_score.assert_not_called() + + mock_runner_engine_license.assert_called_once_with( + dict_args, + config_tool, + mock.ANY, # secret_tool + self.devops_platform_gateway, + self.remote_config_source_gateway, + self.sbom_gateway, + ) + + @mock.patch( + "devsecops_engine_tools.engine_core.src.domain.usecases.handle_scan.runner_engine_license" + ) + def test_process_with_engine_license_propagates_runner_failure_input_core( + self, mock_runner_engine_license + ): + """When the runner reports a failed license report, handle_scan still + returns the InputCore the runner produced.""" + dict_args = { + "use_secrets_manager": "false", + "module": "engine_license", + "use_vulnerability_management": "true", + "remote_config_repo": "test_repo", + "remote_config_branch": "", + } + config_tool = { + "VULNERABILITY_MANAGER": {}, + "ENGINE_LICENSE": {"ENABLED": "true", "TOOL": "GRANT"}, + } + + failure_input_core = InputCore( + totalized_exclusions=[], + threshold_defined=self.threshold, + path_file_results=None, + custom_message_break_build="License scan completed", + scope_pipeline="svc", + scope_service="svc", + stage_pipeline="Build", + ) + mock_runner_engine_license.return_value = ([], failure_input_core, None) + + findings, input_core = self.handle_scan.process(dict_args, config_tool) + self.assertEqual(findings, []) + self.assertIsNone(input_core.path_file_results) + self.assertEqual(input_core.scope_pipeline, "svc") diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/__init__.py b/tools/devsecops_engine_tools/engine_sca/engine_license/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/__init__.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/applications/__init__.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/applications/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/applications/runner_license_scan.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/applications/runner_license_scan.py new file mode 100644 index 000000000..a3f5592fe --- /dev/null +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/src/applications/runner_license_scan.py @@ -0,0 +1,72 @@ +from devsecops_engine_tools.engine_core.src.domain.model.input_core import InputCore +from devsecops_engine_tools.engine_core.src.domain.model.threshold import Threshold +from devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.driven_adapters.grant_tool.grant_manager_scan import ( + GrantScan, +) +from devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.entry_points.entry_point_tool import ( + init_engine_license, +) + + +def runner_engine_license( + dict_args, + config_tool, + secret_tool, + devops_platform_gateway, + remote_config_source_gateway, + sbom_tool_gateway, +): + """Run the engine_license standalone flow. + + Produces ``{pipeline_name}_LICENSE.json`` in the CWD and assembles a + minimal :class:`InputCore` so downstream consumers (BreakBuild, + MetricsManager) keep working without participating in vulnerability + management or risk scoring. + + Returns: + Tuple ``(findings_list, input_core, sbom_components)`` mirroring + the contract used by other engine runners. ``findings_list`` is + always empty because engine_license is a standalone artifact + generator; severity decisions live inside the LICENSE.json. + """ + try: + tools_mapping = { + "GRANT": { + "tool_run": GrantScan, + "tool_sbom": sbom_tool_gateway, + } + } + + selected_tool = config_tool["ENGINE_LICENSE"]["TOOL"] + tool_run = tools_mapping[selected_tool]["tool_run"]() + tool_sbom = tools_mapping[selected_tool]["tool_sbom"] + + license_json_path, sbom_components = init_engine_license( + tool_run, + devops_platform_gateway, + remote_config_source_gateway, + dict_args, + secret_tool, + config_tool, + tool_sbom, + ) + + pipeline_name = devops_platform_gateway.get_variable("pipeline_name") + input_core = InputCore( + totalized_exclusions=[], + threshold_defined=Threshold({"VULNERABILITY": {}, "COMPLIANCE": {}}), + path_file_results=license_json_path, + custom_message_break_build="License scan completed", + scope_pipeline=pipeline_name, + scope_service=pipeline_name, + stage_pipeline="Build", + ) + + return [], input_core, sbom_components + + except Exception as e: + raise Exception(f"Error SCAN engine license : {str(e)}") + + +if __name__ == "__main__": + runner_engine_license() diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/__init__.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/model/__init__.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/model/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/model/gateways/__init__.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/model/gateways/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/model/gateways/tool_gateway.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/model/gateways/tool_gateway.py new file mode 100644 index 000000000..d27db47fa --- /dev/null +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/model/gateways/tool_gateway.py @@ -0,0 +1,18 @@ +from abc import ABCMeta, abstractmethod + + +class ToolGateway(metaclass=ABCMeta): + @abstractmethod + def run_tool_license_sca( + self, + remote_config, + dict_args, + exclusions, + pipeline_name, + to_scan, + sbom_path, + image_to_scan, + secret_tool, + **kwargs, + ) -> str: + "run tool license sca" diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/__init__.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/build_license_report.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/build_license_report.py new file mode 100644 index 000000000..76dac1fe6 --- /dev/null +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/build_license_report.py @@ -0,0 +1,179 @@ +"""Use case that builds the LICENSE.json artifact from a Grant scan report. + +The output is a hybrid JSON: top-level ``metadata`` block (pipeline name, +scan date, tool, applied policy echo, summary counts) plus a flat +``dependencies`` array listing every package found in the SBOM with its +license(s) and the policy decision. + +The artifact is written to ``{pipeline_name}_LICENSE.json`` in the chosen +output directory (CWD by default). +""" + +import copy +import json +import os +import re +from datetime import datetime + +from devsecops_engine_tools.engine_sca.engine_license.src.domain.usecases.license_policy import ( + build_policy_from_remote_config, + classify_package, + get_value, +) +from devsecops_engine_tools.engine_utilities.utils.logger_info import MyLogger +from devsecops_engine_tools.engine_utilities import settings + +logger = MyLogger.__call__(**settings.SETTING_LOGGER).get_logger() + +TOOL = "GRANT" +_SUMMARY_BUCKETS = ("ok", "fail", "warn", "unlicensed", "unknown") + + +class BuildLicenseReport: + """Build the LICENSE.json artifact from a Grant scan report. + + The ``process`` method returns the absolute path to the file it writes + or ``None`` when no report could be generated (e.g. invalid input + file, missing policy). + """ + + def __init__(self, output_dir: str = None): + self.output_dir = output_dir or os.getcwd() + + def process(self, grant_report_path, remote_config, pipeline_name): + policy = build_policy_from_remote_config(remote_config) + if policy is None: + logger.error( + "Cannot build LICENSE report: GRANT.LICENSE_POLICY missing." + ) + return None + + data = self._read_grant_report(grant_report_path) + if data is None: + return None + + dependencies = self._build_dependencies(data, policy) + report = { + "metadata": self._build_metadata( + pipeline_name, remote_config, dependencies + ), + "dependencies": dependencies, + } + return self._write_report(report, pipeline_name) + + @staticmethod + def _read_grant_report(grant_report_path): + if not grant_report_path: + logger.error("Grant report path is empty; cannot build LICENSE report.") + return None + if not os.path.exists(grant_report_path): + logger.error(f"Grant report not found: {grant_report_path}") + return None + try: + with open(grant_report_path, "r") as fh: + return json.load(fh) + except Exception as e: + logger.error(f"Error reading Grant report '{grant_report_path}': {e}") + return None + + def _build_dependencies(self, data, policy): + targets = self._extract_targets(data) + dependencies = [] + for target in targets: + source = get_value(target, "source", "Source", default={}) + source_ref = get_value(source, "ref", "Ref", default="") + source_root = self._source_root_name(source_ref) + + evaluation = get_value( + target, "evaluation", "Evaluation", default={} + ) + findings_block = get_value( + evaluation, "findings", "Findings", default={} + ) + packages = ( + get_value(findings_block, "packages", "Packages", default=[]) + or [] + ) + + for pkg in packages: + pkg_name = get_value(pkg, "name", "Name", default="unknown") + pkg_version = get_value(pkg, "version", "Version", default="") + licenses = ( + get_value(pkg, "licenses", "Licenses", default=[]) or [] + ) + + if source_root and self._is_root_project(pkg_name, source_root): + continue + + classification = classify_package(licenses, policy) + dependencies.append( + { + "name": pkg_name, + "version": pkg_version, + "licenses": classification["licenses"], + "policy_applied": classification["policy_applied"], + "policy_reason": classification["reason"], + "policy_pattern_matched": classification[ + "pattern_matched" + ], + } + ) + return dependencies + + @staticmethod + def _extract_targets(data): + run = get_value(data, "run", "Run", default={}) + targets = get_value(run, "targets", "Targets") + if targets: + return targets + return data.get("results") or data.get("Results") or [] + + @staticmethod + def _source_root_name(source_ref): + if not source_ref: + return "" + base = os.path.basename(source_ref) + base = re.sub(r"\.(cdx|spdx)?\.?(json|xml|yaml|yml)$", "", base, flags=re.I) + base = re.sub(r"[_\-]sbom$", "", base, flags=re.I) + return base.strip().lower() + + @staticmethod + def _is_root_project(pkg_name, source_root_name): + if not pkg_name or not source_root_name: + return False + return pkg_name.strip().lower() == source_root_name + + def _build_metadata(self, pipeline_name, remote_config, dependencies): + policy_used = copy.deepcopy( + (remote_config or {}).get(TOOL, {}).get("LICENSE_POLICY", {}) + ) + + summary = { + "total_dependencies": len(dependencies), + } + for bucket in _SUMMARY_BUCKETS: + summary[bucket] = sum( + 1 for d in dependencies if d["policy_applied"] == bucket + ) + + return { + "pipeline_name": pipeline_name, + "scan_date": datetime.now().isoformat(timespec="seconds"), + "tool": TOOL, + "policy_used": policy_used, + "summary": summary, + } + + def _write_report(self, report, pipeline_name): + os.makedirs(self.output_dir, exist_ok=True) + file_name = f"{pipeline_name}_LICENSE.json" + path = os.path.join(self.output_dir, file_name) + try: + with open(path, "w") as fh: + json.dump(report, fh, indent=2) + abs_path = os.path.abspath(path) + logger.info(f"License report saved to: {abs_path}") + return abs_path + except Exception as e: + logger.error(f"Error writing LICENSE report '{path}': {e}") + return None diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/license_policy.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/license_policy.py new file mode 100644 index 000000000..b70131690 --- /dev/null +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/license_policy.py @@ -0,0 +1,240 @@ +"""Pure license classification helpers used by the engine_license module. + +Functions in this module are stateless and do not perform I/O. They are +shared by the LICENSE.json builder and any other consumer that needs to +classify SBOM packages against a remote_config policy. + +Policy buckets exposed via ``classify_package`` map 1:1 to the summary +buckets in the LICENSE.json artifact: ``ok``, ``fail``, ``warn``, +``unlicensed`` and ``unknown``. +""" + +import fnmatch +import re + +from devsecops_engine_tools.engine_utilities.utils.logger_info import MyLogger +from devsecops_engine_tools.engine_utilities import settings + +logger = MyLogger.__call__(**settings.SETTING_LOGGER).get_logger() + +TOOL = "GRANT" + +SEVERITY_BY_ACTION = { + "fail": "critical", + "warn": "medium", + "ok": "info", + "ignore": "info", +} + +ACTION_TO_SEVERITY = SEVERITY_BY_ACTION + +_VALID_ACTIONS = {"fail", "warn", "info", "ignore"} + + +def get_value(obj, *keys, default=None): + """Return the first non-None value among the given key variants.""" + if not isinstance(obj, dict): + return default + for key in keys: + if key in obj and obj[key] is not None: + return obj[key] + return default + + +def validate_action(value, default): + """Return value (lowercased) if it is a valid action, else default.""" + v = str(value).lower().strip() + return v if v in _VALID_ACTIONS else default + + +def looks_like_spdx_id(label): + """Heuristic: SPDX ids are short, no spaces, alphanumeric + a few symbols.""" + if not label or len(label) > 60: + return False + if " " in label: + return False + return bool(re.match(r"^[A-Za-z0-9.\-+]+$", label)) + + +def build_policy_from_remote_config(remote_config): + """Build the policy dict exclusively from remote_config GRANT.LICENSE_POLICY. + + Returns None (and logs error) if the configuration is absent. + """ + if not (remote_config and isinstance(remote_config, dict)): + logger.error("No remote_config provided: LICENSE_POLICY is required.") + return None + + override = (remote_config.get(TOOL) or {}).get("LICENSE_POLICY") + if not isinstance(override, dict): + logger.error( + "remote_config missing GRANT.LICENSE_POLICY: cannot classify licenses." + ) + return None + + fail_raw = override.get("fail", []) + warn_raw = override.get("warn", []) + + fail_list = [str(p) for p in fail_raw] if isinstance(fail_raw, list) else [] + warn_list = [str(p) for p in warn_raw] if isinstance(warn_raw, list) else [] + + synonyms_raw = override.get("synonyms", {}) + synonyms = { + str(k): str(v) + for k, v in (synonyms_raw.items() if isinstance(synonyms_raw, dict) else []) + } + + return { + "fail": fail_list, + "warn": warn_list, + "synonyms": synonyms, + "unlicensed_action": validate_action( + override.get("unlicensed_action", "ignore"), "ignore" + ), + "unknown_action": validate_action( + override.get("unknown_action", "ignore"), "ignore" + ), + } + + +def _classify_label(normalized_label, policy): + """Classify a single normalized license label. + + Returns a dict with: bucket โˆˆ {fail, warn, ok, unknown}, reason, + pattern_matched (str|None). + """ + label = (normalized_label or "").strip() + label_lc = label.lower() + + for pattern in policy["fail"]: + if fnmatch.fnmatchcase(label_lc, pattern.lower()): + return { + "bucket": "fail", + "reason": f"matches FAIL pattern '{pattern}'", + "pattern_matched": pattern, + } + + for pattern in policy["warn"]: + if fnmatch.fnmatchcase(label_lc, pattern.lower()): + return { + "bucket": "warn", + "reason": f"matches WARN pattern '{pattern}'", + "pattern_matched": pattern, + } + + if looks_like_spdx_id(label): + return { + "bucket": "ok", + "reason": "compliant SPDX license", + "pattern_matched": None, + } + + return { + "bucket": "unknown", + "reason": "non-SPDX license label", + "pattern_matched": None, + } + + +def _extract_license_id(license_entry): + """Best-effort extraction of a license identifier from a SBOM/Grant entry.""" + return ( + get_value( + license_entry, + "id", "Id", "ID", + "spdxExpression", "SpdxExpression", + "name", "Name", + default="", + ) + or "" + ) + + +def classify_package(licenses, policy): + """Classify all licenses on a single package against the policy. + + Dual-license semantics: if ANY license on the package is fully compliant + (bucket == "ok"), the package is compliant โ€” the consumer can legally pick + the permissive option. The single most permissive non-ok candidate is + reported otherwise (warn ranks above fail and unknown because it is the + lightest restriction). + + Args: + licenses: list of license entries (dicts) from the SBOM/Grant report. + May be empty, in which case the package is considered unlicensed. + policy: policy dict produced by ``build_policy_from_remote_config``. + + Returns: + dict with keys: + - policy_applied: one of {ok, fail, warn, unlicensed, unknown} + - label: normalized license label that was applied (or + "UNLICENSED"/"UNKNOWN" sentinels) + - licenses: list[str] of all normalized license labels detected + - reason: human-readable explanation + - pattern_matched: pattern from policy that matched, or None + - severity: effective severity tier for this entry + """ + if policy is None: + return { + "policy_applied": "unknown", + "label": "UNKNOWN", + "licenses": [], + "reason": "no policy available", + "pattern_matched": None, + "severity": SEVERITY_BY_ACTION["ignore"], + } + + if not licenses: + action = policy["unlicensed_action"] + return { + "policy_applied": "unlicensed", + "label": "UNLICENSED", + "licenses": [], + "reason": "no license detected", + "pattern_matched": None, + "severity": SEVERITY_BY_ACTION.get(action, "info"), + } + + normalized_labels = [] + classified = [] + for lic in licenses: + raw_id = _extract_license_id(lic) + normalized = ( + policy["synonyms"].get(raw_id, raw_id).strip() or "UNKNOWN" + ) + normalized_labels.append(normalized) + classified.append((normalized, _classify_label(normalized, policy))) + + for label, result in classified: + if result["bucket"] == "ok": + return { + "policy_applied": "ok", + "label": label, + "licenses": normalized_labels, + "reason": result["reason"], + "pattern_matched": result["pattern_matched"], + "severity": SEVERITY_BY_ACTION["ok"], + } + + rank = {"warn": 0, "fail": 1, "unknown": 2} + + def _key(item): + return rank.get(item[1]["bucket"], 99) + + classified.sort(key=_key) + label, result = classified[0] + bucket = result["bucket"] + + if bucket == "unknown": + severity = SEVERITY_BY_ACTION.get(policy["unknown_action"], "info") + else: + severity = SEVERITY_BY_ACTION.get(bucket, "info") + + return { + "policy_applied": bucket, + "label": label, + "licenses": normalized_labels, + "reason": result["reason"], + "pattern_matched": result["pattern_matched"], + "severity": severity, + } diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/__init__.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/__init__.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/grant_tool/__init__.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/grant_tool/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/grant_tool/grant_manager_scan.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/grant_tool/grant_manager_scan.py new file mode 100644 index 000000000..8205d0b31 --- /dev/null +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/grant_tool/grant_manager_scan.py @@ -0,0 +1,212 @@ +import os +import platform +import shutil +import subprocess +import tarfile + +import requests + +from devsecops_engine_tools.engine_sca.engine_license.src.domain.model.gateways.tool_gateway import ( + ToolGateway, +) +from devsecops_engine_tools.engine_utilities.utils.logger_info import MyLogger +from devsecops_engine_tools.engine_utilities import settings + +logger = MyLogger.__call__(**settings.SETTING_LOGGER).get_logger() + + +class GrantScan(ToolGateway): + """Anchore Grant license scanner driven adapter. + + Supports Linux (amd64, arm64) and macOS (amd64, arm64). Windows is not + supported by the upstream Grant project and is rejected with a logged + warning. + """ + + TOOL = "GRANT" + + def __init__(self): + self.download_tool_called = False + + def run_tool_license_sca( + self, + remote_config, + dict_args, + exclusions, + pipeline_name, + to_scan, + sbom_path, + image_to_scan, + secret_tool, + **kwargs, + ): + try: + grant_config = remote_config.get(self.TOOL, {}) + grant_version = grant_config.get("GRANT_VERSION", "0.6.4") + output_format = grant_config.get("OUTPUT_FORMAT", "json") + debug_pipelines = grant_config.get("DEBUG_PIPELINES", []) + quiet = grant_config.get("QUIET", True) + enable_debug = pipeline_name in debug_pipelines if debug_pipelines else False + + command_prefix = self._resolve_binary(grant_version) + if not command_prefix: + return None + + scan_target = self._resolve_scan_target( + sbom_path, image_to_scan, to_scan + ) + if not scan_target: + logger.error("Grant scan target could not be resolved (no SBOM, image or folder).") + return None + + return self._run_grant( + command_prefix, + scan_target, + pipeline_name, + output_format, + quiet, + enable_debug, + ) + except Exception as e: + logger.error(f"Error running Grant license scan: {e}") + return None + + def _resolve_scan_target(self, sbom_path, image_to_scan, to_scan): + if sbom_path and os.path.exists(sbom_path): + return sbom_path + if image_to_scan: + return image_to_scan + if to_scan and os.path.exists(to_scan): + return to_scan + return None + + def _resolve_binary(self, grant_version): + installed = shutil.which("grant") + if installed: + logger.info(f"Using Grant from PATH: {installed}") + return installed + + os_platform = platform.system() + os_architecture = platform.machine() + + if os_platform == "Windows": + logger.warning( + "Anchore Grant does not provide a Windows binary. " + "Skipping license scan on Windows." + ) + return None + + os_token, arch_token = self._map_platform(os_platform, os_architecture) + if not os_token or not arch_token: + logger.warning( + f"Unsupported platform for Grant: {os_platform}/{os_architecture}" + ) + return None + + file_name = f"grant_{grant_version}_{os_token}_{arch_token}.tar.gz" + url = ( + f"https://github.com/anchore/grant/releases/download/" + f"v{grant_version}/{file_name}" + ) + return self._install_tool_unix(file_name, url) + + def _map_platform(self, os_platform, os_architecture): + os_map = {"Linux": "linux", "Darwin": "darwin"} + arch_map = { + "x86_64": "amd64", + "amd64": "amd64", + "arm64": "arm64", + "aarch64": "arm64", + } + return os_map.get(os_platform), arch_map.get(os_architecture) + + def _install_tool_unix(self, file_name, url): + try: + self.download_tool_called = True + self._download_tool(file_name, url) + + extract_dir = os.path.join(os.getcwd(), "grant_bin") + os.makedirs(extract_dir, exist_ok=True) + with tarfile.open(file_name, "r:gz") as tar: + tar.extractall(extract_dir) + + binary_path = os.path.join(extract_dir, "grant") + if not os.path.exists(binary_path): + logger.error(f"Grant binary not found after extracting {file_name}") + return None + + subprocess.run( + ["chmod", "+x", binary_path], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + logger.info(f"Installed Grant binary: {binary_path}") + return binary_path + except Exception as e: + logger.error(f"Error installing Grant: {e}") + return None + + def _download_tool(self, file_name, url): + try: + response = requests.get(url, allow_redirects=True) + with open(file_name, "wb") as compress_file: + compress_file.write(response.content) + except Exception as e: + logger.error(f"Error downloading Grant: {e}") + raise + + def _run_grant( + self, + command_prefix, + scan_target, + pipeline_name, + output_format, + quiet, + enable_debug, + ): + result_file = f"{pipeline_name}_grant.json" + + command = [ + command_prefix, + "list", + scan_target, + "-o", + output_format, + "-f", + result_file, + ] + + if quiet: + command.append("--quiet") + + try: + result = subprocess.run( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + if enable_debug: + if result.stdout: + logger.info(f"GRANT stdout (first 4kb): {result.stdout[:4096]}") + if result.stderr: + logger.info(f"GRANT stderr: {result.stderr}") + + if os.path.exists(result_file) and os.path.getsize(result_file) > 0: + logger.info(f"Grant report saved to: {result_file}") + return result_file + + if result.stdout and result.stdout.strip().startswith("{"): + with open(result_file, "w") as f: + f.write(result.stdout) + logger.info(f"Grant report saved to: {result_file}") + return result_file + + logger.error( + f"Grant produced no output (exit={result.returncode}): {result.stderr}" + ) + return None + except Exception as e: + logger.error(f"Error executing Grant: {e}") + return None diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/entry_points/__init__.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/entry_points/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/entry_points/entry_point_tool.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/entry_points/entry_point_tool.py new file mode 100644 index 000000000..eef901f36 --- /dev/null +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/entry_points/entry_point_tool.py @@ -0,0 +1,88 @@ +"""Entry point of the engine_license module. + +The flow is intentionally linear and standalone (it does NOT participate +in the build pipeline gating logic): + + 1. Always generate a fresh SBOM of the local repository (no cache reuse, + no branch filter, no image scanning). + 2. Run Grant against that SBOM. + 3. Build the ``{pipeline_name}_LICENSE.json`` artifact from Grant's + output, applying the policy declared in remote_config. + +The entry point returns ``(license_json_path, sbom_components)``. +""" + +import os + +from devsecops_engine_tools.engine_sca.engine_license.src.domain.usecases.build_license_report import ( + BuildLicenseReport, +) +from devsecops_engine_tools.engine_core.src.domain.model.gateway.devops_platform_gateway import ( + DevopsPlatformGateway, +) +from devsecops_engine_tools.engine_core.src.domain.model.gateway.sbom_manager import ( + SbomManagerGateway, +) +from devsecops_engine_tools.engine_utilities.utils.logger_info import MyLogger +from devsecops_engine_tools.engine_utilities import settings + +logger = MyLogger.__call__(**settings.SETTING_LOGGER).get_logger() + + +def init_engine_license( + tool_run, + devops_platform_gateway: DevopsPlatformGateway, + remote_config_source_gateway: DevopsPlatformGateway, + dict_args, + secret_tool, + config_tool, + tool_sbom: SbomManagerGateway, +): + """Run the standalone engine_license flow. + + Returns a tuple ``(license_json_path, sbom_components)``. Either or + both elements may be ``None`` if the corresponding step failed. + """ + remote_config = remote_config_source_gateway.get_remote_config( + dict_args["remote_config_repo"], + "engine_sca/engine_license/ConfigTool.json", + dict_args["remote_config_branch"], + ) + + pipeline_name = devops_platform_gateway.get_variable("pipeline_name") + to_scan = dict_args.get("folder_path") or os.getcwd() + + if not os.path.exists(to_scan): + logger.error(f"Path {to_scan} does not exist; aborting license scan.") + return None, None + + config_sbom = config_tool.get("SBOM_MANAGER", {}) or {} + if tool_sbom is None: + logger.error("SBOM tool gateway is not configured; aborting license scan.") + return None, None + sbom_components = tool_sbom.get_components(to_scan, config_sbom, pipeline_name) + sbom_path = f"{pipeline_name}_SBOM.json" + if not os.path.exists(sbom_path): + logger.error( + f"SBOM file {sbom_path} not found after generation; aborting license scan." + ) + return None, sbom_components + + grant_report_path = tool_run.run_tool_license_sca( + remote_config, + dict_args, + None, + pipeline_name, + to_scan, + sbom_path, + None, + secret_tool, + ) + if not grant_report_path: + logger.error("Grant scan produced no output; aborting LICENSE report build.") + return None, sbom_components + + license_json_path = BuildLicenseReport().process( + grant_report_path, remote_config, pipeline_name + ) + return license_json_path, sbom_components diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/__init__.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/applications/__init__.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/applications/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/applications/test_runner_license_scan.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/applications/test_runner_license_scan.py new file mode 100644 index 000000000..61fe9cdd4 --- /dev/null +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/test/applications/test_runner_license_scan.py @@ -0,0 +1,94 @@ +from unittest.mock import MagicMock, patch + +import pytest +import runpy + +from devsecops_engine_tools.engine_sca.engine_license.src.applications.runner_license_scan import ( + runner_engine_license, +) + + +def _make_devops_gateway(pipeline_name="svc"): + gw = MagicMock() + gw.get_variable.side_effect = lambda name: { + "pipeline_name": pipeline_name, + }.get(name, "") + return gw + + +def test_runner_engine_license_grant_returns_findings_input_core_and_components(): + with patch( + "devsecops_engine_tools.engine_sca.engine_license.src.applications.runner_license_scan.init_engine_license" + ) as mock_init, patch( + "devsecops_engine_tools.engine_sca.engine_license.src.applications.runner_license_scan.GrantScan" + ): + mock_init.return_value = ("/abs/svc_LICENSE.json", ["c1"]) + + config_tool = {"ENGINE_LICENSE": {"TOOL": "GRANT"}} + devops_gw = _make_devops_gateway() + + findings, input_core, sbom_components = runner_engine_license( + {"remote_config_repo": "r", "remote_config_branch": ""}, + config_tool, + None, + devops_gw, + None, + None, + ) + + mock_init.assert_called_once() + + assert findings == [] + assert sbom_components == ["c1"] + + assert input_core.path_file_results == "/abs/svc_LICENSE.json" + assert input_core.totalized_exclusions == [] + assert input_core.scope_pipeline == "svc" + assert input_core.scope_service == "svc" + assert input_core.stage_pipeline == "Build" + assert input_core.custom_message_break_build == "License scan completed" + + assert input_core.threshold_defined is not None + assert input_core.threshold_defined.vulnerability.critical is None + assert input_core.threshold_defined.compliance.critical is None + + +def test_runner_engine_license_propagates_none_path(): + """When init_engine_license fails, runner still returns a usable InputCore.""" + with patch( + "devsecops_engine_tools.engine_sca.engine_license.src.applications.runner_license_scan.init_engine_license" + ) as mock_init, patch( + "devsecops_engine_tools.engine_sca.engine_license.src.applications.runner_license_scan.GrantScan" + ): + mock_init.return_value = (None, None) + config_tool = {"ENGINE_LICENSE": {"TOOL": "GRANT"}} + devops_gw = _make_devops_gateway() + + findings, input_core, sbom_components = runner_engine_license( + {"remote_config_repo": "r", "remote_config_branch": ""}, + config_tool, + None, + devops_gw, + None, + None, + ) + + assert findings == [] + assert sbom_components is None + assert input_core.path_file_results is None + assert input_core.scope_pipeline == "svc" + + +def test_runner_engine_license_unknown_tool_raises(): + config_tool = {"ENGINE_LICENSE": {"TOOL": "UNKNOWN"}} + with pytest.raises(Exception, match="Error SCAN engine license"): + runner_engine_license({}, config_tool, None, None, None, None) + + +def test_runner_license_main_block(): + with pytest.raises((TypeError, Exception)): + runpy.run_module( + "devsecops_engine_tools.engine_sca.engine_license.src.applications.runner_license_scan", + run_name="__main__", + alter_sys=True, + ) diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/__init__.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/__init__.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_build_license_report.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_build_license_report.py new file mode 100644 index 000000000..37f8fac60 --- /dev/null +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_build_license_report.py @@ -0,0 +1,238 @@ +import json +import os +from unittest.mock import patch + +import pytest + +from devsecops_engine_tools.engine_sca.engine_license.src.domain.usecases.build_license_report import ( + BuildLicenseReport, +) + + +def _remote_config(): + return { + "GRANT": { + "LICENSE_POLICY": { + "fail": ["AGPL-*"], + "warn": ["BUSL-*"], + "synonyms": {}, + "unlicensed_action": "warn", + "unknown_action": "ignore", + } + } + } + + +def _grant_payload(packages, source_ref="path/to/my-app_sbom.json"): + return { + "run": { + "targets": [ + { + "source": {"ref": source_ref}, + "evaluation": { + "findings": {"packages": packages}, + }, + } + ] + } + } + + +def _write_grant(tmp_path, payload): + path = tmp_path / "grant_report.json" + path.write_text(json.dumps(payload)) + return str(path) + + +# ---------------------------------------------------------------- happy path + + +def test_process_writes_license_json_with_mixed_classifications(tmp_path): + packages = [ + {"name": "lodash", "version": "4.17.21", "licenses": [{"id": "MIT"}]}, + {"name": "ngrx", "version": "1.0.0", "licenses": [{"id": "AGPL-3.0"}]}, + {"name": "biz-lib", "version": "2.0.0", "licenses": [{"id": "BUSL-1.1"}]}, + {"name": "no-lic-pkg", "version": "0.1.0", "licenses": []}, + { + "name": "weird-pkg", + "version": "0.0.1", + "licenses": [{"name": "Custom Proprietary License"}], + }, + {"name": "my-app", "version": "1.0.0", "licenses": [{"id": "MIT"}]}, + ] + report_path = _write_grant(tmp_path, _grant_payload(packages)) + + use_case = BuildLicenseReport(output_dir=str(tmp_path)) + out_path = use_case.process(report_path, _remote_config(), "svc") + + assert out_path is not None + assert os.path.exists(out_path) + assert out_path.endswith("svc_LICENSE.json") + + with open(out_path) as fh: + report = json.load(fh) + + md = report["metadata"] + assert md["pipeline_name"] == "svc" + assert md["tool"] == "GRANT" + assert md["policy_used"] == _remote_config()["GRANT"]["LICENSE_POLICY"] + assert md["summary"]["total_dependencies"] == 5 + assert md["summary"]["ok"] == 1 + assert md["summary"]["fail"] == 1 + assert md["summary"]["warn"] == 1 + assert md["summary"]["unlicensed"] == 1 + assert md["summary"]["unknown"] == 1 + + deps = report["dependencies"] + assert len(deps) == 5 + names = [d["name"] for d in deps] + assert names == ["lodash", "ngrx", "biz-lib", "no-lic-pkg", "weird-pkg"] + + ngrx = next(d for d in deps if d["name"] == "ngrx") + assert ngrx["policy_applied"] == "fail" + assert ngrx["policy_pattern_matched"] == "AGPL-*" + assert "AGPL-*" in ngrx["policy_reason"] + + no_lic = next(d for d in deps if d["name"] == "no-lic-pkg") + assert no_lic["policy_applied"] == "unlicensed" + assert no_lic["licenses"] == [] + + +def test_process_dual_license_ok_dominates(tmp_path): + packages = [ + { + "name": "dual-pkg", + "version": "1.0", + "licenses": [{"id": "AGPL-3.0"}, {"id": "MIT"}], + } + ] + report_path = _write_grant(tmp_path, _grant_payload(packages)) + + out = BuildLicenseReport(output_dir=str(tmp_path)).process( + report_path, _remote_config(), "svc" + ) + + with open(out) as fh: + report = json.load(fh) + dep = report["dependencies"][0] + assert dep["policy_applied"] == "ok" + assert sorted(dep["licenses"]) == ["AGPL-3.0", "MIT"] + + +def test_process_metadata_policy_used_is_deep_copy(tmp_path): + """Mutating the returned report must not affect the source remote_config.""" + config = _remote_config() + report_path = _write_grant( + tmp_path, + _grant_payload( + [{"name": "x", "version": "1", "licenses": [{"id": "MIT"}]}] + ), + ) + out = BuildLicenseReport(output_dir=str(tmp_path)).process( + report_path, config, "svc" + ) + + with open(out) as fh: + report = json.load(fh) + report["metadata"]["policy_used"]["fail"].append("MUTATED") + assert config["GRANT"]["LICENSE_POLICY"]["fail"] == ["AGPL-*"] + +def test_process_returns_none_when_remote_config_missing(tmp_path): + report_path = _write_grant(tmp_path, _grant_payload([])) + assert ( + BuildLicenseReport(output_dir=str(tmp_path)).process( + report_path, {}, "svc" + ) + is None + ) + + +def test_process_returns_none_when_grant_report_missing(tmp_path): + out = BuildLicenseReport(output_dir=str(tmp_path)).process( + str(tmp_path / "does_not_exist.json"), _remote_config(), "svc" + ) + assert out is None + + +def test_process_returns_none_when_grant_report_path_is_empty(tmp_path): + out = BuildLicenseReport(output_dir=str(tmp_path)).process( + "", _remote_config(), "svc" + ) + assert out is None + + +def test_process_returns_none_when_grant_report_invalid_json(tmp_path): + bad = tmp_path / "bad.json" + bad.write_text("not-json{{{") + out = BuildLicenseReport(output_dir=str(tmp_path)).process( + str(bad), _remote_config(), "svc" + ) + assert out is None + + +def test_process_handles_empty_targets(tmp_path): + report_path = _write_grant(tmp_path, {"run": {"targets": []}}) + out = BuildLicenseReport(output_dir=str(tmp_path)).process( + report_path, _remote_config(), "svc" + ) + with open(out) as fh: + report = json.load(fh) + assert report["dependencies"] == [] + assert report["metadata"]["summary"]["total_dependencies"] == 0 + + +def test_process_falls_back_to_results_key(tmp_path): + payload = { + "results": [ + { + "source": {"ref": "x.json"}, + "evaluation": { + "findings": { + "packages": [ + {"name": "p", "version": "1", "licenses": [{"id": "MIT"}]} + ] + } + }, + } + ] + } + report_path = _write_grant(tmp_path, payload) + out = BuildLicenseReport(output_dir=str(tmp_path)).process( + report_path, _remote_config(), "svc" + ) + with open(out) as fh: + report = json.load(fh) + assert len(report["dependencies"]) == 1 + + +def test_process_returns_none_when_write_fails(tmp_path): + """If file writing fails, process returns None.""" + report_path = _write_grant( + tmp_path, + _grant_payload( + [{"name": "x", "version": "1", "licenses": [{"id": "MIT"}]}] + ), + ) + use_case = BuildLicenseReport(output_dir=str(tmp_path)) + with patch( + "devsecops_engine_tools.engine_sca.engine_license.src.domain.usecases.build_license_report.open", + side_effect=OSError("disk full"), + ): + pass + + with patch.object(use_case, "_write_report", return_value=None): + out = use_case.process(report_path, _remote_config(), "svc") + assert out is None + + +def test_default_output_dir_is_cwd(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + report_path = _write_grant( + tmp_path, + _grant_payload( + [{"name": "x", "version": "1", "licenses": [{"id": "MIT"}]}] + ), + ) + out = BuildLicenseReport().process(report_path, _remote_config(), "svc") + assert out is not None + assert os.path.dirname(out) == str(tmp_path) diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_license_policy.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_license_policy.py new file mode 100644 index 000000000..316fe4df1 --- /dev/null +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_license_policy.py @@ -0,0 +1,165 @@ +from devsecops_engine_tools.engine_sca.engine_license.src.domain.usecases.license_policy import ( + build_policy_from_remote_config, + classify_package, + get_value, + looks_like_spdx_id, + validate_action, + ACTION_TO_SEVERITY, + SEVERITY_BY_ACTION, +) + +def _policy(**overrides): + """Helper: build a default-flavoured policy and apply overrides.""" + base = { + "fail": ["AGPL-*", "SSPL-*"], + "warn": ["BUSL-*", "EPL-*", "LGPL-3.0*"], + "synonyms": {}, + "unlicensed_action": "ignore", + "unknown_action": "ignore", + } + base.update(overrides) + return base + + +def test_get_value_returns_first_non_none(): + obj = {"a": None, "B": "value", "c": "other"} + assert get_value(obj, "a", "B", "c") == "value" + assert get_value({}, "x", default="d") == "d" + assert get_value(None, "x", default="d") == "d" + + +def test_validate_action_clamps_to_known_set(): + assert validate_action("FAIL", "ignore") == "fail" + assert validate_action(" Warn ", "ignore") == "warn" + assert validate_action("bogus", "ignore") == "ignore" + + +def test_looks_like_spdx_id(): + assert looks_like_spdx_id("MIT") + assert looks_like_spdx_id("Apache-2.0") + assert looks_like_spdx_id("GPL-3.0+") + assert not looks_like_spdx_id("") + assert not looks_like_spdx_id("MIT License") + assert not looks_like_spdx_id("a" * 80) + +def test_build_policy_returns_none_when_remote_config_missing(): + assert build_policy_from_remote_config(None) is None + assert build_policy_from_remote_config({}) is None + assert build_policy_from_remote_config({"GRANT": {}}) is None + + +def test_build_policy_normalises_lists_and_actions(): + raw = { + "GRANT": { + "LICENSE_POLICY": { + "fail": ["AGPL-*"], + "warn": ["BUSL-*"], + "synonyms": {"BSD": "BSD-3-Clause"}, + "unlicensed_action": "WARN", + "unknown_action": "Bogus", + } + } + } + policy = build_policy_from_remote_config(raw) + assert policy["fail"] == ["AGPL-*"] + assert policy["warn"] == ["BUSL-*"] + assert policy["synonyms"] == {"BSD": "BSD-3-Clause"} + assert policy["unlicensed_action"] == "warn" + assert policy["unknown_action"] == "ignore" + + +def test_build_policy_handles_non_list_fail_warn(): + raw = {"GRANT": {"LICENSE_POLICY": {"fail": "not-a-list", "warn": None}}} + policy = build_policy_from_remote_config(raw) + assert policy["fail"] == [] + assert policy["warn"] == [] + +def test_classify_package_compliant_spdx(): + result = classify_package([{"id": "MIT"}], _policy()) + assert result["policy_applied"] == "ok" + assert result["label"] == "MIT" + assert result["licenses"] == ["MIT"] + assert result["pattern_matched"] is None + assert result["severity"] == "info" + + +def test_classify_package_fail_pattern(): + result = classify_package([{"id": "AGPL-3.0"}], _policy()) + assert result["policy_applied"] == "fail" + assert result["pattern_matched"] == "AGPL-*" + assert "AGPL-*" in result["reason"] + assert result["severity"] == "critical" + + +def test_classify_package_warn_pattern(): + result = classify_package([{"id": "BUSL-1.1"}], _policy()) + assert result["policy_applied"] == "warn" + assert result["pattern_matched"] == "BUSL-*" + assert result["severity"] == "medium" + + +def test_classify_package_dual_license_ok_dominates(): + result = classify_package( + [{"id": "AGPL-3.0"}, {"id": "MIT"}], _policy() + ) + assert result["policy_applied"] == "ok" + assert sorted(result["licenses"]) == ["AGPL-3.0", "MIT"] + + +def test_classify_package_dual_warn_and_fail_picks_warn(): + result = classify_package( + [{"id": "AGPL-3.0"}, {"id": "BUSL-1.1"}], _policy() + ) + assert result["policy_applied"] == "warn" + + +def test_classify_package_synonyms_resolved(): + policy = _policy(synonyms={"BSD": "BSD-3-Clause"}) + result = classify_package([{"id": "BSD"}], policy) + assert result["policy_applied"] == "ok" + assert result["label"] == "BSD-3-Clause" + + +def test_classify_package_unlicensed_ignore_severity_info(): + result = classify_package([], _policy(unlicensed_action="ignore")) + assert result["policy_applied"] == "unlicensed" + assert result["label"] == "UNLICENSED" + assert result["severity"] == "info" + + +def test_classify_package_unlicensed_warn_severity_medium(): + result = classify_package([], _policy(unlicensed_action="warn")) + assert result["policy_applied"] == "unlicensed" + assert result["label"] == "UNLICENSED" + assert result["severity"] == "medium" + + +def test_classify_package_unknown_label_ignore_severity_info(): + result = classify_package( + [{"name": "Custom Proprietary License"}], + _policy(unknown_action="ignore"), + ) + assert result["policy_applied"] == "unknown" + assert result["severity"] == "info" + + +def test_classify_package_unknown_label_fail_severity_critical(): + result = classify_package( + [{"name": "Custom Proprietary License"}], + _policy(unknown_action="fail"), + ) + assert result["policy_applied"] == "unknown" + assert result["severity"] == "critical" + + +def test_classify_package_with_none_policy(): + result = classify_package([{"id": "MIT"}], None) + assert result["policy_applied"] == "unknown" + assert result["label"] == "UNKNOWN" + + +def test_action_to_severity_mapping_exposed(): + assert ACTION_TO_SEVERITY["fail"] == "critical" + assert ACTION_TO_SEVERITY["warn"] == "medium" + assert ACTION_TO_SEVERITY["ok"] == "info" + assert SEVERITY_BY_ACTION is ACTION_TO_SEVERITY # alias diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/__init__.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/__init__.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/grant_tool/__init__.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/grant_tool/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/grant_tool/test_grant_manager_scan.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/grant_tool/test_grant_manager_scan.py new file mode 100644 index 000000000..f61380b16 --- /dev/null +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/grant_tool/test_grant_manager_scan.py @@ -0,0 +1,132 @@ +from unittest.mock import patch, MagicMock + +from devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.driven_adapters.grant_tool.grant_manager_scan import ( + GrantScan, +) + + +def _base_args(**overrides): + args = { + "remote_config_repo": "/tmp/does-not-exist", + "remote_config_branch": "", + } + args.update(overrides) + return args + + +def test_map_platform_linux_arm64(): + scan = GrantScan() + assert scan._map_platform("Linux", "aarch64") == ("linux", "arm64") + assert scan._map_platform("Darwin", "arm64") == ("darwin", "arm64") + assert scan._map_platform("Darwin", "x86_64") == ("darwin", "amd64") + + +def test_map_platform_unsupported(): + scan = GrantScan() + assert scan._map_platform("Plan9", "riscv") == (None, None) + + +@patch( + "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.driven_adapters.grant_tool.grant_manager_scan.platform" +) +@patch( + "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.driven_adapters.grant_tool.grant_manager_scan.shutil.which" +) +def test_resolve_binary_windows_rejected(mock_which, mock_platform): + mock_which.return_value = None + mock_platform.system.return_value = "Windows" + mock_platform.machine.return_value = "AMD64" + scan = GrantScan() + assert scan._resolve_binary("0.2.5") is None + + +@patch( + "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.driven_adapters.grant_tool.grant_manager_scan.shutil.which" +) +def test_resolve_binary_from_path(mock_which): + mock_which.return_value = "/usr/local/bin/grant" + scan = GrantScan() + assert scan._resolve_binary("0.2.5") == "/usr/local/bin/grant" + + +@patch("os.path.exists") +def test_resolve_scan_target_priority(mock_exists): + mock_exists.return_value = True + scan = GrantScan() + assert scan._resolve_scan_target("sbom.json", "img:tag", "/tmp/x") == "sbom.json" + assert scan._resolve_scan_target(None, "img:tag", "/tmp/x") == "img:tag" + assert scan._resolve_scan_target(None, None, "/tmp/x") == "/tmp/x" + + +@patch("os.path.exists") +def test_resolve_scan_target_none(mock_exists): + mock_exists.return_value = False + scan = GrantScan() + assert scan._resolve_scan_target(None, None, "/missing") is None + + +@patch( + "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.driven_adapters.grant_tool.grant_manager_scan.os.path.getsize", + return_value=42, +) +@patch( + "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.driven_adapters.grant_tool.grant_manager_scan.os.path.exists", + return_value=True, +) +@patch( + "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.driven_adapters.grant_tool.grant_manager_scan.subprocess.run" +) +def test_run_grant_writes_output(mock_run, mock_exists, mock_getsize): + mock_run.return_value = MagicMock(stdout="", stderr="", returncode=0) + scan = GrantScan() + result = scan._run_grant( + "grant", "img:tag", "svc", "json", True, False + ) + assert result == "svc_grant.json" + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + assert "list" in cmd and "img:tag" in cmd and "--quiet" in cmd + assert "-f" in cmd and "svc_grant.json" in cmd + # We must NOT pass policy / non-spdx / osi-approved any more. + assert "-c" not in cmd + assert "--non-spdx" not in cmd + assert "--osi-approved" not in cmd + + +@patch( + "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.driven_adapters.grant_tool.grant_manager_scan.os.path.exists", + return_value=False, +) +@patch( + "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.driven_adapters.grant_tool.grant_manager_scan.subprocess.run" +) +def test_run_grant_no_output(mock_run, mock_exists): + mock_run.return_value = MagicMock(stdout="", stderr="boom", returncode=2) + scan = GrantScan() + assert scan._run_grant("grant", "/x", "svc", "json", False, False) is None + + +@patch.object(GrantScan, "_run_grant", return_value="report.json") +@patch.object(GrantScan, "_resolve_binary", return_value="grant") +def test_run_tool_license_sca_happy_path(mock_bin, mock_run): + scan = GrantScan() + out = scan.run_tool_license_sca( + {"GRANT": {"GRANT_VERSION": "0.2.5"}}, + _base_args(image_to_scan="img:tag"), + {}, + "svc", + to_scan="/tmp/x", + sbom_path=None, + image_to_scan="img:tag", + secret_tool=None, + ) + assert out == "report.json" + + +@patch.object(GrantScan, "_resolve_binary", return_value=None) +def test_run_tool_license_sca_no_binary(mock_bin): + scan = GrantScan() + out = scan.run_tool_license_sca( + {}, _base_args(), {}, "svc", "/tmp", None, None, None + ) + assert out is None diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/entry_points/__init__.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/entry_points/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/entry_points/test_entry_point_tool.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/entry_points/test_entry_point_tool.py new file mode 100644 index 000000000..f1f43733d --- /dev/null +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/entry_points/test_entry_point_tool.py @@ -0,0 +1,216 @@ +from unittest.mock import MagicMock, patch + +from devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.entry_points.entry_point_tool import ( + init_engine_license, +) + + +def _make_devops_gateway(remote_cfg, pipeline_name="svc"): + gw = MagicMock() + gw.get_remote_config.return_value = remote_cfg + gw.get_variable.side_effect = lambda name: { + "pipeline_name": pipeline_name, + "branch_tag": "main", + }.get(name, "") + return gw + + +def _config_tool(): + return { + "ENGINE_LICENSE": {"TOOL": "GRANT"}, + "SBOM_MANAGER": {"ENABLED": True}, + } + + +def _remote_config(): + return { + "GRANT": { + "LICENSE_POLICY": { + "fail": [], + "warn": [], + "synonyms": {}, + "unlicensed_action": "ignore", + "unknown_action": "ignore", + } + } + } + +@patch( + "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.entry_points.entry_point_tool.BuildLicenseReport" +) +@patch( + "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.entry_points.entry_point_tool.os.path.exists" +) +def test_init_engine_license_happy_path(mock_exists, mock_builder): + mock_exists.return_value = True + mock_builder.return_value.process.return_value = "/abs/svc_LICENSE.json" + + devops_gw = _make_devops_gateway(_remote_config()) + remote_gw = _make_devops_gateway(_remote_config()) + + tool_run = MagicMock() + tool_run.run_tool_license_sca.return_value = "svc_grant.json" + + tool_sbom = MagicMock() + tool_sbom.get_components.return_value = ["c1", "c2"] + + license_path, sbom_components = init_engine_license( + tool_run, + devops_gw, + remote_gw, + {"remote_config_repo": "r", "remote_config_branch": ""}, + None, + _config_tool(), + tool_sbom, + ) + + assert license_path == "/abs/svc_LICENSE.json" + assert sbom_components == ["c1", "c2"] + + tool_sbom.get_components.assert_called_once() + tool_run.run_tool_license_sca.assert_called_once() + args, _ = tool_run.run_tool_license_sca.call_args + assert args[5] == "svc_SBOM.json" # sbom_path + assert args[7] is None # image_to_scan + + mock_builder.return_value.process.assert_called_once_with( + "svc_grant.json", _remote_config(), "svc" + ) + + +@patch( + "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.entry_points.entry_point_tool.os.path.exists", + return_value=False, +) +def test_init_engine_license_returns_none_when_to_scan_missing(mock_exists): + devops_gw = _make_devops_gateway(_remote_config()) + remote_gw = _make_devops_gateway(_remote_config()) + tool_run = MagicMock() + tool_sbom = MagicMock() + + license_path, sbom_components = init_engine_license( + tool_run, + devops_gw, + remote_gw, + { + "remote_config_repo": "r", + "remote_config_branch": "", + "folder_path": "/missing/path", + }, + None, + _config_tool(), + tool_sbom, + ) + + assert license_path is None + assert sbom_components is None + tool_sbom.get_components.assert_not_called() + + +@patch( + "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.entry_points.entry_point_tool.os.path.exists" +) +def test_init_engine_license_returns_none_when_sbom_missing_after_generation( + mock_exists, +): + mock_exists.side_effect = [True, False] + devops_gw = _make_devops_gateway(_remote_config()) + remote_gw = _make_devops_gateway(_remote_config()) + tool_run = MagicMock() + tool_sbom = MagicMock() + tool_sbom.get_components.return_value = ["c"] + + license_path, sbom_components = init_engine_license( + tool_run, + devops_gw, + remote_gw, + {"remote_config_repo": "r", "remote_config_branch": ""}, + None, + _config_tool(), + tool_sbom, + ) + + assert license_path is None + assert sbom_components == ["c"] + tool_run.run_tool_license_sca.assert_not_called() + + +@patch( + "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.entry_points.entry_point_tool.os.path.exists", + return_value=True, +) +def test_init_engine_license_returns_none_when_tool_sbom_missing(mock_exists): + devops_gw = _make_devops_gateway(_remote_config()) + remote_gw = _make_devops_gateway(_remote_config()) + tool_run = MagicMock() + + license_path, sbom_components = init_engine_license( + tool_run, + devops_gw, + remote_gw, + {"remote_config_repo": "r", "remote_config_branch": ""}, + None, + _config_tool(), + None, + ) + + assert license_path is None + assert sbom_components is None + tool_run.run_tool_license_sca.assert_not_called() + + +@patch( + "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.entry_points.entry_point_tool.os.path.exists", + return_value=True, +) +def test_init_engine_license_returns_none_when_grant_fails(mock_exists): + devops_gw = _make_devops_gateway(_remote_config()) + remote_gw = _make_devops_gateway(_remote_config()) + tool_run = MagicMock() + tool_run.run_tool_license_sca.return_value = None + tool_sbom = MagicMock() + tool_sbom.get_components.return_value = ["c"] + + license_path, sbom_components = init_engine_license( + tool_run, + devops_gw, + remote_gw, + {"remote_config_repo": "r", "remote_config_branch": ""}, + None, + _config_tool(), + tool_sbom, + ) + + assert license_path is None + assert sbom_components == ["c"] + + +@patch( + "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.entry_points.entry_point_tool.BuildLicenseReport" +) +@patch( + "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.entry_points.entry_point_tool.os.path.exists", + return_value=True, +) +def test_init_engine_license_returns_none_when_build_report_fails( + mock_exists, mock_builder +): + mock_builder.return_value.process.return_value = None + devops_gw = _make_devops_gateway(_remote_config()) + remote_gw = _make_devops_gateway(_remote_config()) + tool_run = MagicMock() + tool_run.run_tool_license_sca.return_value = "grant.json" + tool_sbom = MagicMock() + tool_sbom.get_components.return_value = ["c"] + + license_path, sbom_components = init_engine_license( + tool_run, + devops_gw, + remote_gw, + {"remote_config_repo": "r", "remote_config_branch": ""}, + None, + _config_tool(), + tool_sbom, + ) + assert license_path is None + assert sbom_components == ["c"] From edf742c35d569da01085c7233ddc15dce887be25 Mon Sep 17 00:00:00 2001 From: yenner123 <> Date: Tue, 2 Jun 2026 11:45:18 -0500 Subject: [PATCH 2/8] feat: Implement license context extraction and related functionality --- .../src/domain/usecases/handle_scan.py | 12 ++- .../context_extraction_manager.py | 5 ++ .../test/domain/usecases/test_handle_scan.py | 5 +- .../test_context_extraction_manager.py | 81 ++++++++++++++++++- .../src/applications/runner_license_scan.py | 2 +- .../src/domain/model/context_license.py | 14 ++++ .../grant_tool/grant_manager_scan.py | 32 ++++++++ .../applications/test_runner_license_scan.py | 5 +- .../test/domain/model/__init__.py | 0 .../test/domain/model/test_context_license.py | 36 +++++++++ .../grant_tool/test_grant_manager_scan.py | 48 +++++++++++ 11 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/model/context_license.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/model/__init__.py create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/model/test_context_license.py diff --git a/tools/devsecops_engine_tools/engine_core/src/domain/usecases/handle_scan.py b/tools/devsecops_engine_tools/engine_core/src/domain/usecases/handle_scan.py index 67df44c57..047bd2ff5 100644 --- a/tools/devsecops_engine_tools/engine_core/src/domain/usecases/handle_scan.py +++ b/tools/devsecops_engine_tools/engine_core/src/domain/usecases/handle_scan.py @@ -226,7 +226,7 @@ def process(self, dict_args: any, config_tool: any): self.risk_score_gateway.get_risk_score(findings_list, config_tool, dict_args["module"]) return findings_list, input_core elif "engine_license" in dict_args["module"]: - findings_list, input_core, _sbom_components = runner_engine_license( + findings_list, input_core, _sbom_components, tool_gateway = runner_engine_license( dict_args, config_tool, secret_tool, @@ -234,6 +234,16 @@ def process(self, dict_args: any, config_tool: any): self.remote_config_source_gateway, self.sbom_tool_gateway, ) + + self._handle_context_extraction( + dict_args, + "engine_license", + input_core.path_file_results, + config_tool["ENGINE_LICENSE"], + tool_gateway, + config_tool + ) + return findings_list, input_core def _use_vulnerability_management( diff --git a/tools/devsecops_engine_tools/engine_core/src/infrastructure/driven_adapters/context_extraction/context_extraction_manager.py b/tools/devsecops_engine_tools/engine_core/src/infrastructure/driven_adapters/context_extraction/context_extraction_manager.py index 19661c8c6..199c39619 100644 --- a/tools/devsecops_engine_tools/engine_core/src/infrastructure/driven_adapters/context_extraction/context_extraction_manager.py +++ b/tools/devsecops_engine_tools/engine_core/src/infrastructure/driven_adapters/context_extraction/context_extraction_manager.py @@ -25,6 +25,7 @@ def __init__(self, risk_score_gateway: RiskScoreGateway): "engine_iac": "get_iac_context_from_results", "engine_container": "get_container_context_from_results", "engine_dependencies": "get_dependencies_context_from_results", + "engine_license": "get_license_context_from_results", } # Mapping of module names to their context output keys @@ -32,6 +33,7 @@ def __init__(self, risk_score_gateway: RiskScoreGateway): "engine_iac": "iac_context", "engine_container": "container_context", "engine_dependencies": "dependencies_context", + "engine_license": "license_context", } def register_tool_gateway(self, module_name: str, tool_gateway: any): @@ -116,6 +118,9 @@ def _calculate_priorities_for_context( elif hasattr(context_item, 'id'): # For IaC: id is a string finding_id = context_item.id + elif hasattr(context_item, 'name'): + # For license: name is the identifier + finding_id = context_item.name else: finding_id = "unknown" diff --git a/tools/devsecops_engine_tools/engine_core/test/domain/usecases/test_handle_scan.py b/tools/devsecops_engine_tools/engine_core/test/domain/usecases/test_handle_scan.py index eabd34a93..6c8ce4c35 100644 --- a/tools/devsecops_engine_tools/engine_core/test/domain/usecases/test_handle_scan.py +++ b/tools/devsecops_engine_tools/engine_core/test/domain/usecases/test_handle_scan.py @@ -1014,10 +1014,12 @@ def test_process_with_engine_license_returns_runner_input_core( scope_service="svc", stage_pipeline="Build", ) + mock_tool_gateway = MagicMock() mock_runner_engine_license.return_value = ( [], runner_input_core, ["sbom_component"], + mock_tool_gateway, ) findings, input_core = self.handle_scan.process(dict_args, config_tool) @@ -1066,7 +1068,8 @@ def test_process_with_engine_license_propagates_runner_failure_input_core( scope_service="svc", stage_pipeline="Build", ) - mock_runner_engine_license.return_value = ([], failure_input_core, None) + mock_tool_gateway = MagicMock() + mock_runner_engine_license.return_value = ([], failure_input_core, None, mock_tool_gateway) findings, input_core = self.handle_scan.process(dict_args, config_tool) self.assertEqual(findings, []) diff --git a/tools/devsecops_engine_tools/engine_core/test/infrastructure/driven_adapters/context_extraction/test_context_extraction_manager.py b/tools/devsecops_engine_tools/engine_core/test/infrastructure/driven_adapters/context_extraction/test_context_extraction_manager.py index 4dbdd8720..3bf5337b2 100644 --- a/tools/devsecops_engine_tools/engine_core/test/infrastructure/driven_adapters/context_extraction/test_context_extraction_manager.py +++ b/tools/devsecops_engine_tools/engine_core/test/infrastructure/driven_adapters/context_extraction/test_context_extraction_manager.py @@ -27,7 +27,7 @@ def test_initialization(self): self.assertIsNotNone(self.manager._tool_gateways) self.assertIsNotNone(self.manager._method_mapping) self.assertEqual(len(self.manager._tool_gateways), 0) - self.assertEqual(len(self.manager._method_mapping), 3) + self.assertEqual(len(self.manager._method_mapping), 4) self.assertIsNotNone(self.manager._risk_score_gateway) def test_register_tool_gateway_iac(self): @@ -303,5 +303,84 @@ def test_extract_context_with_gateway_missing_method(self): self.config_tool ) + def test_extract_context_license(self): + """Test extracting context for License module.""" + path_file_results = "/path/to/svc_LICENSE.json" + mock_license_gateway = Mock() + mock_license_gateway.get_license_context_from_results.return_value = [] + self.manager.register_tool_gateway("engine_license", mock_license_gateway) + + self.manager.extract_context( + "engine_license", + path_file_results, + self.remote_config, + self.config_tool + ) + + mock_license_gateway.get_license_context_from_results.assert_called_once_with( + path_file_results + ) + + def test_extract_context_license_prints_output(self): + """Test that license context extraction prints the correct JSON between markers.""" + from io import StringIO + import sys + from dataclasses import dataclass, field + from typing import List, Optional + + @dataclass + class FakeContextLicense: + name: str + version: str + licenses: List[str] + policy_applied: str + policy_reason: str + policy_pattern_matched: str + severity: str + priority: Optional[str] = field(default=None) + + context_items = [ + FakeContextLicense( + name="ngrx", + version="1.0.0", + licenses=["AGPL-3.0"], + policy_applied="fail", + policy_reason="Matched AGPL-*", + policy_pattern_matched="AGPL-*", + severity="critical", + ) + ] + + mock_license_gateway = Mock() + mock_license_gateway.get_license_context_from_results.return_value = context_items + self.manager.register_tool_gateway("engine_license", mock_license_gateway) + + # Mock risk_score_gateway to set priority + def set_priority(findings, config_tool, module): + from devsecops_engine_tools.engine_core.src.domain.model.finding import Priority + for f in findings: + f.priority = Priority(score=1.0, scale="very critical") + + self.mock_risk_score_gateway.get_risk_score.side_effect = set_priority + + captured = StringIO() + sys.stdout = captured + try: + self.manager.extract_context( + "engine_license", + "/path/to/svc_LICENSE.json", + self.remote_config, + self.config_tool + ) + finally: + sys.stdout = sys.__stdout__ + + output = captured.getvalue() + assert "===== BEGIN CONTEXT OUTPUT =====" in output + assert "===== END CONTEXT OUTPUT =====" in output + assert '"license_context"' in output + assert '"ngrx"' in output + assert '"very critical"' in output + if __name__ == '__main__': unittest.main() diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/applications/runner_license_scan.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/applications/runner_license_scan.py index a3f5592fe..35594f41b 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/src/applications/runner_license_scan.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/src/applications/runner_license_scan.py @@ -62,7 +62,7 @@ def runner_engine_license( stage_pipeline="Build", ) - return [], input_core, sbom_components + return [], input_core, sbom_components, tool_run except Exception as e: raise Exception(f"Error SCAN engine license : {str(e)}") diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/model/context_license.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/model/context_license.py new file mode 100644 index 000000000..756684115 --- /dev/null +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/model/context_license.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass, field +from typing import List, Optional + + +@dataclass +class ContextLicense: + name: str + version: str + licenses: List[str] + policy_applied: str + policy_reason: str + policy_pattern_matched: str + severity: str + priority: Optional[str] = field(default=None) diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/grant_tool/grant_manager_scan.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/grant_tool/grant_manager_scan.py index 8205d0b31..833cbb366 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/grant_tool/grant_manager_scan.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/grant_tool/grant_manager_scan.py @@ -1,11 +1,16 @@ +import json import os import platform import shutil import subprocess import tarfile +from typing import List import requests +from devsecops_engine_tools.engine_sca.engine_license.src.domain.model.context_license import ( + ContextLicense, +) from devsecops_engine_tools.engine_sca.engine_license.src.domain.model.gateways.tool_gateway import ( ToolGateway, ) @@ -25,6 +30,14 @@ class GrantScan(ToolGateway): TOOL = "GRANT" + _POLICY_TO_SEVERITY = { + "fail": "critical", + "warn": "medium", + "unlicensed": "low", + "unknown": "low", + "ok": "low", + } + def __init__(self): self.download_tool_called = False @@ -210,3 +223,22 @@ def _run_grant( except Exception as e: logger.error(f"Error executing Grant: {e}") return None + + def get_license_context_from_results(self, path_file_results) -> List[ContextLicense]: + with open(path_file_results, "r") as fh: + data = json.load(fh) + + context_list = [] + for dep in data.get("dependencies", []): + context_list.append( + ContextLicense( + name=dep.get("name", "unknown"), + version=dep.get("version", ""), + licenses=dep.get("licenses", []), + policy_applied=dep.get("policy_applied", "unknown"), + policy_reason=dep.get("policy_reason", ""), + policy_pattern_matched=dep.get("policy_pattern_matched", ""), + severity=self._POLICY_TO_SEVERITY.get(dep.get("policy_applied", ""), "low"), + ) + ) + return context_list diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/applications/test_runner_license_scan.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/applications/test_runner_license_scan.py index 61fe9cdd4..2da18f578 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/test/applications/test_runner_license_scan.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/test/applications/test_runner_license_scan.py @@ -27,7 +27,7 @@ def test_runner_engine_license_grant_returns_findings_input_core_and_components( config_tool = {"ENGINE_LICENSE": {"TOOL": "GRANT"}} devops_gw = _make_devops_gateway() - findings, input_core, sbom_components = runner_engine_license( + findings, input_core, sbom_components, tool_gw = runner_engine_license( {"remote_config_repo": "r", "remote_config_branch": ""}, config_tool, None, @@ -40,6 +40,7 @@ def test_runner_engine_license_grant_returns_findings_input_core_and_components( assert findings == [] assert sbom_components == ["c1"] + assert tool_gw is not None assert input_core.path_file_results == "/abs/svc_LICENSE.json" assert input_core.totalized_exclusions == [] @@ -64,7 +65,7 @@ def test_runner_engine_license_propagates_none_path(): config_tool = {"ENGINE_LICENSE": {"TOOL": "GRANT"}} devops_gw = _make_devops_gateway() - findings, input_core, sbom_components = runner_engine_license( + findings, input_core, sbom_components, tool_gw = runner_engine_license( {"remote_config_repo": "r", "remote_config_branch": ""}, config_tool, None, diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/model/__init__.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/model/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/model/test_context_license.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/model/test_context_license.py new file mode 100644 index 000000000..ee87441ff --- /dev/null +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/model/test_context_license.py @@ -0,0 +1,36 @@ +from dataclasses import asdict + +from devsecops_engine_tools.engine_sca.engine_license.src.domain.model.context_license import ( + ContextLicense, +) + + +def test_context_license_instantiation_and_asdict(): + ctx = ContextLicense( + name="lodash", + version="4.17.21", + licenses=["MIT"], + policy_applied="ok", + policy_reason="Allowed by default", + policy_pattern_matched="", + severity="low", + ) + d = asdict(ctx) + assert d["name"] == "lodash" + assert d["licenses"] == ["MIT"] + assert d["severity"] == "low" + assert d["priority"] is None + + +def test_context_license_with_priority(): + ctx = ContextLicense( + name="ngrx", + version="1.0.0", + licenses=["AGPL-3.0"], + policy_applied="fail", + policy_reason="Matched AGPL-*", + policy_pattern_matched="AGPL-*", + severity="critical", + priority="very critical", + ) + assert ctx.priority == "very critical" diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/grant_tool/test_grant_manager_scan.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/grant_tool/test_grant_manager_scan.py index f61380b16..0023333b9 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/grant_tool/test_grant_manager_scan.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/grant_tool/test_grant_manager_scan.py @@ -130,3 +130,51 @@ def test_run_tool_license_sca_no_binary(mock_bin): {}, _base_args(), {}, "svc", "/tmp", None, None, None ) assert out is None + + +def test_get_license_context_from_results_success(tmp_path): + import json + + license_data = { + "metadata": {"pipeline_name": "svc"}, + "dependencies": [ + { + "name": "lodash", + "version": "4.17.21", + "licenses": ["MIT"], + "policy_applied": "ok", + "policy_reason": "Allowed", + "policy_pattern_matched": "", + }, + { + "name": "ngrx", + "version": "1.0.0", + "licenses": ["AGPL-3.0"], + "policy_applied": "fail", + "policy_reason": "Matched AGPL-*", + "policy_pattern_matched": "AGPL-*", + }, + { + "name": "biz-lib", + "version": "2.0.0", + "licenses": ["BUSL-1.1"], + "policy_applied": "warn", + "policy_reason": "Matched BUSL-*", + "policy_pattern_matched": "BUSL-*", + }, + ], + } + path = tmp_path / "svc_LICENSE.json" + path.write_text(json.dumps(license_data)) + + scan = GrantScan() + result = scan.get_license_context_from_results(str(path)) + + assert len(result) == 3 + assert result[0].name == "lodash" + assert result[0].severity == "low" + assert result[1].name == "ngrx" + assert result[1].severity == "critical" + assert result[2].name == "biz-lib" + assert result[2].severity == "medium" + assert result[0].priority is None From 207df34cc38caae79aea0eefcce1daa59782e2f9 Mon Sep 17 00:00:00 2001 From: yenner123 <> Date: Tue, 2 Jun 2026 16:00:52 -0500 Subject: [PATCH 3/8] feat: Update license scan to exclude 'ok' and 'unlicensed' policy --- .../driven_adapters/grant_tool/grant_manager_scan.py | 9 +++++---- .../grant_tool/test_grant_manager_scan.py | 12 +++++------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/grant_tool/grant_manager_scan.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/grant_tool/grant_manager_scan.py index 833cbb366..0e6449596 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/grant_tool/grant_manager_scan.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/grant_tool/grant_manager_scan.py @@ -33,9 +33,7 @@ class GrantScan(ToolGateway): _POLICY_TO_SEVERITY = { "fail": "critical", "warn": "medium", - "unlicensed": "low", "unknown": "low", - "ok": "low", } def __init__(self): @@ -230,15 +228,18 @@ def get_license_context_from_results(self, path_file_results) -> List[ContextLic context_list = [] for dep in data.get("dependencies", []): + policy = dep.get("policy_applied", "unknown") + if policy == "ok" or policy == "unlicensed": + continue context_list.append( ContextLicense( name=dep.get("name", "unknown"), version=dep.get("version", ""), licenses=dep.get("licenses", []), - policy_applied=dep.get("policy_applied", "unknown"), + policy_applied=policy, policy_reason=dep.get("policy_reason", ""), policy_pattern_matched=dep.get("policy_pattern_matched", ""), - severity=self._POLICY_TO_SEVERITY.get(dep.get("policy_applied", ""), "low"), + severity=self._POLICY_TO_SEVERITY.get(policy, "low"), ) ) return context_list diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/grant_tool/test_grant_manager_scan.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/grant_tool/test_grant_manager_scan.py index 0023333b9..a98261963 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/grant_tool/test_grant_manager_scan.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/grant_tool/test_grant_manager_scan.py @@ -170,11 +170,9 @@ def test_get_license_context_from_results_success(tmp_path): scan = GrantScan() result = scan.get_license_context_from_results(str(path)) - assert len(result) == 3 - assert result[0].name == "lodash" - assert result[0].severity == "low" - assert result[1].name == "ngrx" - assert result[1].severity == "critical" - assert result[2].name == "biz-lib" - assert result[2].severity == "medium" + assert len(result) == 2 + assert result[0].name == "ngrx" + assert result[0].severity == "critical" + assert result[1].name == "biz-lib" + assert result[1].severity == "medium" assert result[0].priority is None From ab504e46167680157041a3bfebc6e37e9b4e5426 Mon Sep 17 00:00:00 2001 From: yenner123 <> Date: Fri, 12 Jun 2026 11:40:06 -0500 Subject: [PATCH 4/8] feat: Update license classification logic to prioritize highest risk and adjust related tests --- .../src/domain/usecases/license_policy.py | 26 +++++-------- .../grant_tool/grant_manager_scan.py | 9 ++--- .../usecases/test_build_license_report.py | 4 +- .../domain/usecases/test_license_policy.py | 8 ++-- .../grant_tool/test_grant_manager_scan.py | 37 ++++++++++++++++--- 5 files changed, 51 insertions(+), 33 deletions(-) diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/license_policy.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/license_policy.py index b70131690..29fbd6792 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/license_policy.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/license_policy.py @@ -153,11 +153,11 @@ def _extract_license_id(license_entry): def classify_package(licenses, policy): """Classify all licenses on a single package against the policy. - Dual-license semantics: if ANY license on the package is fully compliant - (bucket == "ok"), the package is compliant โ€” the consumer can legally pick - the permissive option. The single most permissive non-ok candidate is - reported otherwise (warn ranks above fail and unknown because it is the - lightest restriction). + Highest-risk-wins semantics: if ANY license on the package matches a + ``fail`` pattern, the package is classified as ``fail``. Otherwise, if + any matches ``warn``, the package is ``warn``. Only when all licenses are + compliant (or a single ok is found with no risky siblings) the package is + ``ok``. Args: licenses: list of license entries (dicts) from the SBOM/Grant report. @@ -205,18 +205,8 @@ def classify_package(licenses, policy): normalized_labels.append(normalized) classified.append((normalized, _classify_label(normalized, policy))) - for label, result in classified: - if result["bucket"] == "ok": - return { - "policy_applied": "ok", - "label": label, - "licenses": normalized_labels, - "reason": result["reason"], - "pattern_matched": result["pattern_matched"], - "severity": SEVERITY_BY_ACTION["ok"], - } - - rank = {"warn": 0, "fail": 1, "unknown": 2} + # Highest risk wins: fail > warn > unknown > ok + rank = {"fail": 0, "warn": 1, "unknown": 2, "ok": 3} def _key(item): return rank.get(item[1]["bucket"], 99) @@ -227,6 +217,8 @@ def _key(item): if bucket == "unknown": severity = SEVERITY_BY_ACTION.get(policy["unknown_action"], "info") + elif bucket == "ok": + severity = SEVERITY_BY_ACTION["ok"] else: severity = SEVERITY_BY_ACTION.get(bucket, "info") diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/grant_tool/grant_manager_scan.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/grant_tool/grant_manager_scan.py index 0e6449596..a4aba48a9 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/grant_tool/grant_manager_scan.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/grant_tool/grant_manager_scan.py @@ -29,13 +29,12 @@ class GrantScan(ToolGateway): """ TOOL = "GRANT" - _POLICY_TO_SEVERITY = { "fail": "critical", "warn": "medium", - "unknown": "low", } + def __init__(self): self.download_tool_called = False @@ -229,7 +228,7 @@ def get_license_context_from_results(self, path_file_results) -> List[ContextLic context_list = [] for dep in data.get("dependencies", []): policy = dep.get("policy_applied", "unknown") - if policy == "ok" or policy == "unlicensed": + if policy not in self._POLICY_TO_SEVERITY: continue context_list.append( ContextLicense( @@ -238,8 +237,8 @@ def get_license_context_from_results(self, path_file_results) -> List[ContextLic licenses=dep.get("licenses", []), policy_applied=policy, policy_reason=dep.get("policy_reason", ""), - policy_pattern_matched=dep.get("policy_pattern_matched", ""), - severity=self._POLICY_TO_SEVERITY.get(policy, "low"), + policy_pattern_matched=dep.get("policy_pattern_matched", "") or "", + severity=self._POLICY_TO_SEVERITY[policy], ) ) return context_list diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_build_license_report.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_build_license_report.py index 37f8fac60..135839b15 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_build_license_report.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_build_license_report.py @@ -98,7 +98,7 @@ def test_process_writes_license_json_with_mixed_classifications(tmp_path): assert no_lic["licenses"] == [] -def test_process_dual_license_ok_dominates(tmp_path): +def test_process_dual_license_highest_risk_wins(tmp_path): packages = [ { "name": "dual-pkg", @@ -115,7 +115,7 @@ def test_process_dual_license_ok_dominates(tmp_path): with open(out) as fh: report = json.load(fh) dep = report["dependencies"][0] - assert dep["policy_applied"] == "ok" + assert dep["policy_applied"] == "fail" assert sorted(dep["licenses"]) == ["AGPL-3.0", "MIT"] diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_license_policy.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_license_policy.py index 316fe4df1..9767e9f6c 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_license_policy.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_license_policy.py @@ -98,19 +98,19 @@ def test_classify_package_warn_pattern(): assert result["severity"] == "medium" -def test_classify_package_dual_license_ok_dominates(): +def test_classify_package_dual_license_highest_risk_wins(): result = classify_package( [{"id": "AGPL-3.0"}, {"id": "MIT"}], _policy() ) - assert result["policy_applied"] == "ok" + assert result["policy_applied"] == "fail" assert sorted(result["licenses"]) == ["AGPL-3.0", "MIT"] -def test_classify_package_dual_warn_and_fail_picks_warn(): +def test_classify_package_dual_warn_and_fail_picks_fail(): result = classify_package( [{"id": "AGPL-3.0"}, {"id": "BUSL-1.1"}], _policy() ) - assert result["policy_applied"] == "warn" + assert result["policy_applied"] == "fail" def test_classify_package_synonyms_resolved(): diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/grant_tool/test_grant_manager_scan.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/grant_tool/test_grant_manager_scan.py index a98261963..1341afcc1 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/grant_tool/test_grant_manager_scan.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/grant_tool/test_grant_manager_scan.py @@ -143,15 +143,15 @@ def test_get_license_context_from_results_success(tmp_path): "version": "4.17.21", "licenses": ["MIT"], "policy_applied": "ok", - "policy_reason": "Allowed", - "policy_pattern_matched": "", + "policy_reason": "compliant SPDX license", + "policy_pattern_matched": None, }, { "name": "ngrx", "version": "1.0.0", "licenses": ["AGPL-3.0"], "policy_applied": "fail", - "policy_reason": "Matched AGPL-*", + "policy_reason": "matches FAIL pattern 'AGPL-*'", "policy_pattern_matched": "AGPL-*", }, { @@ -159,9 +159,33 @@ def test_get_license_context_from_results_success(tmp_path): "version": "2.0.0", "licenses": ["BUSL-1.1"], "policy_applied": "warn", - "policy_reason": "Matched BUSL-*", + "policy_reason": "matches WARN pattern 'BUSL-*'", "policy_pattern_matched": "BUSL-*", }, + { + "name": "jakarta.servlet-api", + "version": "6.1.0", + "licenses": ["EPL-2.0", "GPL-2.0-with-classpath-exception"], + "policy_applied": "warn", + "policy_reason": "matches WARN pattern 'EPL-*'", + "policy_pattern_matched": "EPL-*", + }, + { + "name": "no-lic", + "version": "0.1.0", + "licenses": [], + "policy_applied": "unlicensed", + "policy_reason": "no license detected", + "policy_pattern_matched": None, + }, + { + "name": "weird-pkg", + "version": "0.0.1", + "licenses": ["Custom License"], + "policy_applied": "unknown", + "policy_reason": "non-SPDX license label", + "policy_pattern_matched": None, + }, ], } path = tmp_path / "svc_LICENSE.json" @@ -170,9 +194,12 @@ def test_get_license_context_from_results_success(tmp_path): scan = GrantScan() result = scan.get_license_context_from_results(str(path)) - assert len(result) == 2 + # Only fail and warn appear in context + assert len(result) == 3 assert result[0].name == "ngrx" assert result[0].severity == "critical" assert result[1].name == "biz-lib" assert result[1].severity == "medium" + assert result[2].name == "jakarta.servlet-api" + assert result[2].severity == "medium" assert result[0].priority is None From d69e75c0fa6e75edbdecc8c1377f6a15b0e53c23 Mon Sep 17 00:00:00 2001 From: yenner123 <> Date: Fri, 12 Jun 2026 14:27:55 -0500 Subject: [PATCH 5/8] feat: Enhance license scanning functionality with findings extraction and update related tests --- .../src/domain/usecases/handle_scan.py | 1 + .../test/domain/usecases/test_handle_scan.py | 2 +- .../src/applications/runner_license_scan.py | 58 ++++++++++++++++--- .../domain/usecases/build_license_report.py | 1 + .../applications/test_runner_license_scan.py | 50 +++++++++++++++- 5 files changed, 100 insertions(+), 12 deletions(-) diff --git a/tools/devsecops_engine_tools/engine_core/src/domain/usecases/handle_scan.py b/tools/devsecops_engine_tools/engine_core/src/domain/usecases/handle_scan.py index 047bd2ff5..ec5ed5217 100644 --- a/tools/devsecops_engine_tools/engine_core/src/domain/usecases/handle_scan.py +++ b/tools/devsecops_engine_tools/engine_core/src/domain/usecases/handle_scan.py @@ -244,6 +244,7 @@ def process(self, dict_args: any, config_tool: any): config_tool ) + self.risk_score_gateway.get_risk_score(findings_list, config_tool, dict_args["module"]) return findings_list, input_core def _use_vulnerability_management( diff --git a/tools/devsecops_engine_tools/engine_core/test/domain/usecases/test_handle_scan.py b/tools/devsecops_engine_tools/engine_core/test/domain/usecases/test_handle_scan.py index 6c8ce4c35..263b2a1ed 100644 --- a/tools/devsecops_engine_tools/engine_core/test/domain/usecases/test_handle_scan.py +++ b/tools/devsecops_engine_tools/engine_core/test/domain/usecases/test_handle_scan.py @@ -1028,7 +1028,7 @@ def test_process_with_engine_license_returns_runner_input_core( self.assertIs(input_core, runner_input_core) self.vulnerability_management.send_vulnerability_management.assert_not_called() - self.risk_score_gateway.get_risk_score.assert_not_called() + self.risk_score_gateway.get_risk_score.assert_called_once() mock_runner_engine_license.assert_called_once_with( dict_args, diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/applications/runner_license_scan.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/applications/runner_license_scan.py index 35594f41b..de85223cc 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/src/applications/runner_license_scan.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/src/applications/runner_license_scan.py @@ -1,3 +1,9 @@ +import json + +from devsecops_engine_tools.engine_core.src.domain.model.finding import ( + Finding, + Category, +) from devsecops_engine_tools.engine_core.src.domain.model.input_core import InputCore from devsecops_engine_tools.engine_core.src.domain.model.threshold import Threshold from devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.driven_adapters.grant_tool.grant_manager_scan import ( @@ -7,6 +13,42 @@ init_engine_license, ) +_POLICY_TO_SEVERITY = { + "fail": "critical", + "warn": "medium", +} + + +def _build_findings_from_license_json(license_json_path): + if not license_json_path: + return [] + with open(license_json_path, "r") as fh: + data = json.load(fh) + findings = [] + for dep in data.get("dependencies", []): + policy = dep.get("policy_applied", "") + if policy not in _POLICY_TO_SEVERITY: + continue + name = dep.get("name", "unknown") + version = dep.get("version", "") + license_label = dep.get("license_matched") or (dep.get("licenses", []) or ["UNKNOWN"])[0] + findings.append( + Finding( + id=f"{license_label}-{name}", + cvss="", + where=f"{name}:{version}", + description=f"License '{license_label}' for package '{name}' ({dep.get('policy_reason', '')}). ", + severity=_POLICY_TO_SEVERITY[policy], + identification_date="", + published_date_cve="", + module="engine_license", + category=Category.COMPLIANCE, + requirements="", + tool="GRANT", + ) + ) + return findings + def runner_engine_license( dict_args, @@ -18,16 +60,12 @@ def runner_engine_license( ): """Run the engine_license standalone flow. - Produces ``{pipeline_name}_LICENSE.json`` in the CWD and assembles a - minimal :class:`InputCore` so downstream consumers (BreakBuild, - MetricsManager) keep working without participating in vulnerability - management or risk scoring. + Produces ''{pipeline_name}_LICENSE.json'' in the CWD and assembles a + minimal :class:'InputCore' so downstream consumers (BreakBuild, + MetricsManager) keep working. Returns: - Tuple ``(findings_list, input_core, sbom_components)`` mirroring - the contract used by other engine runners. ``findings_list`` is - always empty because engine_license is a standalone artifact - generator; severity decisions live inside the LICENSE.json. + Tuple ''{findings_list, input_core, sbom_components, tool_run}''. """ try: tools_mapping = { @@ -52,6 +90,8 @@ def runner_engine_license( ) pipeline_name = devops_platform_gateway.get_variable("pipeline_name") + findings_list = _build_findings_from_license_json(license_json_path) + input_core = InputCore( totalized_exclusions=[], threshold_defined=Threshold({"VULNERABILITY": {}, "COMPLIANCE": {}}), @@ -62,7 +102,7 @@ def runner_engine_license( stage_pipeline="Build", ) - return [], input_core, sbom_components, tool_run + return findings_list, input_core, sbom_components, tool_run except Exception as e: raise Exception(f"Error SCAN engine license : {str(e)}") diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/build_license_report.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/build_license_report.py index 76dac1fe6..7f37471a4 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/build_license_report.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/build_license_report.py @@ -116,6 +116,7 @@ def _build_dependencies(self, data, policy): "policy_pattern_matched": classification[ "pattern_matched" ], + "license_matched": classification["label"], } ) return dependencies diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/applications/test_runner_license_scan.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/applications/test_runner_license_scan.py index 2da18f578..22825afbf 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/test/applications/test_runner_license_scan.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/test/applications/test_runner_license_scan.py @@ -21,8 +21,11 @@ def test_runner_engine_license_grant_returns_findings_input_core_and_components( "devsecops_engine_tools.engine_sca.engine_license.src.applications.runner_license_scan.init_engine_license" ) as mock_init, patch( "devsecops_engine_tools.engine_sca.engine_license.src.applications.runner_license_scan.GrantScan" - ): + ), patch( + "devsecops_engine_tools.engine_sca.engine_license.src.applications.runner_license_scan._build_findings_from_license_json" + ) as mock_findings: mock_init.return_value = ("/abs/svc_LICENSE.json", ["c1"]) + mock_findings.return_value = [] config_tool = {"ENGINE_LICENSE": {"TOOL": "GRANT"}} devops_gw = _make_devops_gateway() @@ -37,6 +40,7 @@ def test_runner_engine_license_grant_returns_findings_input_core_and_components( ) mock_init.assert_called_once() + mock_findings.assert_called_once_with("/abs/svc_LICENSE.json") assert findings == [] assert sbom_components == ["c1"] @@ -60,8 +64,11 @@ def test_runner_engine_license_propagates_none_path(): "devsecops_engine_tools.engine_sca.engine_license.src.applications.runner_license_scan.init_engine_license" ) as mock_init, patch( "devsecops_engine_tools.engine_sca.engine_license.src.applications.runner_license_scan.GrantScan" - ): + ), patch( + "devsecops_engine_tools.engine_sca.engine_license.src.applications.runner_license_scan._build_findings_from_license_json" + ) as mock_findings: mock_init.return_value = (None, None) + mock_findings.return_value = [] config_tool = {"ENGINE_LICENSE": {"TOOL": "GRANT"}} devops_gw = _make_devops_gateway() @@ -93,3 +100,42 @@ def test_runner_license_main_block(): run_name="__main__", alter_sys=True, ) + + +def test_build_findings_from_license_json(tmp_path): + import json + from devsecops_engine_tools.engine_sca.engine_license.src.applications.runner_license_scan import ( + _build_findings_from_license_json, + ) + from devsecops_engine_tools.engine_core.src.domain.model.finding import Category + + license_data = { + "metadata": {"pipeline_name": "svc"}, + "dependencies": [ + {"name": "lodash", "version": "4.17.21", "licenses": ["MIT"], "policy_applied": "ok", "policy_reason": "compliant", "policy_pattern_matched": None, "license_matched": "MIT"}, + {"name": "ngrx", "version": "1.0.0", "licenses": ["AGPL-3.0"], "policy_applied": "fail", "policy_reason": "matches FAIL pattern 'AGPL-*'", "policy_pattern_matched": "AGPL-*", "license_matched": "AGPL-3.0"}, + {"name": "biz-lib", "version": "2.0.0", "licenses": ["Apache-2.0", "EPL-2.0"], "policy_applied": "warn", "policy_reason": "matches WARN pattern 'EPL-*'", "policy_pattern_matched": "EPL-*", "license_matched": "EPL-2.0"}, + {"name": "no-lic", "version": "0.1.0", "licenses": [], "policy_applied": "unlicensed", "policy_reason": "no license detected", "policy_pattern_matched": None, "license_matched": "UNLICENSED"}, + ], + } + path = tmp_path / "svc_LICENSE.json" + path.write_text(json.dumps(license_data)) + + findings = _build_findings_from_license_json(str(path)) + + assert len(findings) == 2 + assert findings[0].id == "AGPL-3.0-ngrx" + assert findings[0].severity == "critical" + assert findings[0].where == "ngrx:1.0.0" + assert findings[0].category == Category.COMPLIANCE + assert findings[0].module == "engine_license" + # Dual-license: uses EPL-2.0 (the one that matched), not Apache-2.0 + assert findings[1].id == "EPL-2.0-biz-lib" + assert findings[1].severity == "medium" + + +def test_build_findings_from_license_json_none_path(): + from devsecops_engine_tools.engine_sca.engine_license.src.applications.runner_license_scan import ( + _build_findings_from_license_json, + ) + assert _build_findings_from_license_json(None) == [] From e91f179bba6d6bcfb49f17463238008774d63241 Mon Sep 17 00:00:00 2001 From: yenner123 <> Date: Fri, 12 Jun 2026 15:22:56 -0500 Subject: [PATCH 6/8] refactor: license scanning process and remove Grant tool integration --- .gitignore | 1 + .../docs/life_cycle/getting_started.md | 4 +- docs/Docusaurus/docs/modules/engine_core.md | 2 - .../docs/modules/engine_sca/engine_license.md | 147 ++++++----- .../engine_core/ConfigTool.json | 1 - .../engine_sca/engine_license/ConfigTool.json | 6 +- .../src/applications/runner_engine_core.py | 4 +- .../src/domain/usecases/handle_scan.py | 7 +- .../test/domain/usecases/test_handle_scan.py | 9 +- .../src/applications/runner_license_scan.py | 28 +- .../src/domain/model/gateways/tool_gateway.py | 18 -- .../domain/usecases/build_license_report.py | 119 +++------ .../src/domain/usecases/license_policy.py | 10 +- .../grant_tool/grant_manager_scan.py | 244 ------------------ .../{grant_tool => license_scan}/__init__.py | 0 .../license_scan/license_scan_manager.py | 37 +++ .../entry_points/entry_point_tool.py | 31 +-- .../applications/test_runner_license_scan.py | 23 +- .../usecases/test_build_license_report.py | 141 +++------- .../domain/usecases/test_license_policy.py | 6 +- .../grant_tool/test_grant_manager_scan.py | 205 --------------- .../{grant_tool => license_scan}/__init__.py | 0 .../license_scan/test_license_scan_manager.py | 30 +++ .../entry_points/test_entry_point_tool.py | 103 ++------ 24 files changed, 276 insertions(+), 900 deletions(-) delete mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/model/gateways/tool_gateway.py delete mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/grant_tool/grant_manager_scan.py rename tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/{grant_tool => license_scan}/__init__.py (100%) create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/license_scan/license_scan_manager.py delete mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/grant_tool/test_grant_manager_scan.py rename tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/{grant_tool => license_scan}/__init__.py (100%) create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/license_scan/test_license_scan_manager.py diff --git a/.gitignore b/.gitignore index ebbf2ace8..869b6f89a 100755 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,7 @@ kubescape-macos-latest *.tar.gz *.zip *_SBOM.json +*_LICENSE.json syft gitleaks_aux_report_*.json gitleaks_report.json diff --git a/docs/Docusaurus/docs/life_cycle/getting_started.md b/docs/Docusaurus/docs/life_cycle/getting_started.md index cc1a808a1..ee0ffe545 100644 --- a/docs/Docusaurus/docs/life_cycle/getting_started.md +++ b/docs/Docusaurus/docs/life_cycle/getting_started.md @@ -84,7 +84,7 @@ For more information about structure remote config visit [Structure Remote Confi ENGINE_LICENSE - GRANT + CDXGEN Free @@ -106,7 +106,7 @@ For more information about structure remote config visit [Structure Remote Confi ### Scan running - (CLI) - Flags ```bash -devsecops-engine-tools --platform_devops ["local","azure","github"] --remote_config_source ["local","azure","github"] --remote_config_repo ["remote_config_repo"] --remote_config_branch ["remote_config_branch"] --module ["engine_iac", "engine_dast", "engine_secret", "engine_dependencies", "engine_license", "engine_container", "engine_risk", "engine_code", "engine_function"] --tool ["nuclei", "bearer", "checkov", "kics", "kubescape", "trufflehog", "gitleaks", "prisma", "trivy", "xray", "dependency_check", "grant"] --folder_path ["Folder path scan engine_iac, engine_code, engine_dependencies and engine_secret"] --platform ["k8s","cloudformation","docker", "openapi", "terraform"] --use_secrets_manager ["false", "true"] --use_vulnerability_management ["false", "true"] --send_metrics ["false", "true"] --token_cmdb ["token_cmdb"] --token_vulnerability_management ["token_vulnerability_management"] --token_engine_container ["token_engine_container"] --token_engine_dependencies ["token_engine_dependencies"] --token_external_checks ["token_external_checks"] --xray_mode ["scan", "audit","build-scan"] --image_to_scan ["image_to_scan"] --dast_file_path ["dast_file_path"] --context ["false", "true"] --terraform_repo_root ["terraform_files_repo"] --docker_address ["docker addres to engine_container with Prisma tool"] +devsecops-engine-tools --platform_devops ["local","azure","github"] --remote_config_source ["local","azure","github"] --remote_config_repo ["remote_config_repo"] --remote_config_branch ["remote_config_branch"] --module ["engine_iac", "engine_dast", "engine_secret", "engine_dependencies", "engine_license", "engine_container", "engine_risk", "engine_code", "engine_function"] --tool ["nuclei", "bearer", "checkov", "kics", "kubescape", "trufflehog", "gitleaks", "prisma", "trivy", "xray", "dependency_check", "cdxgen"] --folder_path ["Folder path scan engine_iac, engine_code, engine_dependencies and engine_secret"] --platform ["k8s","cloudformation","docker", "openapi", "terraform"] --use_secrets_manager ["false", "true"] --use_vulnerability_management ["false", "true"] --send_metrics ["false", "true"] --token_cmdb ["token_cmdb"] --token_vulnerability_management ["token_vulnerability_management"] --token_engine_container ["token_engine_container"] --token_engine_dependencies ["token_engine_dependencies"] --token_external_checks ["token_external_checks"] --xray_mode ["scan", "audit","build-scan"] --image_to_scan ["image_to_scan"] --dast_file_path ["dast_file_path"] --context ["false", "true"] --terraform_repo_root ["terraform_files_repo"] --docker_address ["docker addres to engine_container with Prisma tool"] ``` ### Scan running sample (CLI) - Local diff --git a/docs/Docusaurus/docs/modules/engine_core.md b/docs/Docusaurus/docs/modules/engine_core.md index cd3f931df..c03e3f447 100644 --- a/docs/Docusaurus/docs/modules/engine_core.md +++ b/docs/Docusaurus/docs/modules/engine_core.md @@ -236,7 +236,6 @@ Configuration of the driven adapters in the main layer and management of on/off }, "ENGINE_LICENSE": { "ENABLED": true, - "TOOL": "GRANT", "PRIORITY": "STANDARD|DISCREET" }, "ENGINE_CODE": { @@ -447,7 +446,6 @@ Configuration of the driven adapters in the main layer and management of on/off - **ENGINE_LICENSE**: Configuration for the engine_license tool - ENABLED: true or false - - TOOL: GRANT - PRIORITY: STANDARD | DISCREET - **ENGINE_FUNCTION**: Configuration for the engine_function tool diff --git a/docs/Docusaurus/docs/modules/engine_sca/engine_license.md b/docs/Docusaurus/docs/modules/engine_sca/engine_license.md index 44701261f..f023b5d4a 100644 --- a/docs/Docusaurus/docs/modules/engine_sca/engine_license.md +++ b/docs/Docusaurus/docs/modules/engine_sca/engine_license.md @@ -2,54 +2,44 @@ ## Overview -The `engine_license` module is a **standalone** license-compliance reporter inside the DevSecOps Engine Tools platform. It always scans the local repository, generates a fresh CycloneDX SBOM, runs the [Anchore Grant](https://github.com/anchore/grant) license inspector against that SBOM, classifies every dependency against a policy declared in remote configuration, and emits a single artifact: `{pipeline_name}_LICENSE.json`. +The `engine_license` module is a **standalone** license-compliance reporter inside the DevSecOps Engine Tools platform. It scans the local repository, generates a fresh CycloneDX SBOM via **CdxGen**, classifies every dependency against a policy declared in remote configuration, and emits a single artifact: `{pipeline_name}_LICENSE.json`. Unlike other engines, `engine_license` does **not**: - Participate in `THRESHOLD` / break-build decisions. - Use `Exclusions.json`. -- Push findings to vulnerability management or risk score. +- Push findings to vulnerability management. - Reuse SBOMs from previous runs. - Scan container images (the artifact is repository-only). The intent is to ship an audit-friendly, policy-driven JSON that downstream consumers can analyse out-of-band of build pipelines. -> **Platform support:** Anchore Grant only ships binaries for **Linux (amd64, arm64)** and **macOS (amd64, arm64)**. Windows is not supported by upstream; the scanner is skipped with a logged warning. - ## Flow ```mermaid flowchart TD A[handle_scan: engine_license branch] --> B[runner_engine_license] B --> C[init_engine_license entry_point_tool] - C --> D[SbomManager.get_components
fresh {pipeline}_SBOM.json] - D --> E[GrantScan.run_tool_license_sca
analyses the SBOM] - E --> F[BuildLicenseReport
applies LICENSE_POLICY] - F --> G["Writes {pipeline}_LICENSE.json in CWD"] - G --> H[runner returns findings=[], input_core, sbom_components] - H --> I[handle_scan forwards findings, input_core] + C --> D["CdxGen generates {pipeline}_SBOM.json"] + D --> E["BuildLicenseReport reads SBOM
applies LICENSE_POLICY"] + E --> F["Writes {pipeline}_LICENSE.json in CWD"] + F --> G[runner builds findings from LICENSE.json] + G --> H[handle_scan prints compliance table & context] ``` -The runner returns the standard `(findings_list, input_core, sbom_components)` triple to keep parity with other engines, but `findings_list` is always empty: the report itself lives in the LICENSE.json file referenced by `input_core.path_file_results`. - ## Configuration Structure -Only one configuration file is consumed: `engine_sca/engine_license/ConfigTool.json`. There is no `Exclusions.json` and no `.grant.yaml`; the policy is declared inline. +Only one configuration file is consumed: `engine_sca/engine_license/ConfigTool.json`. There is no `Exclusions.json`; the policy is declared inline. ### ConfigTool.json ```json { - "GRANT": { - "GRANT_VERSION": "0.6.4", - "OUTPUT_FORMAT": "json", - "QUIET": true, - "DEBUG_PIPELINES": [], + "LICENSE": { "LICENSE_POLICY": { "fail": ["AGPL-*", "SSPL-*"], "warn": ["BUSL-*", "EPL-*", "LGPL-3.0*"], - "synonyms": { - }, + "synonyms": {}, "unlicensed_action": "ignore", "unknown_action": "ignore" } @@ -57,33 +47,26 @@ Only one configuration file is consumed: `engine_sca/engine_license/ConfigTool.j } ``` -#### `GRANT` block - -- **GRANT_VERSION**: Anchore Grant version to download. If `grant` is on `PATH`, it is reused. -- **OUTPUT_FORMAT**: Required to be `"json"` so the deserializer can parse Grant's report. Other values disable the report builder. -- **QUIET**: When `true`, passes `--quiet` to Grant. -- **DEBUG_PIPELINES**: Pipeline names that should log Grant's stdout/stderr for troubleshooting. - #### `LICENSE_POLICY` block -Declarative policy applied to every dependency Grant identifies. **All keys are required** for `engine_license` to produce a report; if `LICENSE_POLICY` is missing, no report is generated. +Declarative policy applied to every dependency in the SBOM. **All keys are required** for `engine_license` to produce a report; if `LICENSE_POLICY` is missing, no report is generated. - **fail**: Glob patterns (case-insensitive `fnmatch`) whose match marks the package as `fail` (severity `critical`). - **warn**: Glob patterns whose match marks the package as `warn` (severity `medium`). - **synonyms**: Object that rewrites raw license identifiers (e.g. `{"BSD": "BSD-3-Clause"}`) before matching. -- **unlicensed_action**: Action assigned to packages with no detected license. One of `fail` | `warn` | `info` | `ignore`. Controls only the severity tier; the package is always listed in the report under the `unlicensed` bucket. -- **unknown_action**: Action assigned to packages whose license label does not look like a valid SPDX identifier and matches no policy pattern. Same value set as above. The package is always listed under the `unknown` bucket. +- **unlicensed_action**: Action assigned to packages with no detected license. One of `fail` | `warn` | `info` | `ignore`. +- **unknown_action**: Action assigned to packages whose license label does not look like a valid SPDX identifier. Same value set as above. ## Output Artifact: `{pipeline_name}_LICENSE.json` -The report is written to the current working directory and uses a hybrid layout: a `metadata` block plus a flat `dependencies` array. +The report is written to the current working directory with a `metadata` block plus a flat `dependencies` array. ```json { "metadata": { - "pipeline_name": "AP0008001_CargaMasiva_HB_Lambda", - "scan_date": "2026-05-27T16:30:00", - "tool": "GRANT", + "pipeline_name": "my_service", + "scan_date": "2026-06-12T14:30:00", + "tool": "CDXGEN", "policy_used": { "fail": ["AGPL-*", "SSPL-*"], "warn": ["BUSL-*", "EPL-*", "LGPL-3.0*"], @@ -107,7 +90,8 @@ The report is written to the current working directory and uses a hybrid layout: "licenses": ["MIT"], "policy_applied": "ok", "policy_reason": "compliant SPDX license", - "policy_pattern_matched": null + "policy_pattern_matched": null, + "license_matched": "MIT" } ] } @@ -117,82 +101,95 @@ The report is written to the current working directory and uses a hybrid layout: #### `metadata` - **pipeline_name**: Value of the `pipeline_name` DevOps platform variable. -- **scan_date**: ISO-8601 timestamp (seconds resolution) when the report was assembled. -- **tool**: Always `"GRANT"`. -- **policy_used**: Verbatim deep copy of `GRANT.LICENSE_POLICY` for auditability. -- **summary.total_dependencies**: Number of entries in `dependencies` (root project is excluded). +- **scan_date**: ISO-8601 timestamp when the report was assembled. +- **tool**: `"CDXGEN"`. +- **policy_used**: Deep copy of `LICENSE.LICENSE_POLICY` for auditability. +- **summary.total_dependencies**: Number of entries in `dependencies`. - **summary.{ok,fail,warn,unlicensed,unknown}**: Count per `policy_applied` bucket. #### `dependencies[]` -- **name / version**: Package identifiers as reported by Grant. +- **name / version**: Package identifiers from the CycloneDX SBOM. - **licenses**: List of normalized license identifiers (after applying `synonyms`). -- **policy_applied**: Bucket of the package โ€” one of `ok`, `fail`, `warn`, `unlicensed`, `unknown`. +- **policy_applied**: Bucket โ€” one of `ok`, `fail`, `warn`, `unlicensed`, `unknown`. - **policy_reason**: Human-readable explanation (e.g. `matches FAIL pattern 'AGPL-*'`). - **policy_pattern_matched**: Original policy pattern that matched, or `null`. +- **license_matched**: The specific license that triggered the classification. ### Classification rules -For each package Grant reports: +For each component in the SBOM: -1. If the package has **no licenses** โ†’ bucket `unlicensed`. +1. If the component has **no licenses** โ†’ bucket `unlicensed`. 2. Otherwise the licenses are normalized through `synonyms` and each is classified: - Match against `fail` patterns โ†’ `fail`. - Match against `warn` patterns โ†’ `warn`. - - Otherwise, looks like an SPDX id โ†’ `ok`. + - Looks like an SPDX id โ†’ `ok`. - Otherwise โ†’ `unknown`. -3. **Dual-license semantics:** if any license on the package classifies as `ok`, the package is `ok` (the consumer can legally pick the permissive option). -4. Otherwise the most permissive non-OK bucket is reported in this rank order: `warn` < `fail` < `unknown` (lighter restriction reported when both exist). -5. The package whose name equals the SBOM's source root (heuristically derived from the source `ref`) is dropped to avoid reporting the project against itself. +3. **Highest-risk-wins:** if any license on the package matches `fail`, the package is `fail`; if any matches `warn`, it's `warn`. Only when all licenses are compliant is the package `ok`. -## Main Responsibilities +## Console Output (Findings) -- **SBOM Generation:** Always invokes the SBOM manager (`cdxgen`) to produce `{pipeline_name}_SBOM.json` in CWD; previous SBOM files are overwritten. -- **License Scan:** Runs `grant list` against the freshly generated SBOM and saves Grant's JSON report. -- **Policy Application:** Classifies every package using `LICENSE_POLICY` from remote config. -- **Report Emission:** Writes the hybrid `{pipeline_name}_LICENSE.json` artifact in CWD. -- **Platform Detection:** Linux/macOS ร— amd64/arm64; Windows logs a warning and aborts the license step. +When run with `--context false` (default), `engine_license` prints a compliance table showing only `fail` and `warn` findings: -## Key Components +| Severity | ID | Description | Where | +|---|---|---|---| +| critical | AGPL-3.0-itext | License 'AGPL-3.0' for package 'itext' (...) | itext:9.2.0 | +| medium | EPL-2.0-junit-bom | License 'EPL-2.0' for package 'junit-bom' (...) | junit-bom:5.10.2 | -- `applications/runner_license_scan.py`: Entry point invoked by `engine_core/handle_scan`. Selects the tool (currently only Grant), runs the standalone flow, and assembles the minimal `InputCore` returned to downstream consumers. -- `infrastructure/entry_points/entry_point_tool.py`: Use case orchestrator: fetch remote config โ†’ fresh SBOM โ†’ Grant scan โ†’ build report. -- `infrastructure/driven_adapters/grant_tool/grant_manager_scan.py`: Driven adapter that downloads/installs the Grant binary and runs `grant list `. -- `domain/usecases/license_policy.py`: Pure helpers โ€” `build_policy_from_remote_config`, `classify_package`, `looks_like_spdx_id`, etc. No I/O, fully unit-testable. -- `domain/usecases/build_license_report.py`: `BuildLicenseReport` use case that reads Grant's JSON, classifies every dependency through `license_policy`, and writes the LICENSE.json artifact. +Dependencies classified as `ok`, `unlicensed`, or `unknown` do **not** appear in the console findings. -## Supported Tools and Features +## Structured Context (`--context true`) -- **Anchore Grant** (`grant list`) over CycloneDX SBOMs. -- **Multi-platform:** Linux amd64/arm64, macOS amd64/arm64. -- **Policy as configuration:** allow / fail / warn / ignore rules declared inline in `LICENSE_POLICY` (no separate `.grant.yaml`). -- **Audit-friendly artifact:** the LICENSE.json includes the original policy and per-dependency justification. +When run with `--context true`, the engine additionally prints a JSON block: + +``` +===== BEGIN CONTEXT OUTPUT ===== +{ + "license_context": [ + { + "name": "itext", + "version": "9.2.0", + "licenses": ["AGPL-3.0"], + "policy_applied": "fail", + "policy_reason": "matches FAIL pattern 'AGPL-*'", + "policy_pattern_matched": "AGPL-*", + "severity": "critical", + "priority": "very critical" + } + ] +} +===== END CONTEXT OUTPUT ===== +``` + +Only `fail` and `warn` dependencies appear in the context output. + +## Key Components + +- `applications/runner_license_scan.py`: Entry point invoked by `engine_core/handle_scan`. Runs the flow and builds `Finding` objects for the compliance table. +- `infrastructure/entry_points/entry_point_tool.py`: Orchestrator: fetch remote config โ†’ fresh SBOM โ†’ build report. +- `infrastructure/driven_adapters/license_scan/license_scan_manager.py`: Reads the LICENSE.json and provides structured context extraction. +- `domain/usecases/license_policy.py`: Pure helpers โ€” `build_policy_from_remote_config`, `classify_package`, `looks_like_spdx_id`. No I/O, fully unit-testable. +- `domain/usecases/build_license_report.py`: `BuildLicenseReport` use case that reads the CycloneDX SBOM, classifies every dependency, and writes the LICENSE.json artifact. ## Example Usage -### Repository (default โ€” folder mode) ```sh devsecops-engine-tools \ --platform_devops local \ --remote_config_source local \ --remote_config_repo example_remote_config_local \ --module engine_license \ - --tool grant \ --folder_path path/to/project ``` If `--folder_path` is omitted, the current working directory is scanned. -> Container image scanning is intentionally not part of this module; it relies on the SBOM produced from the repository. - -> Windows runners will log a warning and skip the scan; configure your pipeline to run `engine_license` on Linux or macOS agents. - ## Configuration Guidelines -- Pin `GRANT_VERSION` to a tested release of Anchore Grant. - Keep `LICENSE_POLICY` in remote configuration so policy changes are reviewed and auditable; the report echoes it verbatim under `metadata.policy_used`. - Use specific SPDX identifiers in `fail` / `warn` patterns when possible (e.g. `AGPL-3.0`, `LGPL-3.0*`); use globs sparingly. -- Use `synonyms` to canonicalize ambiguous labels coming from the SBOM (e.g. `"BSD"` โ†’ `"BSD-3-Clause"`). +- Use `synonyms` to canonicalize ambiguous labels from the SBOM (e.g. `"BSD"` โ†’ `"BSD-3-Clause"`). +- Ensure `SBOM_MANAGER.CDXGEN.FETCH_LICENSE` is `true` so the SBOM includes license metadata. - Choose `unlicensed_action` and `unknown_action` deliberately: - - `ignore` keeps the package in the report (so the auditor sees it) but assigns `info` severity. - - `warn` / `fail` raise the severity attached to those buckets. -- `engine_license` is intended for audit/reporting workflows and should typically run on its own pipeline (or a separate stage) rather than gating builds. + - `ignore` keeps the package in the report but assigns `info` severity (not shown in findings). + - `warn` / `fail` raises the severity for those buckets. diff --git a/example_remote_config_local/engine_core/ConfigTool.json b/example_remote_config_local/engine_core/ConfigTool.json index e705d0708..2aa53b8da 100644 --- a/example_remote_config_local/engine_core/ConfigTool.json +++ b/example_remote_config_local/engine_core/ConfigTool.json @@ -219,7 +219,6 @@ }, "ENGINE_LICENSE": { "ENABLED": true, - "TOOL": "GRANT", "PRIORITY": "DISCREET" }, "ENGINE_CODE": { diff --git a/example_remote_config_local/engine_sca/engine_license/ConfigTool.json b/example_remote_config_local/engine_sca/engine_license/ConfigTool.json index f35fe3cf0..fdb813207 100644 --- a/example_remote_config_local/engine_sca/engine_license/ConfigTool.json +++ b/example_remote_config_local/engine_sca/engine_license/ConfigTool.json @@ -1,9 +1,5 @@ { - "GRANT": { - "GRANT_VERSION": "0.6.4", - "OUTPUT_FORMAT": "json", - "QUIET": true, - "DEBUG_PIPELINES": [], + "LICENSE": { "LICENSE_POLICY": { "fail": ["AGPL-*", "SSPL-*"], "warn": ["BUSL-*", "EPL-*", "LGPL-3.0*"], diff --git a/tools/devsecops_engine_tools/engine_core/src/applications/runner_engine_core.py b/tools/devsecops_engine_tools/engine_core/src/applications/runner_engine_core.py index 7f74d06cd..abc0c0dae 100755 --- a/tools/devsecops_engine_tools/engine_core/src/applications/runner_engine_core.py +++ b/tools/devsecops_engine_tools/engine_core/src/applications/runner_engine_core.py @@ -116,7 +116,7 @@ def get_inputs_from_cli(args): "xray", "dependency_check", "kiuwan", - "grant", + "cdxgen", "all_tools", ], type=str, @@ -268,7 +268,7 @@ def get_inputs_from_cli(args): "engine_secret": ["trufflehog", "gitleaks", "all_tools"], "engine_container": ["prisma", "trivy"], "engine_dependencies": ["xray", "dependency_check", "trivy"], - "engine_license": ["grant"], + "engine_license": ["cdxgen"], "engine_code": ["bearer", "kiuwan"], "engine_dast": ["nuclei"], "engine_risk": None, diff --git a/tools/devsecops_engine_tools/engine_core/src/domain/usecases/handle_scan.py b/tools/devsecops_engine_tools/engine_core/src/domain/usecases/handle_scan.py index ec5ed5217..a9a6bd538 100644 --- a/tools/devsecops_engine_tools/engine_core/src/domain/usecases/handle_scan.py +++ b/tools/devsecops_engine_tools/engine_core/src/domain/usecases/handle_scan.py @@ -49,6 +49,9 @@ from devsecops_engine_tools.engine_sca.engine_license.src.applications.runner_license_scan import ( runner_engine_license, ) +from devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.driven_adapters.license_scan.license_scan_manager import ( + LicenseScanManager, +) from devsecops_engine_tools.engine_sca.engine_function.src.applications.runner_function_scan import ( runner_engine_function, ) @@ -226,7 +229,7 @@ def process(self, dict_args: any, config_tool: any): self.risk_score_gateway.get_risk_score(findings_list, config_tool, dict_args["module"]) return findings_list, input_core elif "engine_license" in dict_args["module"]: - findings_list, input_core, _sbom_components, tool_gateway = runner_engine_license( + findings_list, input_core, _sbom_components = runner_engine_license( dict_args, config_tool, secret_tool, @@ -240,7 +243,7 @@ def process(self, dict_args: any, config_tool: any): "engine_license", input_core.path_file_results, config_tool["ENGINE_LICENSE"], - tool_gateway, + LicenseScanManager(), config_tool ) diff --git a/tools/devsecops_engine_tools/engine_core/test/domain/usecases/test_handle_scan.py b/tools/devsecops_engine_tools/engine_core/test/domain/usecases/test_handle_scan.py index 263b2a1ed..d292f3a23 100644 --- a/tools/devsecops_engine_tools/engine_core/test/domain/usecases/test_handle_scan.py +++ b/tools/devsecops_engine_tools/engine_core/test/domain/usecases/test_handle_scan.py @@ -1002,7 +1002,7 @@ def test_process_with_engine_license_returns_runner_input_core( } config_tool = { "VULNERABILITY_MANAGER": {}, - "ENGINE_LICENSE": {"ENABLED": "true", "TOOL": "GRANT"}, + "ENGINE_LICENSE": {"ENABLED": "true"}, } runner_input_core = InputCore( @@ -1014,12 +1014,10 @@ def test_process_with_engine_license_returns_runner_input_core( scope_service="svc", stage_pipeline="Build", ) - mock_tool_gateway = MagicMock() mock_runner_engine_license.return_value = ( [], runner_input_core, ["sbom_component"], - mock_tool_gateway, ) findings, input_core = self.handle_scan.process(dict_args, config_tool) @@ -1056,7 +1054,7 @@ def test_process_with_engine_license_propagates_runner_failure_input_core( } config_tool = { "VULNERABILITY_MANAGER": {}, - "ENGINE_LICENSE": {"ENABLED": "true", "TOOL": "GRANT"}, + "ENGINE_LICENSE": {"ENABLED": "true"}, } failure_input_core = InputCore( @@ -1068,8 +1066,7 @@ def test_process_with_engine_license_propagates_runner_failure_input_core( scope_service="svc", stage_pipeline="Build", ) - mock_tool_gateway = MagicMock() - mock_runner_engine_license.return_value = ([], failure_input_core, None, mock_tool_gateway) + mock_runner_engine_license.return_value = ([], failure_input_core, None) findings, input_core = self.handle_scan.process(dict_args, config_tool) self.assertEqual(findings, []) diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/applications/runner_license_scan.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/applications/runner_license_scan.py index de85223cc..8e5c20572 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/src/applications/runner_license_scan.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/src/applications/runner_license_scan.py @@ -6,9 +6,6 @@ ) from devsecops_engine_tools.engine_core.src.domain.model.input_core import InputCore from devsecops_engine_tools.engine_core.src.domain.model.threshold import Threshold -from devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.driven_adapters.grant_tool.grant_manager_scan import ( - GrantScan, -) from devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.entry_points.entry_point_tool import ( init_engine_license, ) @@ -44,7 +41,7 @@ def _build_findings_from_license_json(license_json_path): module="engine_license", category=Category.COMPLIANCE, requirements="", - tool="GRANT", + tool="CDXGEN", ) ) return findings @@ -60,33 +57,18 @@ def runner_engine_license( ): """Run the engine_license standalone flow. - Produces ''{pipeline_name}_LICENSE.json'' in the CWD and assembles a - minimal :class:'InputCore' so downstream consumers (BreakBuild, - MetricsManager) keep working. + Produces ''{pipeline_name}_LICENSE.json'' in the CWD. Returns: - Tuple ''{findings_list, input_core, sbom_components, tool_run}''. + Tuple ''{findings_list, input_core, sbom_components}''. """ try: - tools_mapping = { - "GRANT": { - "tool_run": GrantScan, - "tool_sbom": sbom_tool_gateway, - } - } - - selected_tool = config_tool["ENGINE_LICENSE"]["TOOL"] - tool_run = tools_mapping[selected_tool]["tool_run"]() - tool_sbom = tools_mapping[selected_tool]["tool_sbom"] - license_json_path, sbom_components = init_engine_license( - tool_run, devops_platform_gateway, remote_config_source_gateway, dict_args, - secret_tool, config_tool, - tool_sbom, + sbom_tool_gateway, ) pipeline_name = devops_platform_gateway.get_variable("pipeline_name") @@ -102,7 +84,7 @@ def runner_engine_license( stage_pipeline="Build", ) - return findings_list, input_core, sbom_components, tool_run + return findings_list, input_core, sbom_components except Exception as e: raise Exception(f"Error SCAN engine license : {str(e)}") diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/model/gateways/tool_gateway.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/model/gateways/tool_gateway.py deleted file mode 100644 index d27db47fa..000000000 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/model/gateways/tool_gateway.py +++ /dev/null @@ -1,18 +0,0 @@ -from abc import ABCMeta, abstractmethod - - -class ToolGateway(metaclass=ABCMeta): - @abstractmethod - def run_tool_license_sca( - self, - remote_config, - dict_args, - exclusions, - pipeline_name, - to_scan, - sbom_path, - image_to_scan, - secret_tool, - **kwargs, - ) -> str: - "run tool license sca" diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/build_license_report.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/build_license_report.py index 7f37471a4..15e8fe77c 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/build_license_report.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/build_license_report.py @@ -1,4 +1,4 @@ -"""Use case that builds the LICENSE.json artifact from a Grant scan report. +"""Use case that builds the LICENSE.json artifact from a CycloneDX SBOM. The output is a hybrid JSON: top-level ``metadata`` block (pipeline name, scan date, tool, applied policy echo, summary counts) plus a flat @@ -12,43 +12,40 @@ import copy import json import os -import re from datetime import datetime from devsecops_engine_tools.engine_sca.engine_license.src.domain.usecases.license_policy import ( build_policy_from_remote_config, classify_package, - get_value, ) from devsecops_engine_tools.engine_utilities.utils.logger_info import MyLogger from devsecops_engine_tools.engine_utilities import settings logger = MyLogger.__call__(**settings.SETTING_LOGGER).get_logger() -TOOL = "GRANT" +TOOL = "CDXGEN" _SUMMARY_BUCKETS = ("ok", "fail", "warn", "unlicensed", "unknown") class BuildLicenseReport: - """Build the LICENSE.json artifact from a Grant scan report. + """Build the LICENSE.json artifact from a CycloneDX SBOM. The ``process`` method returns the absolute path to the file it writes - or ``None`` when no report could be generated (e.g. invalid input - file, missing policy). + or ``None`` when no report could be generated. """ def __init__(self, output_dir: str = None): self.output_dir = output_dir or os.getcwd() - def process(self, grant_report_path, remote_config, pipeline_name): + def process(self, sbom_path, remote_config, pipeline_name): policy = build_policy_from_remote_config(remote_config) if policy is None: logger.error( - "Cannot build LICENSE report: GRANT.LICENSE_POLICY missing." + "Cannot build LICENSE report: LICENSE_POLICY missing in remote config." ) return None - data = self._read_grant_report(grant_report_path) + data = self._read_sbom(sbom_path) if data is None: return None @@ -62,91 +59,51 @@ def process(self, grant_report_path, remote_config, pipeline_name): return self._write_report(report, pipeline_name) @staticmethod - def _read_grant_report(grant_report_path): - if not grant_report_path: - logger.error("Grant report path is empty; cannot build LICENSE report.") + def _read_sbom(sbom_path): + if not sbom_path: + logger.error("SBOM path is empty; cannot build LICENSE report.") return None - if not os.path.exists(grant_report_path): - logger.error(f"Grant report not found: {grant_report_path}") + if not os.path.exists(sbom_path): + logger.error(f"SBOM not found: {sbom_path}") return None try: - with open(grant_report_path, "r") as fh: + with open(sbom_path, "r") as fh: return json.load(fh) except Exception as e: - logger.error(f"Error reading Grant report '{grant_report_path}': {e}") + logger.error(f"Error reading SBOM '{sbom_path}': {e}") return None def _build_dependencies(self, data, policy): - targets = self._extract_targets(data) + components = data.get("components", []) dependencies = [] - for target in targets: - source = get_value(target, "source", "Source", default={}) - source_ref = get_value(source, "ref", "Ref", default="") - source_root = self._source_root_name(source_ref) - - evaluation = get_value( - target, "evaluation", "Evaluation", default={} - ) - findings_block = get_value( - evaluation, "findings", "Findings", default={} - ) - packages = ( - get_value(findings_block, "packages", "Packages", default=[]) - or [] + for component in components: + pkg_name = component.get("name", "unknown") + pkg_version = component.get("version", "") + raw_licenses = component.get("licenses", []) + licenses = [ + entry.get("license", entry) for entry in raw_licenses + if isinstance(entry, dict) + ] + + classification = classify_package(licenses, policy) + dependencies.append( + { + "name": pkg_name, + "version": pkg_version, + "licenses": classification["licenses"], + "policy_applied": classification["policy_applied"], + "policy_reason": classification["reason"], + "policy_pattern_matched": classification[ + "pattern_matched" + ], + "license_matched": classification["label"], + } ) - - for pkg in packages: - pkg_name = get_value(pkg, "name", "Name", default="unknown") - pkg_version = get_value(pkg, "version", "Version", default="") - licenses = ( - get_value(pkg, "licenses", "Licenses", default=[]) or [] - ) - - if source_root and self._is_root_project(pkg_name, source_root): - continue - - classification = classify_package(licenses, policy) - dependencies.append( - { - "name": pkg_name, - "version": pkg_version, - "licenses": classification["licenses"], - "policy_applied": classification["policy_applied"], - "policy_reason": classification["reason"], - "policy_pattern_matched": classification[ - "pattern_matched" - ], - "license_matched": classification["label"], - } - ) return dependencies - @staticmethod - def _extract_targets(data): - run = get_value(data, "run", "Run", default={}) - targets = get_value(run, "targets", "Targets") - if targets: - return targets - return data.get("results") or data.get("Results") or [] - - @staticmethod - def _source_root_name(source_ref): - if not source_ref: - return "" - base = os.path.basename(source_ref) - base = re.sub(r"\.(cdx|spdx)?\.?(json|xml|yaml|yml)$", "", base, flags=re.I) - base = re.sub(r"[_\-]sbom$", "", base, flags=re.I) - return base.strip().lower() - - @staticmethod - def _is_root_project(pkg_name, source_root_name): - if not pkg_name or not source_root_name: - return False - return pkg_name.strip().lower() == source_root_name - def _build_metadata(self, pipeline_name, remote_config, dependencies): policy_used = copy.deepcopy( - (remote_config or {}).get(TOOL, {}).get("LICENSE_POLICY", {}) + (remote_config or {}).get("LICENSE", {}).get("LICENSE_POLICY", {}) ) summary = { diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/license_policy.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/license_policy.py index 29fbd6792..31080f6c7 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/license_policy.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/license_policy.py @@ -17,7 +17,7 @@ logger = MyLogger.__call__(**settings.SETTING_LOGGER).get_logger() -TOOL = "GRANT" +TOOL = "LICENSE" SEVERITY_BY_ACTION = { "fail": "critical", @@ -57,7 +57,7 @@ def looks_like_spdx_id(label): def build_policy_from_remote_config(remote_config): - """Build the policy dict exclusively from remote_config GRANT.LICENSE_POLICY. + """Build the policy dict from remote_config LICENSE_POLICY section. Returns None (and logs error) if the configuration is absent. """ @@ -68,7 +68,7 @@ def build_policy_from_remote_config(remote_config): override = (remote_config.get(TOOL) or {}).get("LICENSE_POLICY") if not isinstance(override, dict): logger.error( - "remote_config missing GRANT.LICENSE_POLICY: cannot classify licenses." + "remote_config missing LICENSE_POLICY configuration: cannot classify licenses." ) return None @@ -137,7 +137,7 @@ def _classify_label(normalized_label, policy): def _extract_license_id(license_entry): - """Best-effort extraction of a license identifier from a SBOM/Grant entry.""" + """Best-effort extraction of a license identifier from a CycloneDX SBOM entry.""" return ( get_value( license_entry, @@ -160,7 +160,7 @@ def classify_package(licenses, policy): ``ok``. Args: - licenses: list of license entries (dicts) from the SBOM/Grant report. + licenses: list of license entries (dicts) from the CycloneDX SBOM. May be empty, in which case the package is considered unlicensed. policy: policy dict produced by ``build_policy_from_remote_config``. diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/grant_tool/grant_manager_scan.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/grant_tool/grant_manager_scan.py deleted file mode 100644 index a4aba48a9..000000000 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/grant_tool/grant_manager_scan.py +++ /dev/null @@ -1,244 +0,0 @@ -import json -import os -import platform -import shutil -import subprocess -import tarfile -from typing import List - -import requests - -from devsecops_engine_tools.engine_sca.engine_license.src.domain.model.context_license import ( - ContextLicense, -) -from devsecops_engine_tools.engine_sca.engine_license.src.domain.model.gateways.tool_gateway import ( - ToolGateway, -) -from devsecops_engine_tools.engine_utilities.utils.logger_info import MyLogger -from devsecops_engine_tools.engine_utilities import settings - -logger = MyLogger.__call__(**settings.SETTING_LOGGER).get_logger() - - -class GrantScan(ToolGateway): - """Anchore Grant license scanner driven adapter. - - Supports Linux (amd64, arm64) and macOS (amd64, arm64). Windows is not - supported by the upstream Grant project and is rejected with a logged - warning. - """ - - TOOL = "GRANT" - _POLICY_TO_SEVERITY = { - "fail": "critical", - "warn": "medium", - } - - - def __init__(self): - self.download_tool_called = False - - def run_tool_license_sca( - self, - remote_config, - dict_args, - exclusions, - pipeline_name, - to_scan, - sbom_path, - image_to_scan, - secret_tool, - **kwargs, - ): - try: - grant_config = remote_config.get(self.TOOL, {}) - grant_version = grant_config.get("GRANT_VERSION", "0.6.4") - output_format = grant_config.get("OUTPUT_FORMAT", "json") - debug_pipelines = grant_config.get("DEBUG_PIPELINES", []) - quiet = grant_config.get("QUIET", True) - enable_debug = pipeline_name in debug_pipelines if debug_pipelines else False - - command_prefix = self._resolve_binary(grant_version) - if not command_prefix: - return None - - scan_target = self._resolve_scan_target( - sbom_path, image_to_scan, to_scan - ) - if not scan_target: - logger.error("Grant scan target could not be resolved (no SBOM, image or folder).") - return None - - return self._run_grant( - command_prefix, - scan_target, - pipeline_name, - output_format, - quiet, - enable_debug, - ) - except Exception as e: - logger.error(f"Error running Grant license scan: {e}") - return None - - def _resolve_scan_target(self, sbom_path, image_to_scan, to_scan): - if sbom_path and os.path.exists(sbom_path): - return sbom_path - if image_to_scan: - return image_to_scan - if to_scan and os.path.exists(to_scan): - return to_scan - return None - - def _resolve_binary(self, grant_version): - installed = shutil.which("grant") - if installed: - logger.info(f"Using Grant from PATH: {installed}") - return installed - - os_platform = platform.system() - os_architecture = platform.machine() - - if os_platform == "Windows": - logger.warning( - "Anchore Grant does not provide a Windows binary. " - "Skipping license scan on Windows." - ) - return None - - os_token, arch_token = self._map_platform(os_platform, os_architecture) - if not os_token or not arch_token: - logger.warning( - f"Unsupported platform for Grant: {os_platform}/{os_architecture}" - ) - return None - - file_name = f"grant_{grant_version}_{os_token}_{arch_token}.tar.gz" - url = ( - f"https://github.com/anchore/grant/releases/download/" - f"v{grant_version}/{file_name}" - ) - return self._install_tool_unix(file_name, url) - - def _map_platform(self, os_platform, os_architecture): - os_map = {"Linux": "linux", "Darwin": "darwin"} - arch_map = { - "x86_64": "amd64", - "amd64": "amd64", - "arm64": "arm64", - "aarch64": "arm64", - } - return os_map.get(os_platform), arch_map.get(os_architecture) - - def _install_tool_unix(self, file_name, url): - try: - self.download_tool_called = True - self._download_tool(file_name, url) - - extract_dir = os.path.join(os.getcwd(), "grant_bin") - os.makedirs(extract_dir, exist_ok=True) - with tarfile.open(file_name, "r:gz") as tar: - tar.extractall(extract_dir) - - binary_path = os.path.join(extract_dir, "grant") - if not os.path.exists(binary_path): - logger.error(f"Grant binary not found after extracting {file_name}") - return None - - subprocess.run( - ["chmod", "+x", binary_path], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - logger.info(f"Installed Grant binary: {binary_path}") - return binary_path - except Exception as e: - logger.error(f"Error installing Grant: {e}") - return None - - def _download_tool(self, file_name, url): - try: - response = requests.get(url, allow_redirects=True) - with open(file_name, "wb") as compress_file: - compress_file.write(response.content) - except Exception as e: - logger.error(f"Error downloading Grant: {e}") - raise - - def _run_grant( - self, - command_prefix, - scan_target, - pipeline_name, - output_format, - quiet, - enable_debug, - ): - result_file = f"{pipeline_name}_grant.json" - - command = [ - command_prefix, - "list", - scan_target, - "-o", - output_format, - "-f", - result_file, - ] - - if quiet: - command.append("--quiet") - - try: - result = subprocess.run( - command, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - if enable_debug: - if result.stdout: - logger.info(f"GRANT stdout (first 4kb): {result.stdout[:4096]}") - if result.stderr: - logger.info(f"GRANT stderr: {result.stderr}") - - if os.path.exists(result_file) and os.path.getsize(result_file) > 0: - logger.info(f"Grant report saved to: {result_file}") - return result_file - - if result.stdout and result.stdout.strip().startswith("{"): - with open(result_file, "w") as f: - f.write(result.stdout) - logger.info(f"Grant report saved to: {result_file}") - return result_file - - logger.error( - f"Grant produced no output (exit={result.returncode}): {result.stderr}" - ) - return None - except Exception as e: - logger.error(f"Error executing Grant: {e}") - return None - - def get_license_context_from_results(self, path_file_results) -> List[ContextLicense]: - with open(path_file_results, "r") as fh: - data = json.load(fh) - - context_list = [] - for dep in data.get("dependencies", []): - policy = dep.get("policy_applied", "unknown") - if policy not in self._POLICY_TO_SEVERITY: - continue - context_list.append( - ContextLicense( - name=dep.get("name", "unknown"), - version=dep.get("version", ""), - licenses=dep.get("licenses", []), - policy_applied=policy, - policy_reason=dep.get("policy_reason", ""), - policy_pattern_matched=dep.get("policy_pattern_matched", "") or "", - severity=self._POLICY_TO_SEVERITY[policy], - ) - ) - return context_list diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/grant_tool/__init__.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/license_scan/__init__.py similarity index 100% rename from tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/grant_tool/__init__.py rename to tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/license_scan/__init__.py diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/license_scan/license_scan_manager.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/license_scan/license_scan_manager.py new file mode 100644 index 000000000..cb090721d --- /dev/null +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/license_scan/license_scan_manager.py @@ -0,0 +1,37 @@ +import json +from typing import List + +from devsecops_engine_tools.engine_sca.engine_license.src.domain.model.context_license import ( + ContextLicense, +) + +_POLICY_TO_SEVERITY = { + "fail": "critical", + "warn": "medium", +} + + +class LicenseScanManager: + """Reads the LICENSE.json and provides context extraction for engine_license.""" + + def get_license_context_from_results(self, path_file_results) -> List[ContextLicense]: + with open(path_file_results, "r") as fh: + data = json.load(fh) + + context_list = [] + for dep in data.get("dependencies", []): + policy = dep.get("policy_applied", "unknown") + if policy not in _POLICY_TO_SEVERITY: + continue + context_list.append( + ContextLicense( + name=dep.get("name", "unknown"), + version=dep.get("version", ""), + licenses=dep.get("licenses", []), + policy_applied=policy, + policy_reason=dep.get("policy_reason", ""), + policy_pattern_matched=dep.get("policy_pattern_matched", "") or "", + severity=_POLICY_TO_SEVERITY[policy], + ) + ) + return context_list diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/entry_points/entry_point_tool.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/entry_points/entry_point_tool.py index eef901f36..b7e449d40 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/entry_points/entry_point_tool.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/entry_points/entry_point_tool.py @@ -1,13 +1,10 @@ """Entry point of the engine_license module. -The flow is intentionally linear and standalone (it does NOT participate -in the build pipeline gating logic): +The flow is: - 1. Always generate a fresh SBOM of the local repository (no cache reuse, - no branch filter, no image scanning). - 2. Run Grant against that SBOM. - 3. Build the ``{pipeline_name}_LICENSE.json`` artifact from Grant's - output, applying the policy declared in remote_config. + 1. Generate a fresh SBOM via CdxGen. + 2. Build the ``{pipeline_name}_LICENSE.json`` artifact directly from + the SBOM, applying the policy declared in remote_config. The entry point returns ``(license_json_path, sbom_components)``. """ @@ -30,11 +27,9 @@ def init_engine_license( - tool_run, devops_platform_gateway: DevopsPlatformGateway, - remote_config_source_gateway: DevopsPlatformGateway, + remote_config_source_gateway, dict_args, - secret_tool, config_tool, tool_sbom: SbomManagerGateway, ): @@ -68,21 +63,7 @@ def init_engine_license( ) return None, sbom_components - grant_report_path = tool_run.run_tool_license_sca( - remote_config, - dict_args, - None, - pipeline_name, - to_scan, - sbom_path, - None, - secret_tool, - ) - if not grant_report_path: - logger.error("Grant scan produced no output; aborting LICENSE report build.") - return None, sbom_components - license_json_path = BuildLicenseReport().process( - grant_report_path, remote_config, pipeline_name + sbom_path, remote_config, pipeline_name ) return license_json_path, sbom_components diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/applications/test_runner_license_scan.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/applications/test_runner_license_scan.py index 22825afbf..6a842f979 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/test/applications/test_runner_license_scan.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/test/applications/test_runner_license_scan.py @@ -16,21 +16,19 @@ def _make_devops_gateway(pipeline_name="svc"): return gw -def test_runner_engine_license_grant_returns_findings_input_core_and_components(): +def test_runner_engine_license_returns_findings_input_core_and_components(): with patch( "devsecops_engine_tools.engine_sca.engine_license.src.applications.runner_license_scan.init_engine_license" ) as mock_init, patch( - "devsecops_engine_tools.engine_sca.engine_license.src.applications.runner_license_scan.GrantScan" - ), patch( "devsecops_engine_tools.engine_sca.engine_license.src.applications.runner_license_scan._build_findings_from_license_json" ) as mock_findings: mock_init.return_value = ("/abs/svc_LICENSE.json", ["c1"]) mock_findings.return_value = [] - config_tool = {"ENGINE_LICENSE": {"TOOL": "GRANT"}} + config_tool = {"ENGINE_LICENSE": {"ENABLED": True}} devops_gw = _make_devops_gateway() - findings, input_core, sbom_components, tool_gw = runner_engine_license( + findings, input_core, sbom_components = runner_engine_license( {"remote_config_repo": "r", "remote_config_branch": ""}, config_tool, None, @@ -44,7 +42,6 @@ def test_runner_engine_license_grant_returns_findings_input_core_and_components( assert findings == [] assert sbom_components == ["c1"] - assert tool_gw is not None assert input_core.path_file_results == "/abs/svc_LICENSE.json" assert input_core.totalized_exclusions == [] @@ -59,20 +56,17 @@ def test_runner_engine_license_grant_returns_findings_input_core_and_components( def test_runner_engine_license_propagates_none_path(): - """When init_engine_license fails, runner still returns a usable InputCore.""" with patch( "devsecops_engine_tools.engine_sca.engine_license.src.applications.runner_license_scan.init_engine_license" ) as mock_init, patch( - "devsecops_engine_tools.engine_sca.engine_license.src.applications.runner_license_scan.GrantScan" - ), patch( "devsecops_engine_tools.engine_sca.engine_license.src.applications.runner_license_scan._build_findings_from_license_json" ) as mock_findings: mock_init.return_value = (None, None) mock_findings.return_value = [] - config_tool = {"ENGINE_LICENSE": {"TOOL": "GRANT"}} + config_tool = {"ENGINE_LICENSE": {"ENABLED": True}} devops_gw = _make_devops_gateway() - findings, input_core, sbom_components, tool_gw = runner_engine_license( + findings, input_core, sbom_components = runner_engine_license( {"remote_config_repo": "r", "remote_config_branch": ""}, config_tool, None, @@ -87,12 +81,6 @@ def test_runner_engine_license_propagates_none_path(): assert input_core.scope_pipeline == "svc" -def test_runner_engine_license_unknown_tool_raises(): - config_tool = {"ENGINE_LICENSE": {"TOOL": "UNKNOWN"}} - with pytest.raises(Exception, match="Error SCAN engine license"): - runner_engine_license({}, config_tool, None, None, None, None) - - def test_runner_license_main_block(): with pytest.raises((TypeError, Exception)): runpy.run_module( @@ -129,7 +117,6 @@ def test_build_findings_from_license_json(tmp_path): assert findings[0].where == "ngrx:1.0.0" assert findings[0].category == Category.COMPLIANCE assert findings[0].module == "engine_license" - # Dual-license: uses EPL-2.0 (the one that matched), not Apache-2.0 assert findings[1].id == "EPL-2.0-biz-lib" assert findings[1].severity == "medium" diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_build_license_report.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_build_license_report.py index 135839b15..b311c40cc 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_build_license_report.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_build_license_report.py @@ -11,7 +11,7 @@ def _remote_config(): return { - "GRANT": { + "LICENSE": { "LICENSE_POLICY": { "fail": ["AGPL-*"], "warn": ["BUSL-*"], @@ -23,47 +23,32 @@ def _remote_config(): } -def _grant_payload(packages, source_ref="path/to/my-app_sbom.json"): +def _sbom_payload(components): return { - "run": { - "targets": [ - { - "source": {"ref": source_ref}, - "evaluation": { - "findings": {"packages": packages}, - }, - } - ] - } + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "components": components, } -def _write_grant(tmp_path, payload): - path = tmp_path / "grant_report.json" +def _write_sbom(tmp_path, payload): + path = tmp_path / "sbom.json" path.write_text(json.dumps(payload)) return str(path) -# ---------------------------------------------------------------- happy path - - def test_process_writes_license_json_with_mixed_classifications(tmp_path): - packages = [ - {"name": "lodash", "version": "4.17.21", "licenses": [{"id": "MIT"}]}, - {"name": "ngrx", "version": "1.0.0", "licenses": [{"id": "AGPL-3.0"}]}, - {"name": "biz-lib", "version": "2.0.0", "licenses": [{"id": "BUSL-1.1"}]}, + components = [ + {"name": "lodash", "version": "4.17.21", "licenses": [{"license": {"id": "MIT"}}]}, + {"name": "ngrx", "version": "1.0.0", "licenses": [{"license": {"id": "AGPL-3.0"}}]}, + {"name": "biz-lib", "version": "2.0.0", "licenses": [{"license": {"id": "BUSL-1.1"}}]}, {"name": "no-lic-pkg", "version": "0.1.0", "licenses": []}, - { - "name": "weird-pkg", - "version": "0.0.1", - "licenses": [{"name": "Custom Proprietary License"}], - }, - {"name": "my-app", "version": "1.0.0", "licenses": [{"id": "MIT"}]}, + {"name": "weird-pkg", "version": "0.0.1", "licenses": [{"license": {"name": "Custom Proprietary License"}}]}, ] - report_path = _write_grant(tmp_path, _grant_payload(packages)) + sbom_path = _write_sbom(tmp_path, _sbom_payload(components)) use_case = BuildLicenseReport(output_dir=str(tmp_path)) - out_path = use_case.process(report_path, _remote_config(), "svc") + out_path = use_case.process(sbom_path, _remote_config(), "svc") assert out_path is not None assert os.path.exists(out_path) @@ -74,8 +59,8 @@ def test_process_writes_license_json_with_mixed_classifications(tmp_path): md = report["metadata"] assert md["pipeline_name"] == "svc" - assert md["tool"] == "GRANT" - assert md["policy_used"] == _remote_config()["GRANT"]["LICENSE_POLICY"] + assert md["tool"] == "CDXGEN" + assert md["policy_used"] == _remote_config()["LICENSE"]["LICENSE_POLICY"] assert md["summary"]["total_dependencies"] == 5 assert md["summary"]["ok"] == 1 assert md["summary"]["fail"] == 1 @@ -99,17 +84,13 @@ def test_process_writes_license_json_with_mixed_classifications(tmp_path): def test_process_dual_license_highest_risk_wins(tmp_path): - packages = [ - { - "name": "dual-pkg", - "version": "1.0", - "licenses": [{"id": "AGPL-3.0"}, {"id": "MIT"}], - } + components = [ + {"name": "dual-pkg", "version": "1.0", "licenses": [{"license": {"id": "AGPL-3.0"}}, {"license": {"id": "MIT"}}]}, ] - report_path = _write_grant(tmp_path, _grant_payload(packages)) + sbom_path = _write_sbom(tmp_path, _sbom_payload(components)) out = BuildLicenseReport(output_dir=str(tmp_path)).process( - report_path, _remote_config(), "svc" + sbom_path, _remote_config(), "svc" ) with open(out) as fh: @@ -120,60 +101,58 @@ def test_process_dual_license_highest_risk_wins(tmp_path): def test_process_metadata_policy_used_is_deep_copy(tmp_path): - """Mutating the returned report must not affect the source remote_config.""" config = _remote_config() - report_path = _write_grant( + sbom_path = _write_sbom( tmp_path, - _grant_payload( - [{"name": "x", "version": "1", "licenses": [{"id": "MIT"}]}] - ), + _sbom_payload([{"name": "x", "version": "1", "licenses": [{"license": {"id": "MIT"}}]}]), ) out = BuildLicenseReport(output_dir=str(tmp_path)).process( - report_path, config, "svc" + sbom_path, config, "svc" ) with open(out) as fh: report = json.load(fh) report["metadata"]["policy_used"]["fail"].append("MUTATED") - assert config["GRANT"]["LICENSE_POLICY"]["fail"] == ["AGPL-*"] + assert config["LICENSE"]["LICENSE_POLICY"]["fail"] == ["AGPL-*"] + def test_process_returns_none_when_remote_config_missing(tmp_path): - report_path = _write_grant(tmp_path, _grant_payload([])) + sbom_path = _write_sbom(tmp_path, _sbom_payload([])) assert ( BuildLicenseReport(output_dir=str(tmp_path)).process( - report_path, {}, "svc" + sbom_path, {}, "svc" ) is None ) -def test_process_returns_none_when_grant_report_missing(tmp_path): +def test_process_returns_none_when_sbom_missing(tmp_path): out = BuildLicenseReport(output_dir=str(tmp_path)).process( str(tmp_path / "does_not_exist.json"), _remote_config(), "svc" ) assert out is None -def test_process_returns_none_when_grant_report_path_is_empty(tmp_path): +def test_process_returns_none_when_sbom_path_is_empty(tmp_path): out = BuildLicenseReport(output_dir=str(tmp_path)).process( "", _remote_config(), "svc" ) assert out is None -def test_process_returns_none_when_grant_report_invalid_json(tmp_path): +def test_process_returns_none_when_sbom_invalid_json(tmp_path): bad = tmp_path / "bad.json" - bad.write_text("not-json{{{") + bad.write_text("not-json{{{" ) out = BuildLicenseReport(output_dir=str(tmp_path)).process( str(bad), _remote_config(), "svc" ) assert out is None -def test_process_handles_empty_targets(tmp_path): - report_path = _write_grant(tmp_path, {"run": {"targets": []}}) +def test_process_handles_empty_components(tmp_path): + sbom_path = _write_sbom(tmp_path, {"components": []}) out = BuildLicenseReport(output_dir=str(tmp_path)).process( - report_path, _remote_config(), "svc" + sbom_path, _remote_config(), "svc" ) with open(out) as fh: report = json.load(fh) @@ -181,58 +160,12 @@ def test_process_handles_empty_targets(tmp_path): assert report["metadata"]["summary"]["total_dependencies"] == 0 -def test_process_falls_back_to_results_key(tmp_path): - payload = { - "results": [ - { - "source": {"ref": "x.json"}, - "evaluation": { - "findings": { - "packages": [ - {"name": "p", "version": "1", "licenses": [{"id": "MIT"}]} - ] - } - }, - } - ] - } - report_path = _write_grant(tmp_path, payload) - out = BuildLicenseReport(output_dir=str(tmp_path)).process( - report_path, _remote_config(), "svc" - ) - with open(out) as fh: - report = json.load(fh) - assert len(report["dependencies"]) == 1 - - -def test_process_returns_none_when_write_fails(tmp_path): - """If file writing fails, process returns None.""" - report_path = _write_grant( - tmp_path, - _grant_payload( - [{"name": "x", "version": "1", "licenses": [{"id": "MIT"}]}] - ), - ) - use_case = BuildLicenseReport(output_dir=str(tmp_path)) - with patch( - "devsecops_engine_tools.engine_sca.engine_license.src.domain.usecases.build_license_report.open", - side_effect=OSError("disk full"), - ): - pass - - with patch.object(use_case, "_write_report", return_value=None): - out = use_case.process(report_path, _remote_config(), "svc") - assert out is None - - def test_default_output_dir_is_cwd(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - report_path = _write_grant( + sbom_path = _write_sbom( tmp_path, - _grant_payload( - [{"name": "x", "version": "1", "licenses": [{"id": "MIT"}]}] - ), + _sbom_payload([{"name": "x", "version": "1", "licenses": [{"license": {"id": "MIT"}}]}]), ) - out = BuildLicenseReport().process(report_path, _remote_config(), "svc") + out = BuildLicenseReport().process(sbom_path, _remote_config(), "svc") assert out is not None assert os.path.dirname(out) == str(tmp_path) diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_license_policy.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_license_policy.py index 9767e9f6c..a55cd7b7d 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_license_policy.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_license_policy.py @@ -45,12 +45,12 @@ def test_looks_like_spdx_id(): def test_build_policy_returns_none_when_remote_config_missing(): assert build_policy_from_remote_config(None) is None assert build_policy_from_remote_config({}) is None - assert build_policy_from_remote_config({"GRANT": {}}) is None + assert build_policy_from_remote_config({"LICENSE": {}}) is None def test_build_policy_normalises_lists_and_actions(): raw = { - "GRANT": { + "LICENSE": { "LICENSE_POLICY": { "fail": ["AGPL-*"], "warn": ["BUSL-*"], @@ -69,7 +69,7 @@ def test_build_policy_normalises_lists_and_actions(): def test_build_policy_handles_non_list_fail_warn(): - raw = {"GRANT": {"LICENSE_POLICY": {"fail": "not-a-list", "warn": None}}} + raw = {"LICENSE": {"LICENSE_POLICY": {"fail": "not-a-list", "warn": None}}} policy = build_policy_from_remote_config(raw) assert policy["fail"] == [] assert policy["warn"] == [] diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/grant_tool/test_grant_manager_scan.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/grant_tool/test_grant_manager_scan.py deleted file mode 100644 index 1341afcc1..000000000 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/grant_tool/test_grant_manager_scan.py +++ /dev/null @@ -1,205 +0,0 @@ -from unittest.mock import patch, MagicMock - -from devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.driven_adapters.grant_tool.grant_manager_scan import ( - GrantScan, -) - - -def _base_args(**overrides): - args = { - "remote_config_repo": "/tmp/does-not-exist", - "remote_config_branch": "", - } - args.update(overrides) - return args - - -def test_map_platform_linux_arm64(): - scan = GrantScan() - assert scan._map_platform("Linux", "aarch64") == ("linux", "arm64") - assert scan._map_platform("Darwin", "arm64") == ("darwin", "arm64") - assert scan._map_platform("Darwin", "x86_64") == ("darwin", "amd64") - - -def test_map_platform_unsupported(): - scan = GrantScan() - assert scan._map_platform("Plan9", "riscv") == (None, None) - - -@patch( - "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.driven_adapters.grant_tool.grant_manager_scan.platform" -) -@patch( - "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.driven_adapters.grant_tool.grant_manager_scan.shutil.which" -) -def test_resolve_binary_windows_rejected(mock_which, mock_platform): - mock_which.return_value = None - mock_platform.system.return_value = "Windows" - mock_platform.machine.return_value = "AMD64" - scan = GrantScan() - assert scan._resolve_binary("0.2.5") is None - - -@patch( - "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.driven_adapters.grant_tool.grant_manager_scan.shutil.which" -) -def test_resolve_binary_from_path(mock_which): - mock_which.return_value = "/usr/local/bin/grant" - scan = GrantScan() - assert scan._resolve_binary("0.2.5") == "/usr/local/bin/grant" - - -@patch("os.path.exists") -def test_resolve_scan_target_priority(mock_exists): - mock_exists.return_value = True - scan = GrantScan() - assert scan._resolve_scan_target("sbom.json", "img:tag", "/tmp/x") == "sbom.json" - assert scan._resolve_scan_target(None, "img:tag", "/tmp/x") == "img:tag" - assert scan._resolve_scan_target(None, None, "/tmp/x") == "/tmp/x" - - -@patch("os.path.exists") -def test_resolve_scan_target_none(mock_exists): - mock_exists.return_value = False - scan = GrantScan() - assert scan._resolve_scan_target(None, None, "/missing") is None - - -@patch( - "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.driven_adapters.grant_tool.grant_manager_scan.os.path.getsize", - return_value=42, -) -@patch( - "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.driven_adapters.grant_tool.grant_manager_scan.os.path.exists", - return_value=True, -) -@patch( - "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.driven_adapters.grant_tool.grant_manager_scan.subprocess.run" -) -def test_run_grant_writes_output(mock_run, mock_exists, mock_getsize): - mock_run.return_value = MagicMock(stdout="", stderr="", returncode=0) - scan = GrantScan() - result = scan._run_grant( - "grant", "img:tag", "svc", "json", True, False - ) - assert result == "svc_grant.json" - mock_run.assert_called_once() - cmd = mock_run.call_args[0][0] - assert "list" in cmd and "img:tag" in cmd and "--quiet" in cmd - assert "-f" in cmd and "svc_grant.json" in cmd - # We must NOT pass policy / non-spdx / osi-approved any more. - assert "-c" not in cmd - assert "--non-spdx" not in cmd - assert "--osi-approved" not in cmd - - -@patch( - "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.driven_adapters.grant_tool.grant_manager_scan.os.path.exists", - return_value=False, -) -@patch( - "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.driven_adapters.grant_tool.grant_manager_scan.subprocess.run" -) -def test_run_grant_no_output(mock_run, mock_exists): - mock_run.return_value = MagicMock(stdout="", stderr="boom", returncode=2) - scan = GrantScan() - assert scan._run_grant("grant", "/x", "svc", "json", False, False) is None - - -@patch.object(GrantScan, "_run_grant", return_value="report.json") -@patch.object(GrantScan, "_resolve_binary", return_value="grant") -def test_run_tool_license_sca_happy_path(mock_bin, mock_run): - scan = GrantScan() - out = scan.run_tool_license_sca( - {"GRANT": {"GRANT_VERSION": "0.2.5"}}, - _base_args(image_to_scan="img:tag"), - {}, - "svc", - to_scan="/tmp/x", - sbom_path=None, - image_to_scan="img:tag", - secret_tool=None, - ) - assert out == "report.json" - - -@patch.object(GrantScan, "_resolve_binary", return_value=None) -def test_run_tool_license_sca_no_binary(mock_bin): - scan = GrantScan() - out = scan.run_tool_license_sca( - {}, _base_args(), {}, "svc", "/tmp", None, None, None - ) - assert out is None - - -def test_get_license_context_from_results_success(tmp_path): - import json - - license_data = { - "metadata": {"pipeline_name": "svc"}, - "dependencies": [ - { - "name": "lodash", - "version": "4.17.21", - "licenses": ["MIT"], - "policy_applied": "ok", - "policy_reason": "compliant SPDX license", - "policy_pattern_matched": None, - }, - { - "name": "ngrx", - "version": "1.0.0", - "licenses": ["AGPL-3.0"], - "policy_applied": "fail", - "policy_reason": "matches FAIL pattern 'AGPL-*'", - "policy_pattern_matched": "AGPL-*", - }, - { - "name": "biz-lib", - "version": "2.0.0", - "licenses": ["BUSL-1.1"], - "policy_applied": "warn", - "policy_reason": "matches WARN pattern 'BUSL-*'", - "policy_pattern_matched": "BUSL-*", - }, - { - "name": "jakarta.servlet-api", - "version": "6.1.0", - "licenses": ["EPL-2.0", "GPL-2.0-with-classpath-exception"], - "policy_applied": "warn", - "policy_reason": "matches WARN pattern 'EPL-*'", - "policy_pattern_matched": "EPL-*", - }, - { - "name": "no-lic", - "version": "0.1.0", - "licenses": [], - "policy_applied": "unlicensed", - "policy_reason": "no license detected", - "policy_pattern_matched": None, - }, - { - "name": "weird-pkg", - "version": "0.0.1", - "licenses": ["Custom License"], - "policy_applied": "unknown", - "policy_reason": "non-SPDX license label", - "policy_pattern_matched": None, - }, - ], - } - path = tmp_path / "svc_LICENSE.json" - path.write_text(json.dumps(license_data)) - - scan = GrantScan() - result = scan.get_license_context_from_results(str(path)) - - # Only fail and warn appear in context - assert len(result) == 3 - assert result[0].name == "ngrx" - assert result[0].severity == "critical" - assert result[1].name == "biz-lib" - assert result[1].severity == "medium" - assert result[2].name == "jakarta.servlet-api" - assert result[2].severity == "medium" - assert result[0].priority is None diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/grant_tool/__init__.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/license_scan/__init__.py similarity index 100% rename from tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/grant_tool/__init__.py rename to tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/license_scan/__init__.py diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/license_scan/test_license_scan_manager.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/license_scan/test_license_scan_manager.py new file mode 100644 index 000000000..eb9098fd7 --- /dev/null +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/license_scan/test_license_scan_manager.py @@ -0,0 +1,30 @@ +import json + +from devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.driven_adapters.license_scan.license_scan_manager import ( + LicenseScanManager, +) + + +def test_get_license_context_from_results_success(tmp_path): + license_data = { + "metadata": {"pipeline_name": "svc"}, + "dependencies": [ + {"name": "lodash", "version": "4.17.21", "licenses": ["MIT"], "policy_applied": "ok", "policy_reason": "compliant", "policy_pattern_matched": None}, + {"name": "ngrx", "version": "1.0.0", "licenses": ["AGPL-3.0"], "policy_applied": "fail", "policy_reason": "matches FAIL pattern", "policy_pattern_matched": "AGPL-*"}, + {"name": "jakarta.servlet-api", "version": "6.1.0", "licenses": ["EPL-2.0", "GPL-2.0"], "policy_applied": "warn", "policy_reason": "matches WARN pattern", "policy_pattern_matched": "EPL-*"}, + {"name": "no-lic", "version": "0.1.0", "licenses": [], "policy_applied": "unlicensed", "policy_reason": "no license", "policy_pattern_matched": None}, + ], + } + path = tmp_path / "svc_LICENSE.json" + path.write_text(json.dumps(license_data)) + + manager = LicenseScanManager() + result = manager.get_license_context_from_results(str(path)) + + # Only fail and warn appear + assert len(result) == 2 + assert result[0].name == "ngrx" + assert result[0].severity == "critical" + assert result[1].name == "jakarta.servlet-api" + assert result[1].severity == "medium" + assert result[0].priority is None diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/entry_points/test_entry_point_tool.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/entry_points/test_entry_point_tool.py index f1f43733d..d6b3b36cb 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/entry_points/test_entry_point_tool.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/entry_points/test_entry_point_tool.py @@ -5,9 +5,8 @@ ) -def _make_devops_gateway(remote_cfg, pipeline_name="svc"): +def _make_devops_gateway(pipeline_name="svc"): gw = MagicMock() - gw.get_remote_config.return_value = remote_cfg gw.get_variable.side_effect = lambda name: { "pipeline_name": pipeline_name, "branch_tag": "main", @@ -15,16 +14,22 @@ def _make_devops_gateway(remote_cfg, pipeline_name="svc"): return gw +def _make_remote_gw(remote_cfg): + gw = MagicMock() + gw.get_remote_config.return_value = remote_cfg + return gw + + def _config_tool(): return { - "ENGINE_LICENSE": {"TOOL": "GRANT"}, + "ENGINE_LICENSE": {"ENABLED": True}, "SBOM_MANAGER": {"ENABLED": True}, } def _remote_config(): return { - "GRANT": { + "LICENSE": { "LICENSE_POLICY": { "fail": [], "warn": [], @@ -35,6 +40,7 @@ def _remote_config(): } } + @patch( "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.entry_points.entry_point_tool.BuildLicenseReport" ) @@ -45,36 +51,24 @@ def test_init_engine_license_happy_path(mock_exists, mock_builder): mock_exists.return_value = True mock_builder.return_value.process.return_value = "/abs/svc_LICENSE.json" - devops_gw = _make_devops_gateway(_remote_config()) - remote_gw = _make_devops_gateway(_remote_config()) - - tool_run = MagicMock() - tool_run.run_tool_license_sca.return_value = "svc_grant.json" - + devops_gw = _make_devops_gateway() + remote_gw = _make_remote_gw(_remote_config()) tool_sbom = MagicMock() tool_sbom.get_components.return_value = ["c1", "c2"] license_path, sbom_components = init_engine_license( - tool_run, devops_gw, remote_gw, {"remote_config_repo": "r", "remote_config_branch": ""}, - None, _config_tool(), tool_sbom, ) assert license_path == "/abs/svc_LICENSE.json" assert sbom_components == ["c1", "c2"] - tool_sbom.get_components.assert_called_once() - tool_run.run_tool_license_sca.assert_called_once() - args, _ = tool_run.run_tool_license_sca.call_args - assert args[5] == "svc_SBOM.json" # sbom_path - assert args[7] is None # image_to_scan - mock_builder.return_value.process.assert_called_once_with( - "svc_grant.json", _remote_config(), "svc" + "svc_SBOM.json", _remote_config(), "svc" ) @@ -83,21 +77,14 @@ def test_init_engine_license_happy_path(mock_exists, mock_builder): return_value=False, ) def test_init_engine_license_returns_none_when_to_scan_missing(mock_exists): - devops_gw = _make_devops_gateway(_remote_config()) - remote_gw = _make_devops_gateway(_remote_config()) - tool_run = MagicMock() + devops_gw = _make_devops_gateway() + remote_gw = _make_remote_gw(_remote_config()) tool_sbom = MagicMock() license_path, sbom_components = init_engine_license( - tool_run, devops_gw, remote_gw, - { - "remote_config_repo": "r", - "remote_config_branch": "", - "folder_path": "/missing/path", - }, - None, + {"remote_config_repo": "r", "remote_config_branch": "", "folder_path": "/missing"}, _config_tool(), tool_sbom, ) @@ -110,29 +97,23 @@ def test_init_engine_license_returns_none_when_to_scan_missing(mock_exists): @patch( "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.entry_points.entry_point_tool.os.path.exists" ) -def test_init_engine_license_returns_none_when_sbom_missing_after_generation( - mock_exists, -): +def test_init_engine_license_returns_none_when_sbom_missing_after_generation(mock_exists): mock_exists.side_effect = [True, False] - devops_gw = _make_devops_gateway(_remote_config()) - remote_gw = _make_devops_gateway(_remote_config()) - tool_run = MagicMock() + devops_gw = _make_devops_gateway() + remote_gw = _make_remote_gw(_remote_config()) tool_sbom = MagicMock() tool_sbom.get_components.return_value = ["c"] license_path, sbom_components = init_engine_license( - tool_run, devops_gw, remote_gw, {"remote_config_repo": "r", "remote_config_branch": ""}, - None, _config_tool(), tool_sbom, ) assert license_path is None assert sbom_components == ["c"] - tool_run.run_tool_license_sca.assert_not_called() @patch( @@ -140,49 +121,19 @@ def test_init_engine_license_returns_none_when_sbom_missing_after_generation( return_value=True, ) def test_init_engine_license_returns_none_when_tool_sbom_missing(mock_exists): - devops_gw = _make_devops_gateway(_remote_config()) - remote_gw = _make_devops_gateway(_remote_config()) - tool_run = MagicMock() + devops_gw = _make_devops_gateway() + remote_gw = _make_remote_gw(_remote_config()) license_path, sbom_components = init_engine_license( - tool_run, devops_gw, remote_gw, {"remote_config_repo": "r", "remote_config_branch": ""}, - None, _config_tool(), None, ) assert license_path is None assert sbom_components is None - tool_run.run_tool_license_sca.assert_not_called() - - -@patch( - "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.entry_points.entry_point_tool.os.path.exists", - return_value=True, -) -def test_init_engine_license_returns_none_when_grant_fails(mock_exists): - devops_gw = _make_devops_gateway(_remote_config()) - remote_gw = _make_devops_gateway(_remote_config()) - tool_run = MagicMock() - tool_run.run_tool_license_sca.return_value = None - tool_sbom = MagicMock() - tool_sbom.get_components.return_value = ["c"] - - license_path, sbom_components = init_engine_license( - tool_run, - devops_gw, - remote_gw, - {"remote_config_repo": "r", "remote_config_branch": ""}, - None, - _config_tool(), - tool_sbom, - ) - - assert license_path is None - assert sbom_components == ["c"] @patch( @@ -192,23 +143,17 @@ def test_init_engine_license_returns_none_when_grant_fails(mock_exists): "devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.entry_points.entry_point_tool.os.path.exists", return_value=True, ) -def test_init_engine_license_returns_none_when_build_report_fails( - mock_exists, mock_builder -): +def test_init_engine_license_returns_none_when_build_report_fails(mock_exists, mock_builder): mock_builder.return_value.process.return_value = None - devops_gw = _make_devops_gateway(_remote_config()) - remote_gw = _make_devops_gateway(_remote_config()) - tool_run = MagicMock() - tool_run.run_tool_license_sca.return_value = "grant.json" + devops_gw = _make_devops_gateway() + remote_gw = _make_remote_gw(_remote_config()) tool_sbom = MagicMock() tool_sbom.get_components.return_value = ["c"] license_path, sbom_components = init_engine_license( - tool_run, devops_gw, remote_gw, {"remote_config_repo": "r", "remote_config_branch": ""}, - None, _config_tool(), tool_sbom, ) From 049f9c09d69a99488fe6e875b7109a6b6f058fb9 Mon Sep 17 00:00:00 2001 From: yenner123 <> Date: Mon, 6 Jul 2026 11:36:03 -0500 Subject: [PATCH 7/8] feat(engine_license): fix PR observations for threshold/config mapping and context extraction --- .../docs/life_cycle/getting_started.md | 2 +- .../docs/modules/engine_sca/engine_license.md | 13 ------ .../engine_sca/engine_license/ConfigTool.json | 14 ++++++- .../context_extraction_manager.py | 2 +- .../src/applications/runner_license_scan.py | 19 ++++----- .../domain/usecases/build_license_report.py | 1 + .../src/domain/usecases/license_policy.py | 26 +++++++----- .../license_scan/license_scan_manager.py | 9 ++--- .../entry_points/entry_point_tool.py | 14 ++++--- .../applications/test_runner_license_scan.py | 15 +++---- .../usecases/test_build_license_report.py | 4 ++ .../domain/usecases/test_license_policy.py | 40 +++++++++++++++---- .../license_scan/test_license_scan_manager.py | 8 ++-- .../entry_points/test_entry_point_tool.py | 15 ++++--- 14 files changed, 112 insertions(+), 70 deletions(-) diff --git a/docs/Docusaurus/docs/life_cycle/getting_started.md b/docs/Docusaurus/docs/life_cycle/getting_started.md index ee0ffe545..d21a70cef 100644 --- a/docs/Docusaurus/docs/life_cycle/getting_started.md +++ b/docs/Docusaurus/docs/life_cycle/getting_started.md @@ -106,7 +106,7 @@ For more information about structure remote config visit [Structure Remote Confi ### Scan running - (CLI) - Flags ```bash -devsecops-engine-tools --platform_devops ["local","azure","github"] --remote_config_source ["local","azure","github"] --remote_config_repo ["remote_config_repo"] --remote_config_branch ["remote_config_branch"] --module ["engine_iac", "engine_dast", "engine_secret", "engine_dependencies", "engine_license", "engine_container", "engine_risk", "engine_code", "engine_function"] --tool ["nuclei", "bearer", "checkov", "kics", "kubescape", "trufflehog", "gitleaks", "prisma", "trivy", "xray", "dependency_check", "cdxgen"] --folder_path ["Folder path scan engine_iac, engine_code, engine_dependencies and engine_secret"] --platform ["k8s","cloudformation","docker", "openapi", "terraform"] --use_secrets_manager ["false", "true"] --use_vulnerability_management ["false", "true"] --send_metrics ["false", "true"] --token_cmdb ["token_cmdb"] --token_vulnerability_management ["token_vulnerability_management"] --token_engine_container ["token_engine_container"] --token_engine_dependencies ["token_engine_dependencies"] --token_external_checks ["token_external_checks"] --xray_mode ["scan", "audit","build-scan"] --image_to_scan ["image_to_scan"] --dast_file_path ["dast_file_path"] --context ["false", "true"] --terraform_repo_root ["terraform_files_repo"] --docker_address ["docker addres to engine_container with Prisma tool"] +devsecops-engine-tools --platform_devops ["local","azure","github"] --remote_config_source ["local","azure","github"] --remote_config_repo ["remote_config_repo"] --remote_config_branch ["remote_config_branch"] --module ["engine_iac", "engine_dast", "engine_secret", "engine_dependencies", "engine_license", "engine_container", "engine_risk", "engine_code", "engine_function"] --tool ["nuclei", "bearer", "checkov", "kics", "kubescape", "trufflehog", "gitleaks", "prisma", "trivy", "xray", "dependency_check", "cdxgen"] --folder_path ["Folder path scan engine_iac, engine_code, engine_dependencies, engine_secret and engine_license"] --platform ["k8s","cloudformation","docker", "openapi", "terraform"] --use_secrets_manager ["false", "true"] --use_vulnerability_management ["false", "true"] --send_metrics ["false", "true"] --token_cmdb ["token_cmdb"] --token_vulnerability_management ["token_vulnerability_management"] --token_engine_container ["token_engine_container"] --token_engine_dependencies ["token_engine_dependencies"] --token_external_checks ["token_external_checks"] --xray_mode ["scan", "audit","build-scan"] --image_to_scan ["image_to_scan"] --dast_file_path ["dast_file_path"] --context ["false", "true"] --terraform_repo_root ["terraform_files_repo"] --docker_address ["docker addres to engine_container with Prisma tool"] ``` ### Scan running sample (CLI) - Local diff --git a/docs/Docusaurus/docs/modules/engine_sca/engine_license.md b/docs/Docusaurus/docs/modules/engine_sca/engine_license.md index f023b5d4a..ff6802716 100644 --- a/docs/Docusaurus/docs/modules/engine_sca/engine_license.md +++ b/docs/Docusaurus/docs/modules/engine_sca/engine_license.md @@ -14,19 +14,6 @@ Unlike other engines, `engine_license` does **not**: The intent is to ship an audit-friendly, policy-driven JSON that downstream consumers can analyse out-of-band of build pipelines. -## Flow - -```mermaid -flowchart TD - A[handle_scan: engine_license branch] --> B[runner_engine_license] - B --> C[init_engine_license entry_point_tool] - C --> D["CdxGen generates {pipeline}_SBOM.json"] - D --> E["BuildLicenseReport reads SBOM
applies LICENSE_POLICY"] - E --> F["Writes {pipeline}_LICENSE.json in CWD"] - F --> G[runner builds findings from LICENSE.json] - G --> H[handle_scan prints compliance table & context] -``` - ## Configuration Structure Only one configuration file is consumed: `engine_sca/engine_license/ConfigTool.json`. There is no `Exclusions.json`; the policy is declared inline. diff --git a/example_remote_config_local/engine_sca/engine_license/ConfigTool.json b/example_remote_config_local/engine_sca/engine_license/ConfigTool.json index fdb813207..a52c4cc81 100644 --- a/example_remote_config_local/engine_sca/engine_license/ConfigTool.json +++ b/example_remote_config_local/engine_sca/engine_license/ConfigTool.json @@ -6,7 +6,19 @@ "synonyms": { }, "unlicensed_action": "ignore", - "unknown_action": "ignore" + "unknown_action": "ignore", + "severity_mapping": { + "fail": "critical", + "warn": "medium", + "ok": "info", + "ignore": "info" + } + } + }, + "THRESHOLD": { + "VULNERABILITY": {}, + "COMPLIANCE": { + "Critical": 1 } } } diff --git a/tools/devsecops_engine_tools/engine_core/src/infrastructure/driven_adapters/context_extraction/context_extraction_manager.py b/tools/devsecops_engine_tools/engine_core/src/infrastructure/driven_adapters/context_extraction/context_extraction_manager.py index 199c39619..e30aad995 100644 --- a/tools/devsecops_engine_tools/engine_core/src/infrastructure/driven_adapters/context_extraction/context_extraction_manager.py +++ b/tools/devsecops_engine_tools/engine_core/src/infrastructure/driven_adapters/context_extraction/context_extraction_manager.py @@ -77,7 +77,7 @@ def extract_context( # Dependencies requires remote_config context_list = method(path_file_results, remote_config=remote_config, **kwargs) else: - # IaC and Container only need path_file_results + # IaC, Container, and License only need path_file_results context_list = method(path_file_results) if not context_list: diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/applications/runner_license_scan.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/applications/runner_license_scan.py index 8e5c20572..a7a5a775a 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/src/applications/runner_license_scan.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/src/applications/runner_license_scan.py @@ -10,11 +10,8 @@ init_engine_license, ) -_POLICY_TO_SEVERITY = { - "fail": "critical", - "warn": "medium", -} - +_RELEVANT_POLICIES = ("fail", "warn") +_DEFAULT_COMPLIANCE_THRESHOLD = {"Critical": 1} def _build_findings_from_license_json(license_json_path): if not license_json_path: @@ -24,7 +21,7 @@ def _build_findings_from_license_json(license_json_path): findings = [] for dep in data.get("dependencies", []): policy = dep.get("policy_applied", "") - if policy not in _POLICY_TO_SEVERITY: + if policy not in _RELEVANT_POLICIES: continue name = dep.get("name", "unknown") version = dep.get("version", "") @@ -35,7 +32,7 @@ def _build_findings_from_license_json(license_json_path): cvss="", where=f"{name}:{version}", description=f"License '{license_label}' for package '{name}' ({dep.get('policy_reason', '')}). ", - severity=_POLICY_TO_SEVERITY[policy], + severity=dep.get("severity", "info"), identification_date="", published_date_cve="", module="engine_license", @@ -63,7 +60,7 @@ def runner_engine_license( Tuple ''{findings_list, input_core, sbom_components}''. """ try: - license_json_path, sbom_components = init_engine_license( + license_json_path, sbom_components, remote_config = init_engine_license( devops_platform_gateway, remote_config_source_gateway, dict_args, @@ -74,9 +71,13 @@ def runner_engine_license( pipeline_name = devops_platform_gateway.get_variable("pipeline_name") findings_list = _build_findings_from_license_json(license_json_path) + threshold_data = (remote_config or {}).get("THRESHOLD", {}) input_core = InputCore( totalized_exclusions=[], - threshold_defined=Threshold({"VULNERABILITY": {}, "COMPLIANCE": {}}), + threshold_defined=Threshold({ + "VULNERABILITY": threshold_data.get("VULNERABILITY", {}), + "COMPLIANCE": threshold_data.get("COMPLIANCE", _DEFAULT_COMPLIANCE_THRESHOLD), + }), path_file_results=license_json_path, custom_message_break_build="License scan completed", scope_pipeline=pipeline_name, diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/build_license_report.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/build_license_report.py index 15e8fe77c..e554f523a 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/build_license_report.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/build_license_report.py @@ -97,6 +97,7 @@ def _build_dependencies(self, data, policy): "pattern_matched" ], "license_matched": classification["label"], + "severity": classification["severity"], } ) return dependencies diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/license_policy.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/license_policy.py index 31080f6c7..5243aae7d 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/license_policy.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/license_policy.py @@ -19,16 +19,14 @@ TOOL = "LICENSE" -SEVERITY_BY_ACTION = { +_DEFAULT_SEVERITY_BY_ACTION = { "fail": "critical", "warn": "medium", "ok": "info", "ignore": "info", } -ACTION_TO_SEVERITY = SEVERITY_BY_ACTION - -_VALID_ACTIONS = {"fail", "warn", "info", "ignore"} +__DEFAULT_VALID_ACTIONS = {"fail", "warn", "info", "ignore"} def get_value(obj, *keys, default=None): @@ -44,7 +42,7 @@ def get_value(obj, *keys, default=None): def validate_action(value, default): """Return value (lowercased) if it is a valid action, else default.""" v = str(value).lower().strip() - return v if v in _VALID_ACTIONS else default + return v if v in __DEFAULT_VALID_ACTIONS else default def looks_like_spdx_id(label): @@ -84,6 +82,13 @@ def build_policy_from_remote_config(remote_config): for k, v in (synonyms_raw.items() if isinstance(synonyms_raw, dict) else []) } + severity_mapping = dict(_DEFAULT_SEVERITY_BY_ACTION) + severity_mapping_raw = override.get("severity_mapping", {}) + if isinstance(severity_mapping_raw, dict): + severity_mapping.update( + {str(k): str(v) for k, v in severity_mapping_raw.items()} + ) + return { "fail": fail_list, "warn": warn_list, @@ -94,6 +99,7 @@ def build_policy_from_remote_config(remote_config): "unknown_action": validate_action( override.get("unknown_action", "ignore"), "ignore" ), + "severity_mapping": severity_mapping, } @@ -181,7 +187,7 @@ def classify_package(licenses, policy): "licenses": [], "reason": "no policy available", "pattern_matched": None, - "severity": SEVERITY_BY_ACTION["ignore"], + "severity": _DEFAULT_SEVERITY_BY_ACTION["ignore"], } if not licenses: @@ -192,7 +198,7 @@ def classify_package(licenses, policy): "licenses": [], "reason": "no license detected", "pattern_matched": None, - "severity": SEVERITY_BY_ACTION.get(action, "info"), + "severity": policy["severity_mapping"].get(action, "info"), } normalized_labels = [] @@ -216,11 +222,11 @@ def _key(item): bucket = result["bucket"] if bucket == "unknown": - severity = SEVERITY_BY_ACTION.get(policy["unknown_action"], "info") + severity = policy["severity_mapping"].get(policy["unknown_action"], "info") elif bucket == "ok": - severity = SEVERITY_BY_ACTION["ok"] + severity = policy["severity_mapping"].get("ok", "info") else: - severity = SEVERITY_BY_ACTION.get(bucket, "info") + severity = policy["severity_mapping"].get(bucket, "info") return { "policy_applied": bucket, diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/license_scan/license_scan_manager.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/license_scan/license_scan_manager.py index cb090721d..16a32fd47 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/license_scan/license_scan_manager.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/driven_adapters/license_scan/license_scan_manager.py @@ -5,10 +5,7 @@ ContextLicense, ) -_POLICY_TO_SEVERITY = { - "fail": "critical", - "warn": "medium", -} +_RELEVANT_POLICIES = ("fail", "warn") class LicenseScanManager: @@ -21,7 +18,7 @@ def get_license_context_from_results(self, path_file_results) -> List[ContextLic context_list = [] for dep in data.get("dependencies", []): policy = dep.get("policy_applied", "unknown") - if policy not in _POLICY_TO_SEVERITY: + if policy not in _RELEVANT_POLICIES: continue context_list.append( ContextLicense( @@ -31,7 +28,7 @@ def get_license_context_from_results(self, path_file_results) -> List[ContextLic policy_applied=policy, policy_reason=dep.get("policy_reason", ""), policy_pattern_matched=dep.get("policy_pattern_matched", "") or "", - severity=_POLICY_TO_SEVERITY[policy], + severity=dep.get("severity", "info"), ) ) return context_list diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/entry_points/entry_point_tool.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/entry_points/entry_point_tool.py index b7e449d40..8704e9138 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/entry_points/entry_point_tool.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/entry_points/entry_point_tool.py @@ -35,8 +35,10 @@ def init_engine_license( ): """Run the standalone engine_license flow. - Returns a tuple ``(license_json_path, sbom_components)``. Either or - both elements may be ``None`` if the corresponding step failed. + Returns a tuple ``(license_json_path, sbom_components, remote_config)``. + ``license_json_path``/``sbom_components`` may be ``None`` if the + corresponding step failed. ``remote_config`` is always the module's + remote config (used e.g. to build the compliance threshold). """ remote_config = remote_config_source_gateway.get_remote_config( dict_args["remote_config_repo"], @@ -49,21 +51,21 @@ def init_engine_license( if not os.path.exists(to_scan): logger.error(f"Path {to_scan} does not exist; aborting license scan.") - return None, None + return None, None, remote_config config_sbom = config_tool.get("SBOM_MANAGER", {}) or {} if tool_sbom is None: logger.error("SBOM tool gateway is not configured; aborting license scan.") - return None, None + return None, None, remote_config sbom_components = tool_sbom.get_components(to_scan, config_sbom, pipeline_name) sbom_path = f"{pipeline_name}_SBOM.json" if not os.path.exists(sbom_path): logger.error( f"SBOM file {sbom_path} not found after generation; aborting license scan." ) - return None, sbom_components + return None, sbom_components, remote_config license_json_path = BuildLicenseReport().process( sbom_path, remote_config, pipeline_name ) - return license_json_path, sbom_components + return license_json_path, sbom_components, remote_config diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/applications/test_runner_license_scan.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/applications/test_runner_license_scan.py index 6a842f979..618b602bd 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/test/applications/test_runner_license_scan.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/test/applications/test_runner_license_scan.py @@ -22,7 +22,7 @@ def test_runner_engine_license_returns_findings_input_core_and_components(): ) as mock_init, patch( "devsecops_engine_tools.engine_sca.engine_license.src.applications.runner_license_scan._build_findings_from_license_json" ) as mock_findings: - mock_init.return_value = ("/abs/svc_LICENSE.json", ["c1"]) + mock_init.return_value = ("/abs/svc_LICENSE.json", ["c1"], {"THRESHOLD": {"COMPLIANCE": {"Critical": 2}}}) mock_findings.return_value = [] config_tool = {"ENGINE_LICENSE": {"ENABLED": True}} @@ -52,7 +52,7 @@ def test_runner_engine_license_returns_findings_input_core_and_components(): assert input_core.threshold_defined is not None assert input_core.threshold_defined.vulnerability.critical is None - assert input_core.threshold_defined.compliance.critical is None + assert input_core.threshold_defined.compliance.critical == 2 def test_runner_engine_license_propagates_none_path(): @@ -61,7 +61,7 @@ def test_runner_engine_license_propagates_none_path(): ) as mock_init, patch( "devsecops_engine_tools.engine_sca.engine_license.src.applications.runner_license_scan._build_findings_from_license_json" ) as mock_findings: - mock_init.return_value = (None, None) + mock_init.return_value = (None, None, {}) mock_findings.return_value = [] config_tool = {"ENGINE_LICENSE": {"ENABLED": True}} devops_gw = _make_devops_gateway() @@ -79,6 +79,7 @@ def test_runner_engine_license_propagates_none_path(): assert sbom_components is None assert input_core.path_file_results is None assert input_core.scope_pipeline == "svc" + assert input_core.threshold_defined.compliance.critical == 1 def test_runner_license_main_block(): @@ -100,10 +101,10 @@ def test_build_findings_from_license_json(tmp_path): license_data = { "metadata": {"pipeline_name": "svc"}, "dependencies": [ - {"name": "lodash", "version": "4.17.21", "licenses": ["MIT"], "policy_applied": "ok", "policy_reason": "compliant", "policy_pattern_matched": None, "license_matched": "MIT"}, - {"name": "ngrx", "version": "1.0.0", "licenses": ["AGPL-3.0"], "policy_applied": "fail", "policy_reason": "matches FAIL pattern 'AGPL-*'", "policy_pattern_matched": "AGPL-*", "license_matched": "AGPL-3.0"}, - {"name": "biz-lib", "version": "2.0.0", "licenses": ["Apache-2.0", "EPL-2.0"], "policy_applied": "warn", "policy_reason": "matches WARN pattern 'EPL-*'", "policy_pattern_matched": "EPL-*", "license_matched": "EPL-2.0"}, - {"name": "no-lic", "version": "0.1.0", "licenses": [], "policy_applied": "unlicensed", "policy_reason": "no license detected", "policy_pattern_matched": None, "license_matched": "UNLICENSED"}, + {"name": "lodash", "version": "4.17.21", "licenses": ["MIT"], "policy_applied": "ok", "policy_reason": "compliant", "policy_pattern_matched": None, "license_matched": "MIT", "severity": "info"}, + {"name": "ngrx", "version": "1.0.0", "licenses": ["AGPL-3.0"], "policy_applied": "fail", "policy_reason": "matches FAIL pattern 'AGPL-*'", "policy_pattern_matched": "AGPL-*", "license_matched": "AGPL-3.0", "severity": "critical"}, + {"name": "biz-lib", "version": "2.0.0", "licenses": ["Apache-2.0", "EPL-2.0"], "policy_applied": "warn", "policy_reason": "matches WARN pattern 'EPL-*'", "policy_pattern_matched": "EPL-*", "license_matched": "EPL-2.0", "severity": "medium"}, + {"name": "no-lic", "version": "0.1.0", "licenses": [], "policy_applied": "unlicensed", "policy_reason": "no license detected", "policy_pattern_matched": None, "license_matched": "UNLICENSED", "severity": "info"}, ], } path = tmp_path / "svc_LICENSE.json" diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_build_license_report.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_build_license_report.py index b311c40cc..0dab7f5d5 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_build_license_report.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_build_license_report.py @@ -77,6 +77,10 @@ def test_process_writes_license_json_with_mixed_classifications(tmp_path): assert ngrx["policy_applied"] == "fail" assert ngrx["policy_pattern_matched"] == "AGPL-*" assert "AGPL-*" in ngrx["policy_reason"] + assert ngrx["severity"] == "critical" + + biz_lib = next(d for d in deps if d["name"] == "biz-lib") + assert biz_lib["severity"] == "medium" no_lic = next(d for d in deps if d["name"] == "no-lic-pkg") assert no_lic["policy_applied"] == "unlicensed" diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_license_policy.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_license_policy.py index a55cd7b7d..faf432bd8 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_license_policy.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_license_policy.py @@ -4,10 +4,10 @@ get_value, looks_like_spdx_id, validate_action, - ACTION_TO_SEVERITY, - SEVERITY_BY_ACTION, + _DEFAULT_SEVERITY_BY_ACTION, ) + def _policy(**overrides): """Helper: build a default-flavoured policy and apply overrides.""" base = { @@ -16,6 +16,7 @@ def _policy(**overrides): "synonyms": {}, "unlicensed_action": "ignore", "unknown_action": "ignore", + "severity_mapping": dict(_DEFAULT_SEVERITY_BY_ACTION), } base.update(overrides) return base @@ -74,6 +75,31 @@ def test_build_policy_handles_non_list_fail_warn(): assert policy["fail"] == [] assert policy["warn"] == [] + +def test_build_policy_default_severity_mapping(): + raw = {"LICENSE": {"LICENSE_POLICY": {"fail": [], "warn": []}}} + policy = build_policy_from_remote_config(raw) + assert policy["severity_mapping"] == _DEFAULT_SEVERITY_BY_ACTION + + +def test_build_policy_severity_mapping_override(): + raw = { + "LICENSE": { + "LICENSE_POLICY": { + "fail": ["AGPL-*"], + "warn": [], + "severity_mapping": {"fail": "high"}, + } + } + } + policy = build_policy_from_remote_config(raw) + assert policy["severity_mapping"]["fail"] == "high" + assert policy["severity_mapping"]["warn"] == _DEFAULT_SEVERITY_BY_ACTION["warn"] + + result = classify_package([{"id": "AGPL-3.0"}], policy) + assert result["policy_applied"] == "fail" + assert result["severity"] == "high" + def test_classify_package_compliant_spdx(): result = classify_package([{"id": "MIT"}], _policy()) assert result["policy_applied"] == "ok" @@ -156,10 +182,10 @@ def test_classify_package_with_none_policy(): result = classify_package([{"id": "MIT"}], None) assert result["policy_applied"] == "unknown" assert result["label"] == "UNKNOWN" + assert result["severity"] == "info" -def test_action_to_severity_mapping_exposed(): - assert ACTION_TO_SEVERITY["fail"] == "critical" - assert ACTION_TO_SEVERITY["warn"] == "medium" - assert ACTION_TO_SEVERITY["ok"] == "info" - assert SEVERITY_BY_ACTION is ACTION_TO_SEVERITY # alias +def test_severity_by_action_defaults(): + assert _DEFAULT_SEVERITY_BY_ACTION["fail"] == "critical" + assert _DEFAULT_SEVERITY_BY_ACTION["warn"] == "medium" + assert _DEFAULT_SEVERITY_BY_ACTION["ok"] == "info" diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/license_scan/test_license_scan_manager.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/license_scan/test_license_scan_manager.py index eb9098fd7..1ab769b5e 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/license_scan/test_license_scan_manager.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/driven_adapters/license_scan/test_license_scan_manager.py @@ -9,10 +9,10 @@ def test_get_license_context_from_results_success(tmp_path): license_data = { "metadata": {"pipeline_name": "svc"}, "dependencies": [ - {"name": "lodash", "version": "4.17.21", "licenses": ["MIT"], "policy_applied": "ok", "policy_reason": "compliant", "policy_pattern_matched": None}, - {"name": "ngrx", "version": "1.0.0", "licenses": ["AGPL-3.0"], "policy_applied": "fail", "policy_reason": "matches FAIL pattern", "policy_pattern_matched": "AGPL-*"}, - {"name": "jakarta.servlet-api", "version": "6.1.0", "licenses": ["EPL-2.0", "GPL-2.0"], "policy_applied": "warn", "policy_reason": "matches WARN pattern", "policy_pattern_matched": "EPL-*"}, - {"name": "no-lic", "version": "0.1.0", "licenses": [], "policy_applied": "unlicensed", "policy_reason": "no license", "policy_pattern_matched": None}, + {"name": "lodash", "version": "4.17.21", "licenses": ["MIT"], "policy_applied": "ok", "policy_reason": "compliant", "policy_pattern_matched": None, "severity": "info"}, + {"name": "ngrx", "version": "1.0.0", "licenses": ["AGPL-3.0"], "policy_applied": "fail", "policy_reason": "matches FAIL pattern", "policy_pattern_matched": "AGPL-*", "severity": "critical"}, + {"name": "jakarta.servlet-api", "version": "6.1.0", "licenses": ["EPL-2.0", "GPL-2.0"], "policy_applied": "warn", "policy_reason": "matches WARN pattern", "policy_pattern_matched": "EPL-*", "severity": "medium"}, + {"name": "no-lic", "version": "0.1.0", "licenses": [], "policy_applied": "unlicensed", "policy_reason": "no license", "policy_pattern_matched": None, "severity": "info"}, ], } path = tmp_path / "svc_LICENSE.json" diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/entry_points/test_entry_point_tool.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/entry_points/test_entry_point_tool.py index d6b3b36cb..0b6524772 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/entry_points/test_entry_point_tool.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/entry_points/test_entry_point_tool.py @@ -56,7 +56,7 @@ def test_init_engine_license_happy_path(mock_exists, mock_builder): tool_sbom = MagicMock() tool_sbom.get_components.return_value = ["c1", "c2"] - license_path, sbom_components = init_engine_license( + license_path, sbom_components, remote_config = init_engine_license( devops_gw, remote_gw, {"remote_config_repo": "r", "remote_config_branch": ""}, @@ -66,6 +66,7 @@ def test_init_engine_license_happy_path(mock_exists, mock_builder): assert license_path == "/abs/svc_LICENSE.json" assert sbom_components == ["c1", "c2"] + assert remote_config == _remote_config() tool_sbom.get_components.assert_called_once() mock_builder.return_value.process.assert_called_once_with( "svc_SBOM.json", _remote_config(), "svc" @@ -81,7 +82,7 @@ def test_init_engine_license_returns_none_when_to_scan_missing(mock_exists): remote_gw = _make_remote_gw(_remote_config()) tool_sbom = MagicMock() - license_path, sbom_components = init_engine_license( + license_path, sbom_components, remote_config = init_engine_license( devops_gw, remote_gw, {"remote_config_repo": "r", "remote_config_branch": "", "folder_path": "/missing"}, @@ -91,6 +92,7 @@ def test_init_engine_license_returns_none_when_to_scan_missing(mock_exists): assert license_path is None assert sbom_components is None + assert remote_config == _remote_config() tool_sbom.get_components.assert_not_called() @@ -104,7 +106,7 @@ def test_init_engine_license_returns_none_when_sbom_missing_after_generation(moc tool_sbom = MagicMock() tool_sbom.get_components.return_value = ["c"] - license_path, sbom_components = init_engine_license( + license_path, sbom_components, remote_config = init_engine_license( devops_gw, remote_gw, {"remote_config_repo": "r", "remote_config_branch": ""}, @@ -114,6 +116,7 @@ def test_init_engine_license_returns_none_when_sbom_missing_after_generation(moc assert license_path is None assert sbom_components == ["c"] + assert remote_config == _remote_config() @patch( @@ -124,7 +127,7 @@ def test_init_engine_license_returns_none_when_tool_sbom_missing(mock_exists): devops_gw = _make_devops_gateway() remote_gw = _make_remote_gw(_remote_config()) - license_path, sbom_components = init_engine_license( + license_path, sbom_components, remote_config = init_engine_license( devops_gw, remote_gw, {"remote_config_repo": "r", "remote_config_branch": ""}, @@ -134,6 +137,7 @@ def test_init_engine_license_returns_none_when_tool_sbom_missing(mock_exists): assert license_path is None assert sbom_components is None + assert remote_config == _remote_config() @patch( @@ -150,7 +154,7 @@ def test_init_engine_license_returns_none_when_build_report_fails(mock_exists, m tool_sbom = MagicMock() tool_sbom.get_components.return_value = ["c"] - license_path, sbom_components = init_engine_license( + license_path, sbom_components, remote_config = init_engine_license( devops_gw, remote_gw, {"remote_config_repo": "r", "remote_config_branch": ""}, @@ -159,3 +163,4 @@ def test_init_engine_license_returns_none_when_build_report_fails(mock_exists, m ) assert license_path is None assert sbom_components == ["c"] + assert remote_config == _remote_config() From f6b25fa9908a1369017aeb6471ce67a756f0b760 Mon Sep 17 00:00:00 2001 From: yenner123 <> Date: Wed, 8 Jul 2026 20:04:28 -0500 Subject: [PATCH 8/8] refactor(engine_license): move license_policy helpers to infrastructure/helpers --- docs/Docusaurus/docs/life_cycle/getting_started.md | 2 +- docs/Docusaurus/docs/modules/engine_sca/engine_license.md | 4 +++- .../src/domain/usecases/build_license_report.py | 2 +- .../engine_license/src/infrastructure/helpers/__init__.py | 0 .../usecases => infrastructure/helpers}/license_policy.py | 0 .../engine_license/test/infrastructure/helpers/__init__.py | 0 .../helpers}/test_license_policy.py | 2 +- 7 files changed, 6 insertions(+), 4 deletions(-) create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/helpers/__init__.py rename tools/devsecops_engine_tools/engine_sca/engine_license/src/{domain/usecases => infrastructure/helpers}/license_policy.py (100%) create mode 100644 tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/helpers/__init__.py rename tools/devsecops_engine_tools/engine_sca/engine_license/test/{domain/usecases => infrastructure/helpers}/test_license_policy.py (98%) diff --git a/docs/Docusaurus/docs/life_cycle/getting_started.md b/docs/Docusaurus/docs/life_cycle/getting_started.md index d21a70cef..8fb7b6e66 100644 --- a/docs/Docusaurus/docs/life_cycle/getting_started.md +++ b/docs/Docusaurus/docs/life_cycle/getting_started.md @@ -34,7 +34,7 @@ For more information about structure remote config visit [Structure Remote Confi Free - ENGINE_IAC + ENGINE_IAC CHECKOV Free diff --git a/docs/Docusaurus/docs/modules/engine_sca/engine_license.md b/docs/Docusaurus/docs/modules/engine_sca/engine_license.md index ff6802716..da6013dae 100644 --- a/docs/Docusaurus/docs/modules/engine_sca/engine_license.md +++ b/docs/Docusaurus/docs/modules/engine_sca/engine_license.md @@ -14,6 +14,8 @@ Unlike other engines, `engine_license` does **not**: The intent is to ship an audit-friendly, policy-driven JSON that downstream consumers can analyse out-of-band of build pipelines. +> **Why CdxGen?** The module relies on CdxGen because it is the only mainstream SBOM generator that enriches license data by querying the package registry (e.g. Maven Central, npm, NuGet) via its `FETCH_LICENSE` option. Tools such as Syft and Trivy report only locally declared licenses (from lockfiles or existing package metadata) and do not perform active registry lookups, so licenses missing from local manifests (common in Maven/Gradle projects) would not be resolved. This is why `SBOM_MANAGER.CDXGEN.FETCH_LICENSE` must be `true` for the report to be complete. + ## Configuration Structure Only one configuration file is consumed: `engine_sca/engine_license/ConfigTool.json`. There is no `Exclusions.json`; the policy is declared inline. @@ -155,7 +157,7 @@ Only `fail` and `warn` dependencies appear in the context output. - `applications/runner_license_scan.py`: Entry point invoked by `engine_core/handle_scan`. Runs the flow and builds `Finding` objects for the compliance table. - `infrastructure/entry_points/entry_point_tool.py`: Orchestrator: fetch remote config โ†’ fresh SBOM โ†’ build report. - `infrastructure/driven_adapters/license_scan/license_scan_manager.py`: Reads the LICENSE.json and provides structured context extraction. -- `domain/usecases/license_policy.py`: Pure helpers โ€” `build_policy_from_remote_config`, `classify_package`, `looks_like_spdx_id`. No I/O, fully unit-testable. +- `infrastructure/helpers/license_policy.py`: Pure helpers โ€” `build_policy_from_remote_config`, `classify_package`, `looks_like_spdx_id`. - `domain/usecases/build_license_report.py`: `BuildLicenseReport` use case that reads the CycloneDX SBOM, classifies every dependency, and writes the LICENSE.json artifact. ## Example Usage diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/build_license_report.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/build_license_report.py index e554f523a..27c5a7266 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/build_license_report.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/build_license_report.py @@ -14,7 +14,7 @@ import os from datetime import datetime -from devsecops_engine_tools.engine_sca.engine_license.src.domain.usecases.license_policy import ( +from devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.helpers.license_policy import ( build_policy_from_remote_config, classify_package, ) diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/helpers/__init__.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/helpers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/license_policy.py b/tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/helpers/license_policy.py similarity index 100% rename from tools/devsecops_engine_tools/engine_sca/engine_license/src/domain/usecases/license_policy.py rename to tools/devsecops_engine_tools/engine_sca/engine_license/src/infrastructure/helpers/license_policy.py diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/helpers/__init__.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/helpers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_license_policy.py b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/helpers/test_license_policy.py similarity index 98% rename from tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_license_policy.py rename to tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/helpers/test_license_policy.py index faf432bd8..c7711e393 100644 --- a/tools/devsecops_engine_tools/engine_sca/engine_license/test/domain/usecases/test_license_policy.py +++ b/tools/devsecops_engine_tools/engine_sca/engine_license/test/infrastructure/helpers/test_license_policy.py @@ -1,4 +1,4 @@ -from devsecops_engine_tools.engine_sca.engine_license.src.domain.usecases.license_policy import ( +from devsecops_engine_tools.engine_sca.engine_license.src.infrastructure.helpers.license_policy import ( build_policy_from_remote_config, classify_package, get_value,