Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ log-agent.txt
reports/
results/
results.json
vulns.json
vulns-basic.sarif
vulns-enhanced.sarif

# Backup files
*.bkp
Expand All @@ -99,4 +102,11 @@ da-analyzer-results
# Optional: User-specific test scripts if not shared
test-commands.txt

workbench-cli-log.txt
workbench-cli-log.txt
*.sarif

vuln-report-epss-kev.json
vuln-report-epss.json
vuln-report-nvd-epss-kev.json
vuln-report.json
vuln-report-epss.json
54 changes: 54 additions & 0 deletions src/workbench_cli/api/components_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from typing import Dict, Any

import logging

from ..exceptions import ApiError
from .helpers.api_base import APIBase
from .helpers.component_info_normalizer import normalize_component_response

logger = logging.getLogger("workbench-cli")


class ComponentsAPI(APIBase):
"""Workbench API Component Operations."""

def get_component_information(self, component_name: str, component_version: str) -> Dict[str, Any]:
"""Retrieve component metadata from Workbench.

Args:
component_name: The component or package name (e.g. "ansi-regex").
component_version: The component version (e.g. "1.1.1").

Returns:
Dictionary with the component information as returned by the API.

Raises:
ApiError: If the component does not exist or the API request fails.
"""
logger.debug(
"Fetching information for component '%s' version '%s'...",
component_name,
component_version,
)

payload = {
"group": "components",
"action": "get_information",
"data": {
"component_name": component_name,
"component_version": component_version,
},
}

response = self._send_request(payload)

# Successful response
if response.get("status") == "1" and "data" in response:
return normalize_component_response(response["data"])

# Something went wrong – build a helpful error message
error_msg = response.get("error", f"Unexpected response: {response}")
raise ApiError(
f"Failed to fetch information for component '{component_name}' version '{component_version}': {error_msg}",
details=response,
)
70 changes: 70 additions & 0 deletions src/workbench_cli/api/helpers/component_info_normalizer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from typing import Any, Dict, List

# Fields that callers actively use; expand if more become important.
_EXPECTED_FIELDS = {
"id",
"cpe",
"name",
"version",
"purl",
"purl_type",
"purl_namespace",
"purl_name",
"purl_version",
"supplier_name",
"supplier_url",
"license_identifier",
"license_name",
"copyright",
"comment",
}


def normalize_component_response(raw: Any) -> Dict[str, Any]:
"""Return a stable dict from the Workbench *components/get_information* response.

Workbench 25.x sometimes returns the *data* field as a single-element list, or
as a dict. Future versions may rename or add keys. This helper:
• Converts list→dict when length==1.
• Ignores unknown fields (passes through only those we care about).
• Returns an empty dict on unexpected structures.
"""
# 1. Normalise list ↔ dict
if isinstance(raw, list):
raw = raw[0] if raw else {}
if not isinstance(raw, dict):
return {}

# 2. Map any known aliases between versions (none yet, but placeholder)
aliases = {
# Map alternative field names to our canonical keys
"license": "license_identifier", # some API versions use generic 'license'
"licenseId": "license_identifier", # camel-cased variant
"license_id": "license_identifier", # snake_cased variant
"licenseName": "license_name",
"license_name": "license_name", # ensure canonical form if already correct
}
for old, new in aliases.items():
if old in raw and new not in raw:
raw[new] = raw.pop(old)

# When only identifier or name supplied, attempt to set the other for completeness
if "license_identifier" in raw and "license_name" not in raw:
raw["license_name"] = raw["license_identifier"]
if "license_name" in raw and "license_identifier" not in raw:
raw["license_identifier"] = raw["license_name"]

