diff --git a/README.md b/README.md index 7929189..9a6e6d8 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ socketcli \ | Use case | Recommended mode | Key flags | |:--|:--|:--| | Basic policy enforcement in CI | Diff-based policy check | `--strict-blocking` | +| Legal/compliance artifact generation | Legal preset | `--legal` | | Reachable-focused SARIF for reporting | Full-scope grouped SARIF | `--reach --sarif-scope full --sarif-grouping alert --sarif-reachability reachable --sarif-file ` | | Detailed reachability export for investigations | Full-scope instance SARIF | `--reach --sarif-scope full --sarif-grouping instance --sarif-reachability all --sarif-file ` | | Net-new PR findings only | Diff-scope SARIF | `--reach --sarif-scope diff --sarif-reachability reachable --sarif-file ` | @@ -134,6 +135,35 @@ Run: socketcli --config .socketcli.toml --target-path . ``` +Legal/compliance preset example: + +```bash +socketcli --legal --target-path . +``` + +This preset enables license generation and writes default artifacts unless you override them: +- `socket-report.json` +- `socket-summary.txt` +- `socket-report-link.txt` +- `socket-sbom.json` +- `socket-license.json` + +FOSSA-compatibility shaped legal artifacts: + +```bash +socketcli --legal-format fossa --target-path . +``` + +This switches the JSON report and legal artifact payloads to FOSSA-style compatibility shapes: +- the analyze artifact becomes a `project` / `vulnerability` / `licensing` / `quality` report +- the SBOM artifact becomes a `project` / `dependencies` attribution-style payload + +When `--legal-format fossa` is used without explicit output paths, the defaults are closer to the FOSSA pipeline contract: +- `fossa-analyze.json` +- `fossa-test.txt` +- `fossa-link.txt` +- `fossa-sbom.json` + Reference sample configs: TOML: diff --git a/pyproject.toml b/pyproject.toml index 50b0518..4b7f380 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.2.88" +version = "2.2.89" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index a7dcdfb..9547cf5 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.2.88' +__version__ = '2.2.89' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/config.py b/socketsecurity/config.py index 1d18c6a..ffaf5f4 100644 --- a/socketsecurity/config.py +++ b/socketsecurity/config.py @@ -79,6 +79,7 @@ class CliConfig: enable_debug: bool = False allow_unverified: bool = False enable_json: bool = False + json_file: Optional[str] = None enable_sarif: bool = False sarif_file: Optional[str] = None sarif_scope: str = "diff" @@ -86,6 +87,8 @@ class CliConfig: sarif_reachability: str = "all" enable_gitlab_security: bool = False gitlab_security_file: Optional[str] = None + summary_file: Optional[str] = None + report_link_file: Optional[str] = None disable_overview: bool = False disable_security_issue: bool = False files: str = None @@ -137,6 +140,8 @@ class CliConfig: reach_continue_on_no_source_files: bool = False max_purl_batch_size: int = 5000 enable_commit_status: bool = False + legal: bool = False + legal_format: str = "socket" config_file: Optional[str] = None @classmethod @@ -194,6 +199,7 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': 'enable_diff': args.enable_diff, 'allow_unverified': args.allow_unverified, 'enable_json': args.enable_json, + 'json_file': args.json_file, 'enable_sarif': args.enable_sarif, 'sarif_file': args.sarif_file, 'sarif_scope': args.sarif_scope, @@ -201,6 +207,8 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': 'sarif_reachability': args.sarif_reachability, 'enable_gitlab_security': args.enable_gitlab_security, 'gitlab_security_file': args.gitlab_security_file, + 'summary_file': args.summary_file, + 'report_link_file': args.report_link_file, 'disable_overview': args.disable_overview, 'disable_security_issue': args.disable_security_issue, 'files': args.files, @@ -246,9 +254,40 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': 'reach_continue_on_no_source_files': args.reach_continue_on_no_source_files, 'max_purl_batch_size': args.max_purl_batch_size, 'enable_commit_status': args.enable_commit_status, + 'legal': args.legal or args.legal_format == "fossa", + 'legal_format': args.legal_format, 'config_file': args.config_file, 'version': __version__ } + + if config_args['legal']: + config_args['generate_license'] = True + if not config_args['json_file']: + config_args['json_file'] = "socket-report.json" + if not config_args['summary_file']: + config_args['summary_file'] = "socket-summary.txt" + if not config_args['report_link_file']: + config_args['report_link_file'] = "socket-report-link.txt" + if not config_args['sbom_file']: + config_args['sbom_file'] = "socket-sbom.json" + if config_args['license_file_name'] == "license_output.json": + config_args['license_file_name'] = "socket-license.json" + + if config_args['legal_format'] == "fossa": + if not args.json_file: + config_args['json_file'] = "fossa-analyze.json" + if not args.summary_file: + config_args['summary_file'] = "fossa-test.txt" + if not args.report_link_file: + config_args['report_link_file'] = "fossa-link.txt" + if not args.license_file_name: + # argparse always provides a default, so this branch is defensive only + config_args['license_file_name'] = "fossa-sbom.json" + elif args.license_file_name == "license_output.json": + config_args['license_file_name'] = "fossa-sbom.json" + if not args.sbom_file: + # FOSSA's "SBOM" artifact is the attribution payload; suppress the extra Socket-only SBOM file by default. + config_args['sbom_file'] = None excluded_ecosystems = config_args["excluded_ecosystems"] if isinstance(excluded_ecosystems, list): config_args["excluded_ecosystems"] = excluded_ecosystems @@ -570,6 +609,12 @@ def create_argument_parser() -> argparse.ArgumentParser: action="store_true", help="Output in JSON format" ) + output_group.add_argument( + "--json-file", + dest="json_file", + metavar="", + help="Output file path for JSON report" + ) output_group.add_argument( "--enable-sarif", dest="enable_sarif", @@ -617,6 +662,18 @@ def create_argument_parser() -> argparse.ArgumentParser: default="gl-dependency-scanning-report.json", help="Output file path for GitLab Security report (default: gl-dependency-scanning-report.json)" ) + output_group.add_argument( + "--summary-file", + dest="summary_file", + metavar="", + help="Output file path for a plain-text summary report" + ) + output_group.add_argument( + "--report-link-file", + dest="report_link_file", + metavar="", + help="Output file path for the Socket report link" + ) output_group.add_argument( "--disable-overview", dest="disable_overview", @@ -746,6 +803,19 @@ def create_argument_parser() -> argparse.ArgumentParser: action="store_true", help="Disable SSL certificate verification for API requests" ) + advanced_group.add_argument( + "--legal", + dest="legal", + action="store_true", + help="Enable legal/compliance-friendly defaults and file outputs" + ) + advanced_group.add_argument( + "--legal-format", + dest="legal_format", + choices=["socket", "fossa"], + default="socket", + help="Select the legal artifact format. 'socket' keeps Socket-native outputs; 'fossa' emits compatibility-shaped JSON artifacts." + ) config_group.add_argument( "--include-module-folders", dest="include_module_folders", diff --git a/socketsecurity/fossa_compat.py b/socketsecurity/fossa_compat.py new file mode 100644 index 0000000..4708096 --- /dev/null +++ b/socketsecurity/fossa_compat.py @@ -0,0 +1,353 @@ +from __future__ import annotations + +from typing import Any, Iterable, Optional + +from socketsecurity.config import CliConfig +from socketsecurity.core.classes import Diff, Issue, Package + + +LICENSE_ALERT_TYPES = {"licenseSpdxDisj"} +QUALITY_ALERT_PREFIXES = ("risk", "quality", "outdated", "unmaintained") + + +def _ecosystem_to_package_manager(ecosystem: Optional[str]) -> str: + mapping = { + "pypi": "pip", + "npm": "npm", + "maven": "maven", + "nuget": "nuget", + "gem": "gem", + "golang": "go", + "cargo": "cargo", + } + if not ecosystem: + return "unknown" + return mapping.get(ecosystem, ecosystem) + + +def _listify(value: Any) -> list[Any]: + if value is None: + return [] + if isinstance(value, list): + return value + return [value] + + +def _first_non_empty(*values: Any) -> Any: + for value in values: + if value not in (None, "", [], {}): + return value + return None + + +def _build_project_metadata(diff_report: Diff, config: CliConfig) -> dict[str, Any]: + repo = getattr(config, "repo", None) or "socket-default-repo" + branch = getattr(config, "branch", None) or "socket-default-branch" + revision = getattr(diff_report, "id", None) or getattr(diff_report, "new_scan_id", None) or "unknown-revision" + report_url = getattr(diff_report, "report_url", None) or getattr(diff_report, "diff_url", None) + project_id = repo + return { + "branch": branch, + "id": f"{project_id}-{revision}", + "project": repo, + "projectId": project_id, + "revision": revision, + "url": report_url, + } + + +def _build_source_metadata(issue: Issue, package: Optional[Package]) -> dict[str, Any]: + package_type = _ecosystem_to_package_manager( + getattr(package, "type", None) or getattr(issue, "pkg_type", None) + ) + package_name = getattr(package, "name", None) or getattr(issue, "pkg_name", None) + package_version = getattr(package, "version", None) or getattr(issue, "pkg_version", None) + package_url = getattr(package, "url", None) or getattr(issue, "url", None) + return { + "id": f"{package_type}+{package_name}${package_version}", + "name": package_name, + "url": package_url, + "version": package_version, + "packageManager": package_type, + } + + +def _build_depths(package: Optional[Package]) -> dict[str, int]: + is_direct = bool(getattr(package, "direct", False)) + return { + "direct": 1 if is_direct else 0, + "deep": 0 if is_direct else 1, + } + + +def _build_statuses(issue: Issue) -> dict[str, int]: + is_ignored = bool(getattr(issue, "ignore", False)) + return { + "active": 0 if is_ignored else 1, + "ignored": 1 if is_ignored else 0, + } + + +def _build_projects_entry(project: dict[str, Any], package: Optional[Package]) -> list[dict[str, Any]]: + is_direct = bool(getattr(package, "direct", False)) + return [{ + "id": project["projectId"], + "status": "active", + "depth": 1 if is_direct else 2, + "title": project["project"], + "scannedAt": None, + "analyzedAt": None, + "url": project["url"], + "firstFoundAt": None, + "defaultBranch": project["branch"], + "latest": True, + "revisionId": f"{project['projectId']}${project['revision']}", + "revisionScanId": project["revision"], + }] + + +def _extract_cve(props: dict[str, Any]) -> Optional[str]: + cve = _first_non_empty(props.get("cveId"), props.get("cve")) + if isinstance(cve, list): + return cve[0] if cve else None + return cve + + +def _extract_float(*values: Any) -> Optional[float]: + value = _first_non_empty(*values) + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _extract_string_list(*values: Any) -> list[str]: + items = _listify(_first_non_empty(*values)) + output = [] + for item in items: + if isinstance(item, str) and item: + output.append(item) + return output + + +def _build_remediation(props: dict[str, Any]) -> dict[str, Any]: + partial_fix = _first_non_empty( + props.get("partialFix"), + props.get("fixedVersion"), + props.get("fixed_version"), + props.get("patchedVersion"), + props.get("patched_version"), + props.get("range"), + ) + complete_fix = _first_non_empty( + props.get("completeFix"), + props.get("fixedVersion"), + props.get("fixed_version"), + props.get("patchedVersion"), + props.get("patched_version"), + props.get("range"), + ) + return { + "partialFix": partial_fix, + "partialFixDistance": props.get("partialFixDistance"), + "completeFix": complete_fix, + "completeFixDistance": props.get("completeFixDistance"), + } + + +def _build_epss(props: dict[str, Any]) -> dict[str, Any]: + score = _extract_float(props.get("epssScore"), props.get("epss_score")) + percentile = _extract_float(props.get("epssPercentile"), props.get("epss_percentile")) + return { + "score": score, + "percentile": percentile, + } + + +def _build_metrics(props: dict[str, Any]) -> list[dict[str, Any]]: + metrics = props.get("metrics") + if isinstance(metrics, list): + return metrics + + metric_map = [ + ("Attack Vector", props.get("attackVector")), + ("Attack Complexity", props.get("attackComplexity")), + ("Privileges Required", props.get("privilegesRequired")), + ("User Interaction", props.get("userInteraction")), + ("Scope", props.get("scope")), + ("Confidentiality Impact", props.get("confidentialityImpact")), + ("Integrity Impact", props.get("integrityImpact")), + ("Availability Impact", props.get("availabilityImpact")), + ] + return [ + {"name": name, "value": value} + for name, value in metric_map + if value not in (None, "") + ] + + +def _extract_references(issue: Issue, props: dict[str, Any]) -> list[str]: + references = _listify(props.get("references")) + if props.get("url"): + references.append(props["url"]) + if getattr(issue, "url", None): + references.append(issue.url) + deduped = [] + seen = set() + for reference in references: + if not isinstance(reference, str) or not reference: + continue + if reference in seen: + continue + seen.add(reference) + deduped.append(reference) + return deduped + + +def _build_vulnerability_entry( + issue: Issue, package: Optional[Package], project: dict[str, Any], index: int +) -> dict[str, Any]: + props = getattr(issue, "props", {}) or {} + return { + "id": props.get("id") or f"socket-vulnerability-{index}", + "type": "vulnerability", + "createdAt": props.get("createdAt"), + "source": _build_source_metadata(issue, package), + "depths": _build_depths(package), + "containerLayers": {"base": 0, "other": 0}, + "statuses": _build_statuses(issue), + "projects": _build_projects_entry(project, package), + "url": getattr(issue, "url", None) or project["url"], + "vulnId": _first_non_empty(props.get("ghsaId"), props.get("cveId"), issue.key, f"socket-vuln-{index}"), + "title": getattr(issue, "title", None), + "cve": _extract_cve(props), + "cvss": _extract_float(props.get("cvssScore"), props.get("cvss")), + "severity": getattr(issue, "severity", "unknown"), + "details": _first_non_empty(getattr(issue, "description", None), props.get("overview"), props.get("note")), + "remediation": _build_remediation(props), + "metrics": _build_metrics(props), + "cveStatus": props.get("cveStatus"), + "cwes": _extract_string_list(props.get("cwes"), props.get("cwe")), + "published": props.get("published"), + "affectedVersionRanges": _extract_string_list(props.get("affectedVersionRanges"), props.get("affected_versions")), + "patchedVersionRanges": _extract_string_list(props.get("patchedVersionRanges"), props.get("patched_versions")), + "references": _extract_references(issue, props), + "cvssVector": props.get("cvssVector"), + "exploitability": props.get("exploitability"), + "epss": _build_epss(props), + "cpes": _extract_string_list(props.get("cpes")), + } + + +def _build_licensing_entry( + issue: Issue, package: Optional[Package], project: dict[str, Any], index: int +) -> dict[str, Any]: + props = getattr(issue, "props", {}) or {} + package_license = getattr(package, "license", None) + issue_type = "policy_conflict" + if not package_license: + issue_type = "unlicensed_dependency" + elif getattr(issue, "type", None) not in LICENSE_ALERT_TYPES: + issue_type = "policy_flag" + return { + "id": props.get("id") or f"socket-licensing-{index}", + "type": issue_type, + "createdAt": props.get("createdAt"), + "source": _build_source_metadata(issue, package), + "depths": _build_depths(package), + "statuses": _build_statuses(issue), + "projects": _build_projects_entry(project, package), + "url": getattr(issue, "url", None) or project["url"], + "title": getattr(issue, "title", None) or "License Policy Violation", + "details": _first_non_empty(getattr(issue, "description", None), props.get("note"), package_license), + "license": package_license, + "identifiedLicense": package_license, + "references": _extract_references(issue, props), + } + + +def _build_quality_entry( + issue: Issue, package: Optional[Package], project: dict[str, Any], index: int +) -> dict[str, Any]: + props = getattr(issue, "props", {}) or {} + return { + "id": props.get("id") or f"socket-quality-{index}", + "type": getattr(issue, "type", None) or "quality_issue", + "createdAt": props.get("createdAt"), + "source": _build_source_metadata(issue, package), + "depths": _build_depths(package), + "statuses": _build_statuses(issue), + "projects": _build_projects_entry(project, package), + "url": getattr(issue, "url", None) or project["url"], + "title": getattr(issue, "title", None), + "details": _first_non_empty(getattr(issue, "description", None), props.get("note")), + "references": _extract_references(issue, props), + } + + +def _iter_selected_issues(diff_report: Diff, config: CliConfig) -> Iterable[Issue]: + yield from getattr(diff_report, "new_alerts", []) or [] + if getattr(config, "strict_blocking", False): + yield from getattr(diff_report, "unchanged_alerts", []) or [] + + +def _classify_issue(issue: Issue) -> str: + issue_type = (getattr(issue, "type", "") or "").lower() + category = (getattr(issue, "category", "") or "").lower() + if issue_type in LICENSE_ALERT_TYPES or "license" in issue_type or category == "licensing": + return "licensing" + if category == "quality" or issue_type.startswith(QUALITY_ALERT_PREFIXES): + return "quality" + return "vulnerability" + + +def build_fossa_report_payload(diff_report: Diff, config: CliConfig) -> dict[str, Any]: + project = _build_project_metadata(diff_report, config) + package_lookup = getattr(diff_report, "packages", {}) or {} + vulnerabilities = [] + licensing = [] + quality = [] + + for index, issue in enumerate(_iter_selected_issues(diff_report, config), start=1): + package = package_lookup.get(getattr(issue, "pkg_id", "")) if package_lookup else None + category = _classify_issue(issue) + if category == "licensing": + licensing.append(_build_licensing_entry(issue, package, project, index)) + elif category == "quality": + quality.append(_build_quality_entry(issue, package, project, index)) + else: + vulnerabilities.append(_build_vulnerability_entry(issue, package, project, index)) + + return { + "project": project, + "vulnerability": vulnerabilities, + "licensing": licensing, + "quality": quality, + } + + +def build_fossa_attribution_payload(diff_report: Diff, config: CliConfig) -> dict[str, Any]: + project = _build_project_metadata(diff_report, config) + packages = getattr(diff_report, "packages", {}) or {} + package_entries = [] + + for package in packages.values(): + package_entries.append({ + "id": package.id, + "name": package.name, + "version": package.version, + "ecosystem": _ecosystem_to_package_manager(package.type), + "direct": bool(getattr(package, "direct", False)), + "url": package.url, + "purl": package.purl, + "declaredLicense": package.license, + "licenseDetails": package.licenseDetails or [], + "licenseAttrib": package.licenseAttrib or [], + }) + + return { + "project": project, + "dependencies": package_entries, + } diff --git a/socketsecurity/output.py b/socketsecurity/output.py index 921ca79..63fe565 100644 --- a/socketsecurity/output.py +++ b/socketsecurity/output.py @@ -5,6 +5,7 @@ from .core.messages import Messages from .core.classes import Diff, Issue from .config import CliConfig +from .fossa_compat import build_fossa_report_payload from socketsecurity.plugins.manager import PluginManager from socketsecurity.core.alert_selection import ( clone_diff_with_selected_alerts, @@ -90,6 +91,9 @@ def handle_output(self, diff_report: Diff) -> None: plugin_mgr = PluginManager({"slack": slack_config}) plugin_mgr.send(diff_report, config=self.config) + self.save_json_file(diff_report, getattr(self.config, "json_file", None)) + self.save_summary_file(diff_report, getattr(self.config, "summary_file", None)) + self.save_report_link_file(diff_report, getattr(self.config, "report_link_file", None)) self.save_sbom_file(diff_report, self.config.sbom_file) def return_exit_code(self, diff_report: Diff) -> int: @@ -107,50 +111,15 @@ def return_exit_code(self, diff_report: Diff) -> int: def output_console_comments(self, diff_report: Diff, sbom_file_name: Optional[str] = None) -> None: """Outputs formatted console comments""" - selected_alerts = select_diff_alerts(diff_report, strict_blocking=self.config.strict_blocking) - has_new_alerts = len(selected_alerts) > 0 - has_unchanged_alerts = ( - self.config.strict_blocking and - hasattr(diff_report, 'unchanged_alerts') and - len(diff_report.unchanged_alerts) > 0 - ) - - if not has_new_alerts and not has_unchanged_alerts: - self.logger.info("No issues found") - return - - # Count blocking vs warning alerts - new_blocking = sum(1 for issue in diff_report.new_alerts if issue.error) - new_warning = sum(1 for issue in diff_report.new_alerts if issue.warn) - - unchanged_blocking = 0 - unchanged_warning = 0 - if has_unchanged_alerts: - unchanged_blocking = sum(1 for issue in diff_report.unchanged_alerts if issue.error) - unchanged_warning = sum(1 for issue in diff_report.unchanged_alerts if issue.warn) - - selected_diff = clone_diff_with_selected_alerts(diff_report, selected_alerts) - console_security_comment = Messages.create_console_security_alert_table(selected_diff) - - # Build status message - self.logger.info("Security issues detected by Socket Security:") - if new_blocking > 0: - self.logger.info(f" - NEW blocking issues: {new_blocking}") - if new_warning > 0: - self.logger.info(f" - NEW warning issues: {new_warning}") - if unchanged_blocking > 0: - self.logger.info(f" - EXISTING blocking issues: {unchanged_blocking} (causing failure due to --strict-blocking)") - if unchanged_warning > 0: - self.logger.info(f" - EXISTING warning issues: {unchanged_warning}") - - self.logger.info(f"Diff Url: {diff_report.diff_url}") - self.logger.info(f"\n{console_security_comment}") + summary_text = self.build_summary_text(diff_report) + for line in summary_text.splitlines(): + self.logger.info(line) + if not summary_text.strip(): + self.logger.info("") def output_console_json(self, diff_report: Diff, sbom_file_name: Optional[str] = None) -> None: """Outputs JSON formatted results""" - selected_alerts = select_diff_alerts(diff_report, strict_blocking=self.config.strict_blocking) - selected_diff = clone_diff_with_selected_alerts(diff_report, selected_alerts) - console_security_comment = Messages.create_security_comment_json(selected_diff) + console_security_comment = self.build_json_report(diff_report) self.save_sbom_file(diff_report, sbom_file_name) self.logger.info(json.dumps(console_security_comment)) @@ -246,14 +215,106 @@ def report_pass(self, diff_report: Diff) -> bool: def save_sbom_file(self, diff_report: Diff, sbom_file_name: Optional[str] = None) -> None: """Saves SBOM file if filename is provided""" - if not sbom_file_name or not diff_report.sbom: + if not sbom_file_name: return - sbom_path = Path(sbom_file_name) - sbom_path.parent.mkdir(parents=True, exist_ok=True) + sbom_data = getattr(diff_report, "sbom", None) + if sbom_data is None: + sbom_data = [] + + self.write_json_file(sbom_file_name, sbom_data) - with open(sbom_path, "w") as f: - json.dump(diff_report.sbom, f, indent=2) + def build_summary_text(self, diff_report: Diff) -> str: + """Render the console summary text for stdout and file output.""" + selected_alerts = select_diff_alerts(diff_report, strict_blocking=self.config.strict_blocking) + has_new_alerts = len(selected_alerts) > 0 + has_unchanged_alerts = ( + self.config.strict_blocking and + hasattr(diff_report, 'unchanged_alerts') and + len(diff_report.unchanged_alerts) > 0 + ) + + if not has_new_alerts and not has_unchanged_alerts: + return "No issues found" + + new_blocking = sum(1 for issue in diff_report.new_alerts if issue.error) + new_warning = sum(1 for issue in diff_report.new_alerts if issue.warn) + + unchanged_blocking = 0 + unchanged_warning = 0 + if has_unchanged_alerts: + unchanged_blocking = sum(1 for issue in diff_report.unchanged_alerts if issue.error) + unchanged_warning = sum(1 for issue in diff_report.unchanged_alerts if issue.warn) + + selected_diff = clone_diff_with_selected_alerts(diff_report, selected_alerts) + console_security_comment = Messages.create_console_security_alert_table(selected_diff) + + lines = ["Security issues detected by Socket Security:"] + if new_blocking > 0: + lines.append(f" - NEW blocking issues: {new_blocking}") + if new_warning > 0: + lines.append(f" - NEW warning issues: {new_warning}") + if unchanged_blocking > 0: + lines.append( + f" - EXISTING blocking issues: {unchanged_blocking} (causing failure due to --strict-blocking)" + ) + if unchanged_warning > 0: + lines.append(f" - EXISTING warning issues: {unchanged_warning}") + + report_link = getattr(diff_report, "report_url", "") or getattr(diff_report, "diff_url", "") + lines.append(f"Diff Url: {report_link}") + lines.append("") + lines.append(str(console_security_comment)) + return "\n".join(lines) + + def build_json_report(self, diff_report: Diff) -> dict: + """Build the JSON report payload for stdout and file output.""" + if getattr(self.config, "legal_format", "socket") == "fossa": + return build_fossa_report_payload(diff_report, self.config) + + selected_alerts = select_diff_alerts(diff_report, strict_blocking=self.config.strict_blocking) + selected_diff = clone_diff_with_selected_alerts(diff_report, selected_alerts) + report = Messages.create_security_comment_json(selected_diff) + legal_flag = getattr(self.config, "legal", False) + repo = getattr(self.config, "repo", None) + branch = getattr(self.config, "branch", None) + commit_sha = getattr(self.config, "commit_sha", None) + report["report_url"] = getattr(diff_report, "report_url", None) + report["repo"] = repo if isinstance(repo, str) or repo is None else None + report["branch"] = branch if isinstance(branch, str) or branch is None else None + report["commit_sha"] = commit_sha if isinstance(commit_sha, str) or commit_sha is None else None + report["legal_mode"] = legal_flag if isinstance(legal_flag, bool) else False + return report + + def save_json_file(self, diff_report: Diff, json_file_name: Optional[str] = None) -> None: + if not json_file_name: + return + self.write_json_file(json_file_name, self.build_json_report(diff_report)) + + def save_summary_file(self, diff_report: Diff, summary_file_name: Optional[str] = None) -> None: + if not summary_file_name: + return + self.write_text_file(summary_file_name, self.build_summary_text(diff_report) + "\n") + + def save_report_link_file(self, diff_report: Diff, report_link_file_name: Optional[str] = None) -> None: + if not report_link_file_name: + return + report_link = getattr(diff_report, "report_url", "") or getattr(diff_report, "diff_url", "") + if not report_link: + return + self.write_text_file(report_link_file_name, report_link + "\n") + + def write_json_file(self, file_name: str, content: Any) -> None: + file_path = Path(file_name) + file_path.parent.mkdir(parents=True, exist_ok=True) + with open(file_path, "w") as f: + json.dump(content, f, indent=2) + + def write_text_file(self, file_name: str, content: str) -> None: + file_path = Path(file_name) + file_path.parent.mkdir(parents=True, exist_ok=True) + with open(file_path, "w") as f: + f.write(content) def output_gitlab_security(self, diff_report: Diff) -> None: """ diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index 1f2b166..86f5ff8 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -20,12 +20,44 @@ from socketsecurity.core.messages import Messages from socketsecurity.core.scm_comments import Comments from socketsecurity.core.socket_config import SocketConfig +from socketsecurity.fossa_compat import build_fossa_attribution_payload from socketsecurity.output import OutputHandler socket_logger, log = initialize_logging() load_dotenv() + +def build_license_artifact_payload( + diff: Diff, + legal_format: str = "socket", + config: CliConfig | None = None, +) -> dict: + """Build the license artifact payload from a diff, tolerating sparse scan paths.""" + if legal_format == "fossa": + if config is None: + raise ValueError("config is required when building FOSSA-format legal artifacts") + return build_fossa_attribution_payload(diff, config) + + all_packages = {} + packages = getattr(diff, "packages", {}) or {} + for purl in packages: + package = packages[purl] + output = { + "id": package.id, + "name": package.name, + "version": package.version, + "ecosystem": package.type, + "direct": package.direct, + "url": package.url, + "license": package.license, + "licenseDetails": package.licenseDetails, + "licenseAttrib": package.licenseAttrib, + "purl": package.purl, + } + all_packages[package.id] = output + return all_packages + def cli(): try: main_code() @@ -743,22 +775,11 @@ def _is_unprocessed(c): # Handle license generation if not should_skip_scan and diff.id != "NO_DIFF_RAN" and diff.id != "NO_SCAN_RAN" and config.generate_license: - all_packages = {} - for purl in diff.packages: - package = diff.packages[purl] - output = { - "id": package.id, - "name": package.name, - "version": package.version, - "ecosystem": package.type, - "direct": package.direct, - "url": package.url, - "license": package.license, - "licenseDetails": package.licenseDetails, - "licenseAttrib": package.licenseAttrib, - "purl": package.purl, - } - all_packages[package.id] = output + all_packages = build_license_artifact_payload( + diff, + legal_format=getattr(config, "legal_format", "socket"), + config=config, + ) core.save_file(config.license_file_name, json.dumps(all_packages)) # If we forced API mode due to no supported files, behave as if --disable-blocking was set diff --git a/tests/unit/test_cli_config.py b/tests/unit/test_cli_config.py index 045f0e4..27801ec 100644 --- a/tests/unit/test_cli_config.py +++ b/tests/unit/test_cli_config.py @@ -7,8 +7,15 @@ def test_api_token_from_env(self, monkeypatch): config = CliConfig.from_args([]) # Empty args list assert config.api_token == "test-token" - def test_required_args(self): + def test_required_args(self, monkeypatch): """Test that api token is required if not in environment""" + for env_var in ( + "SOCKET_SECURITY_API_KEY", + "SOCKET_SECURITY_API_TOKEN", + "SOCKET_API_KEY", + "SOCKET_API_TOKEN", + ): + monkeypatch.delenv(env_var, raising=False) with pytest.raises(ValueError, match="API token is required"): config = CliConfig.from_args([]) if not config.api_token: @@ -81,4 +88,42 @@ def test_workspace_is_independent_of_workspace_name(self): "--workspace-name", "monorepo-suffix", ]) assert config.workspace == "my-workspace" - assert config.workspace_name == "monorepo-suffix" \ No newline at end of file + assert config.workspace_name == "monorepo-suffix" + + def test_legal_flag_sets_default_artifact_files(self): + config = CliConfig.from_args(["--api-token", "test", "--legal"]) + assert config.legal is True + assert config.legal_format == "socket" + assert config.generate_license is True + assert config.json_file == "socket-report.json" + assert config.summary_file == "socket-summary.txt" + assert config.report_link_file == "socket-report-link.txt" + assert config.sbom_file == "socket-sbom.json" + assert config.license_file_name == "socket-license.json" + + def test_legal_flag_preserves_explicit_file_paths(self): + config = CliConfig.from_args([ + "--api-token", "test", + "--legal", + "--json-file", "custom-report.json", + "--summary-file", "custom-summary.txt", + "--report-link-file", "custom-link.txt", + "--sbom-file", "custom-sbom.json", + "--license-file-name", "custom-license.json", + ]) + assert config.json_file == "custom-report.json" + assert config.summary_file == "custom-summary.txt" + assert config.report_link_file == "custom-link.txt" + assert config.sbom_file == "custom-sbom.json" + assert config.license_file_name == "custom-license.json" + + def test_fossa_legal_format_enables_legal_defaults(self): + config = CliConfig.from_args(["--api-token", "test", "--legal-format", "fossa"]) + assert config.legal is True + assert config.legal_format == "fossa" + assert config.generate_license is True + assert config.json_file == "fossa-analyze.json" + assert config.summary_file == "fossa-test.txt" + assert config.report_link_file == "fossa-link.txt" + assert config.sbom_file is None + assert config.license_file_name == "fossa-sbom.json" diff --git a/tests/unit/test_fossa_compat.py b/tests/unit/test_fossa_compat.py new file mode 100644 index 0000000..65c2a20 --- /dev/null +++ b/tests/unit/test_fossa_compat.py @@ -0,0 +1,111 @@ +import json +from pathlib import Path + +from socketsecurity.config import CliConfig +from socketsecurity.core.classes import Diff, Issue, Package +from socketsecurity.fossa_compat import build_fossa_report_payload + + +FIXTURE_DIR = Path("/Users/lelia/github/fossa/DependencyScan/Fossa/validation-pipeline") + + +def test_fossa_report_payload_matches_sample_top_level_shape(): + sample = json.loads( + (FIXTURE_DIR / "fossa-analyze-11464165-job-011e1ec8-6569-5e69-4f06-baf193d1351e_03172026132742.json").read_text() + ) + + config = CliConfig.from_args(["--api-token", "test", "--legal-format", "fossa"]) + diff = Diff(id="scan-123", report_url="https://socket.dev/report/123") + + payload = build_fossa_report_payload(diff, config) + + assert list(payload.keys()) == list(sample.keys()) + assert sorted(payload["project"].keys()) == sorted(sample["project"].keys()) + assert payload["vulnerability"] == [] + assert payload["licensing"] == [] + assert payload["quality"] == [] + + +def test_fossa_report_payload_vulnerability_keys_cover_sample_shape(): + sample = json.loads( + (FIXTURE_DIR / "fossa-analyze-11464165-job-7f33e5bd-7764-5d8a-ba2e-506e078b9c3f_03172026132955.json").read_text() + ) + sample_vulnerability = sample["vulnerability"][0] + + config = CliConfig.from_args([ + "--api-token", "test", + "--legal-format", "fossa", + "--repo", "owner/repo", + "--branch", "refs/heads/main", + ]) + diff = Diff(id="scan-123", report_url="https://socket.dev/report/123") + diff.packages = { + "pkg-1": Package( + id="pkg-1", + name="requests", + version="2.31.0", + type="pypi", + score={}, + alerts=[], + direct=True, + url="https://requests.readthedocs.io/", + license="Apache-2.0", + purl="pkg:pypi/requests@2.31.0", + ) + } + diff.new_alerts = [ + Issue( + title="Insufficiently Protected Credentials", + severity="medium", + description="Requests may leak credentials for crafted URLs.", + error=True, + key="GHSA-9hjg-9r4m-mvj7", + type="vulnerability", + pkg_type="pypi", + pkg_name="requests", + pkg_version="2.31.0", + pkg_id="pkg-1", + purl="pkg:pypi/requests@2.31.0", + url="https://socket.dev/pypi/package/requests/alerts/2.31.0", + props={ + "id": 11088938, + "createdAt": "2025-10-08T10:41:05.933Z", + "ghsaId": "GHSA-9hjg-9r4m-mvj7", + "cveId": "CVE-2024-47081", + "cvssScore": 5.3, + "fixedVersion": "2.32.4", + "partialFixDistance": "MINOR", + "completeFixDistance": "MINOR", + "attackVector": "Network", + "attackComplexity": "High", + "privilegesRequired": "None", + "userInteraction": "Required", + "scope": "Unchanged", + "confidentialityImpact": "High", + "integrityImpact": "None", + "availabilityImpact": "None", + "cveStatus": "COMPLETED", + "cwes": ["CWE-522"], + "published": "2025-06-09T19:06:08Z", + "affectedVersionRanges": ["<2.32.4"], + "patchedVersionRanges": ["2.32.4"], + "references": ["https://github.com/advisories/GHSA-9hjg-9r4m-mvj7"], + "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N", + "exploitability": "UNKNOWN", + "epssScore": 0.00154, + "epssPercentile": 0.35957, + "cpes": [], + }, + ) + ] + + payload = build_fossa_report_payload(diff, config) + generated_vulnerability = payload["vulnerability"][0] + + assert sorted(generated_vulnerability.keys()) == sorted(sample_vulnerability.keys()) + assert generated_vulnerability["source"]["packageManager"] == sample_vulnerability["source"]["packageManager"] + assert sorted(generated_vulnerability["source"].keys()) == sorted(sample_vulnerability["source"].keys()) + assert sorted(generated_vulnerability["depths"].keys()) == sorted(sample_vulnerability["depths"].keys()) + assert sorted(generated_vulnerability["statuses"].keys()) == sorted(sample_vulnerability["statuses"].keys()) + assert sorted(generated_vulnerability["remediation"].keys()) == sorted(sample_vulnerability["remediation"].keys()) + assert sorted(generated_vulnerability["epss"].keys()) == sorted(sample_vulnerability["epss"].keys()) diff --git a/tests/unit/test_output.py b/tests/unit/test_output.py index 0fe007e..c5f2e02 100644 --- a/tests/unit/test_output.py +++ b/tests/unit/test_output.py @@ -1,6 +1,6 @@ import pytest from socketsecurity.output import OutputHandler -from socketsecurity.core.classes import Diff, Issue +from socketsecurity.core.classes import Diff, Issue, Package import json class TestOutputHandler: @@ -123,6 +123,219 @@ def test_sbom_file_saving(self, handler, tmp_path): handler.save_sbom_file(diff, str(sbom_path)) assert sbom_path.exists() + def test_sbom_file_saving_without_sbom_writes_empty_array(self, handler, tmp_path): + diff = Diff() + sbom_path = tmp_path / "empty.json" + handler.save_sbom_file(diff, str(sbom_path)) + assert sbom_path.exists() + assert json.loads(sbom_path.read_text()) == [] + + def test_json_file_saving(self, tmp_path): + from socketsecurity.config import CliConfig + from unittest.mock import Mock + + json_path = tmp_path / "report.json" + + config = Mock(spec=CliConfig) + config.disable_blocking = False + config.strict_blocking = False + config.json_file = str(json_path) + config.summary_file = None + config.report_link_file = None + config.sbom_file = None + config.legal = True + config.legal_format = "socket" + config.repo = "owner/repo" + config.branch = "main" + config.commit_sha = "abc123" + config.enable_json = False + config.enable_sarif = False + config.enable_gitlab_security = False + config.enable_debug = False + + handler = OutputHandler(config, Mock()) + + diff = Diff() + diff.id = "scan-123" + diff.diff_url = "https://socket.dev/diff/123" + diff.report_url = "https://socket.dev/report/123" + diff.new_alerts = [ + Issue( + title="Test", + severity="high", + description="desc", + error=True, + key="test-key", + type="vulnerability", + pkg_type="npm", + pkg_name="test-package", + pkg_version="1.0.0", + purl="pkg:npm/test-package@1.0.0", + url="https://socket.dev/npm/package/test-package/alerts/1.0.0", + ) + ] + + handler.save_json_file(diff, str(json_path)) + + saved = json.loads(json_path.read_text()) + assert saved["full_scan_id"] == "scan-123" + assert saved["report_url"] == "https://socket.dev/report/123" + assert saved["repo"] == "owner/repo" + assert saved["branch"] == "main" + assert saved["commit_sha"] == "abc123" + assert saved["legal_mode"] is True + + def test_summary_and_report_link_files_are_written(self, tmp_path): + from socketsecurity.config import CliConfig + from unittest.mock import Mock + + summary_path = tmp_path / "summary.txt" + report_link_path = tmp_path / "report-link.txt" + + config = Mock(spec=CliConfig) + config.disable_blocking = False + config.strict_blocking = False + config.json_file = None + config.summary_file = str(summary_path) + config.report_link_file = str(report_link_path) + config.sbom_file = None + config.legal = False + config.legal_format = "socket" + config.repo = None + config.branch = "" + config.commit_sha = "" + config.enable_json = False + config.enable_sarif = False + config.enable_gitlab_security = False + config.enable_debug = False + + handler = OutputHandler(config, Mock()) + + diff = Diff() + diff.id = "scan-123" + diff.diff_url = "https://socket.dev/diff/123" + diff.report_url = "https://socket.dev/report/123" + diff.new_alerts = [ + Issue( + title="Test", + severity="high", + description="desc", + error=True, + key="test-key", + type="vulnerability", + pkg_type="npm", + pkg_name="test-package", + pkg_version="1.0.0", + purl="pkg:npm/test-package@1.0.0", + url="https://socket.dev/npm/package/test-package/alerts/1.0.0", + ) + ] + + handler.save_summary_file(diff, str(summary_path)) + handler.save_report_link_file(diff, str(report_link_path)) + + assert "Security issues detected by Socket Security:" in summary_path.read_text() + assert report_link_path.read_text().strip() == "https://socket.dev/report/123" + + def test_json_file_saving_in_fossa_format(self, tmp_path): + from socketsecurity.config import CliConfig + from unittest.mock import Mock + + json_path = tmp_path / "fossa-report.json" + + config = Mock(spec=CliConfig) + config.disable_blocking = False + config.strict_blocking = False + config.json_file = str(json_path) + config.summary_file = None + config.report_link_file = None + config.sbom_file = None + config.legal = True + config.legal_format = "fossa" + config.repo = "owner/repo" + config.branch = "refs/heads/main" + config.commit_sha = "abc123" + config.enable_json = False + config.enable_sarif = False + config.enable_gitlab_security = False + config.enable_debug = False + + handler = OutputHandler(config, Mock()) + + diff = Diff() + diff.id = "scan-123" + diff.report_url = "https://socket.dev/report/123" + diff.packages = { + "pkg-1": Package( + id="pkg-1", + name="requests", + version="2.31.0", + type="pypi", + score={}, + alerts=[], + direct=True, + url="https://socket.dev/pypi/package/requests/overview/2.31.0", + license="Apache-2.0", + purl="pkg:pypi/requests@2.31.0", + ) + } + diff.new_alerts = [ + Issue( + title="Prototype Pollution", + severity="high", + description="Upgrade to a fixed version.", + error=True, + key="alert-1", + type="vulnerability", + pkg_type="pypi", + pkg_name="requests", + pkg_version="2.31.0", + pkg_id="pkg-1", + purl="pkg:pypi/requests@2.31.0", + url="https://socket.dev/npm/package/requests/alerts/2.31.0", + props={ + "ghsaId": "GHSA-1234", + "cveId": "CVE-2026-1234", + "cvssScore": 8.2, + "fixedVersion": "2.31.1", + "references": ["https://github.com/advisories/GHSA-1234"], + }, + ), + Issue( + title="License Policy Violation", + severity="medium", + description="Package license violates policy.", + warn=True, + key="license-1", + type="licenseSpdxDisj", + pkg_type="pypi", + pkg_name="requests", + pkg_version="2.31.0", + pkg_id="pkg-1", + purl="pkg:pypi/requests@2.31.0", + url="https://socket.dev/pypi/package/requests/license/2.31.0", + ), + ] + + handler.save_json_file(diff, str(json_path)) + + saved = json.loads(json_path.read_text()) + assert saved["project"] == { + "branch": "refs/heads/main", + "id": "owner/repo-scan-123", + "project": "owner/repo", + "projectId": "owner/repo", + "revision": "scan-123", + "url": "https://socket.dev/report/123", + } + assert len(saved["vulnerability"]) == 1 + assert saved["vulnerability"][0]["vulnId"] == "GHSA-1234" + assert saved["vulnerability"][0]["cve"] == "CVE-2026-1234" + assert saved["vulnerability"][0]["source"]["packageManager"] == "pip" + assert saved["vulnerability"][0]["remediation"]["completeFix"] == "2.31.1" + assert saved["licensing"][0]["type"] == "policy_conflict" + assert saved["quality"] == [] + def test_report_pass_with_strict_blocking_new_alerts(self): """Test that strict-blocking fails on new blocking alerts""" from socketsecurity.config import CliConfig diff --git a/tests/unit/test_socketcli.py b/tests/unit/test_socketcli.py new file mode 100644 index 0000000..67d7114 --- /dev/null +++ b/tests/unit/test_socketcli.py @@ -0,0 +1,109 @@ +from socketsecurity.core.classes import Diff, Package +from socketsecurity.socketcli import build_license_artifact_payload + + +def test_build_license_artifact_payload_without_packages_returns_empty_dict(): + diff = Diff() + + payload = build_license_artifact_payload(diff) + + assert payload == {} + + +def test_build_license_artifact_payload_serializes_package_fields(): + diff = Diff() + diff.packages = { + "pypi/requests@2.31.0": Package( + id="pkg-1", + name="requests", + version="2.31.0", + type="pypi", + score={}, + alerts=[], + direct=True, + url="https://socket.dev/pypi/package/requests/overview/2.31.0", + license="Apache-2.0", + licenseDetails=[{"id": "Apache-2.0"}], + licenseAttrib=[{"id": "Apache-2.0"}], + purl="requests@2.31.0", + ) + } + + payload = build_license_artifact_payload(diff) + + assert payload == { + "pkg-1": { + "id": "pkg-1", + "name": "requests", + "version": "2.31.0", + "ecosystem": "pypi", + "direct": True, + "url": "https://socket.dev/pypi/package/requests/overview/2.31.0", + "license": "Apache-2.0", + "licenseDetails": [{"id": "Apache-2.0"}], + "licenseAttrib": [{"id": "Apache-2.0"}], + "purl": "requests@2.31.0", + } + } + + +def test_build_license_artifact_payload_fossa_format_without_packages(): + class Config: + repo = "owner/repo" + branch = "main" + + diff = Diff(id="scan-1", report_url="https://socket.dev/report/1") + + payload = build_license_artifact_payload(diff, legal_format="fossa", config=Config()) + + assert payload == { + "project": { + "branch": "main", + "id": "owner/repo-scan-1", + "project": "owner/repo", + "projectId": "owner/repo", + "revision": "scan-1", + "url": "https://socket.dev/report/1", + }, + "dependencies": [], + } + + +def test_build_license_artifact_payload_fossa_format_serializes_dependencies(): + class Config: + repo = "owner/repo" + branch = "main" + + diff = Diff(id="scan-1", report_url="https://socket.dev/report/1") + diff.packages = { + "pkg:pypi/requests@2.31.0": Package( + id="pkg-1", + name="requests", + version="2.31.0", + type="pypi", + score={}, + alerts=[], + direct=True, + url="https://socket.dev/pypi/package/requests/overview/2.31.0", + license="Apache-2.0", + licenseDetails=[{"id": "Apache-2.0"}], + licenseAttrib=[{"id": "Apache-2.0"}], + purl="pkg:pypi/requests@2.31.0", + ) + } + + payload = build_license_artifact_payload(diff, legal_format="fossa", config=Config()) + + assert payload["project"]["projectId"] == "owner/repo" + assert payload["dependencies"] == [{ + "id": "pkg-1", + "name": "requests", + "version": "2.31.0", + "ecosystem": "pip", + "direct": True, + "url": "https://socket.dev/pypi/package/requests/overview/2.31.0", + "purl": "pkg:pypi/requests@2.31.0", + "declaredLicense": "Apache-2.0", + "licenseDetails": [{"id": "Apache-2.0"}], + "licenseAttrib": [{"id": "Apache-2.0"}], + }] diff --git a/uv.lock b/uv.lock index a90e6b2..3266b98 100644 --- a/uv.lock +++ b/uv.lock @@ -1168,7 +1168,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.2.86" +version = "2.2.89" source = { editable = "." } dependencies = [ { name = "bs4" },