# Extract from *licenses* list (Workbench 25.x) when canonical keys still missing
if ("license_identifier" not in raw or not raw["license_identifier"]) and "licenses" in raw:
lic_data = raw.get("licenses") or []
if isinstance(lic_data, list) and lic_data:
first_lic = lic_data[0]
if isinstance(first_lic, dict):
raw["license_identifier"] = first_lic.get("identifier") or first_lic.get("id") or raw.get("license_identifier")
raw["license_name"] = first_lic.get("name") or raw.get("license_name")
elif isinstance(lic_data, dict): # single object
raw["license_identifier"] = lic_data.get("identifier") or lic_data.get("id") or raw.get("license_identifier")
raw["license_name"] = lic_data.get("name") or raw.get("license_name")

# 3. Return only expected fields (others are ignored to shield callers)
return {field: raw.get(field) for field in _EXPECTED_FIELDS if field in raw}
3 changes: 2 additions & 1 deletion src/workbench_cli/api/workbench_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from .projects_api import ProjectsAPI
from .scans_api import ScansAPI
from .vulnerabilities_api import VulnerabilitiesAPI
from .components_api import ComponentsAPI
from ..exceptions import (
WorkbenchCLIError,
ApiError,
Expand Down Expand Up @@ -35,7 +36,7 @@
# Assume logger is configured in main.py
logger = logging.getLogger("workbench-cli")

class WorkbenchAPI(UploadAPI, ResolveWorkbenchProjectScan, ProjectsAPI, VulnerabilitiesAPI, ScansAPI):
class WorkbenchAPI(UploadAPI, ResolveWorkbenchProjectScan, ProjectsAPI, VulnerabilitiesAPI, ScansAPI, ComponentsAPI):
"""
Workbench API client class for interacting with the FossID Workbench API.
This class composes all the individual API parts into a single client.
Expand Down
59 changes: 58 additions & 1 deletion src/workbench_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def add_common_result_options(subparser):
results_display_args.add_argument("--show-scan-metrics", help="Show metrics on file identifications (total files, pending id, identified, no matches).", action="store_true", default=False)
results_display_args.add_argument("--show-policy-warnings", help="Shows Policy Warnings in identified components or dependencies.", action="store_true", default=False)
results_display_args.add_argument("--show-vulnerabilities", help="Shows a summary of vulnerabilities found in the scan.", action="store_true", default=False)
results_display_args.add_argument("--path-result", help="Saves the requested results to this file/directory (JSON format).", metavar="PATH")
results_display_args.add_argument("--json-result-path", help="Saves the requested results to this file/directory (JSON format).", metavar="PATH")

# --- Main Parsing Function ---
def parse_cmdline_args():
Expand Down Expand Up @@ -114,6 +114,24 @@ def parse_cmdline_args():
# Download reports for a specific scan (globally)
workbench-cli --api-url <URL> --api-user <USER> --api-token <TOKEN> \\
download-reports --scan-name MYSCAN01 --report-scope scan --report-type html --report-save-path reports/

# Export vulnerability results in CycloneDX format
workbench-cli --api-url <URL> --api-user <USER> --api-token <TOKEN> \\
export-vulns --project-name MYPROJ --scan-name MYSCAN01 --format cyclonedx -o vulns.cdx.json

# Export complete SBOM with all components (not just vulnerable ones)
workbench-cli --api-url <URL> --api-user <USER> --api-token <TOKEN> \\
export-vulns --project-name MYPROJ --scan-name MYSCAN01 --format cyclonedx -o complete-sbom.cdx.json --augment-full-bom

# Export CycloneDX SBOM with external enrichment
workbench-cli --api-url <URL> --api-user <USER> --api-token <TOKEN> \\
export-vulns --project-name MYPROJ --scan-name MYSCAN01 --format cyclonedx -o vulns-enriched.cdx.json \\
--enrich-nvd --enrich-epss --augment-full-bom

# Export vulnerability results in SPDX 3.0 format with enrichment
workbench-cli --api-url <URL> --api-user <USER> --api-token <TOKEN> \\
export-vulns --project-name MYPROJ --scan-name MYSCAN01 --format spdx3 -o vulns.spdx.json \\
--enrich-nvd --enrich-epss
"""
)

Expand Down Expand Up @@ -297,6 +315,45 @@ def parse_cmdline_args():
add_common_monitoring_options(scan_git_parser)
add_common_result_options(scan_git_parser)

# --- 'export-vulns' Subcommand ---
export_vulns_parser = subparsers.add_parser(
'export-vulns',
help='Export vulnerability results in multiple formats (SARIF, CycloneDX, SPDX 3.0).',
description='Export vulnerability results from an existing scan in various formats:\n'
'• SARIF (Static Analysis Results Interchange Format) v2.1.0 - compatible with GitHub Advanced Security\n'
'• CycloneDX - Software Bill of Materials with vulnerability information\n'
'• SPDX 3.0 - Security Profile for vulnerability reporting\n\n'
'All formats share the same data enrichment pipeline and VEX assessment processing.',
formatter_class=RawTextHelpFormatter
)

# Required arguments
required_args = export_vulns_parser.add_argument_group("Required")
required_args.add_argument("--project-name", help="Project name containing the scan.", type=str, required=True, metavar="NAME")
required_args.add_argument("--scan-name", help="Scan name to export vulnerability results from.", type=str, required=True, metavar="NAME")
required_args.add_argument("--format", help="Output format for the vulnerability report.", choices=["sarif", "cyclonedx", "spdx3"], required=True, metavar="FORMAT")
required_args.add_argument("-o", "--output", help="Output file path for the vulnerability report.", type=str, required=True, metavar="PATH")

# External API enrichment
external_api_args = export_vulns_parser.add_argument_group("External API Enrichment (Network Calls)")
external_api_args.add_argument("--enrich-nvd", help="Fetch CVE descriptions from NVD API (Default: False - opt-in).", action=argparse.BooleanOptionalAction, default=False)
external_api_args.add_argument("--enrich-epss", help="Fetch EPSS scores from FIRST API (Default: False - opt-in).", action=argparse.BooleanOptionalAction, default=False)
external_api_args.add_argument("--enrich-cisa-kev", help="Fetch CISA Known Exploited Vulnerabilities (Default: False - opt-in).", action=argparse.BooleanOptionalAction, default=False)
external_api_args.add_argument("--external-timeout", help="Timeout for external API calls in seconds (Default: 30).", type=int, default=30, metavar="SECONDS")

# Output processing & suppression
processing_args = export_vulns_parser.add_argument_group("Output Processing & Suppression")
processing_args.add_argument("--severity-threshold", help="Filter vulnerabilities by CVSS severity.", choices=["critical", "high", "medium", "low"], metavar="LEVEL")
processing_args.add_argument("--disable-dynamic-risk-scoring", dest="disable_dynamic_risk_scoring", help="Disable Dynamic Risk Scoring (VEX suppression and EPSS / KEV escalation).", action="store_true")
processing_args.add_argument("--augment-full-bom", help="Augment the SBOM from the scan with vulnerability enrichment and dynamic scoring.", action="store_true")


# Output control
output_control_args = export_vulns_parser.add_argument_group("Output Control")
output_control_args.add_argument("--quiet", help="Suppress progress output.", action="store_true")

add_common_monitoring_options(export_vulns_parser)

# --- Validate args after parsing ---
args = parser.parse_args()

Expand Down
4 changes: 3 additions & 1 deletion src/workbench_cli/handlers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .show_results import handle_show_results
from .evaluate_gates import handle_evaluate_gates
from .download_reports import handle_download_reports
from .export_vulns import handle_export_vulns

__all__ = [
'handle_scan',
Expand All @@ -21,5 +22,6 @@
'handle_import_sbom',
'handle_show_results',
'handle_evaluate_gates',
'handle_download_reports'
'handle_download_reports',
'handle_export_vulns'
]
Loading
Loading