diff --git a/README.md b/README.md index d03b812..0e95f6a 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,26 @@ If you don't want to provide the Socket API Token every time then you can use th | --timeout | False | | Timeout in seconds for API requests | | --include-module-folders | False | False | If enabled will include manifest files from folders like node_modules | +#### Plugins + +The Python CLI currently Supports the following plugins: + +- Jira + +##### Jira + +| Environment Variable | Required | Default | Description | +|:------------------------|:---------|:--------|:-----------------------------------| +| SOCKET_JIRA_ENABLED | False | false | Enables/Disables the Jira Plugin | +| SOCKET_JIRA_CONFIG_JSON | True | None | Required if the Plugin is enabled. | + +Example `SOCKET_JIRA_CONFIG_JSON` value + +````json +{"url": "https://REPLACE_ME.atlassian.net", "email": "example@example.com", "api_token": "REPLACE_ME", "project": "REPLACE_ME" } +```` + + ## File Selection Behavior The CLI determines which files to scan based on the following logic: diff --git a/pyproject.toml b/pyproject.toml index 4355a42..028ddae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.0.48" +version = "2.0.50" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 92c21d0..45c32f5 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.0.48' +__version__ = '2.0.50' diff --git a/socketsecurity/config.py b/socketsecurity/config.py index 2d3aece..42b4684 100644 --- a/socketsecurity/config.py +++ b/socketsecurity/config.py @@ -1,9 +1,24 @@ import argparse import os -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, field from typing import List, Optional from socketsecurity import __version__ from socketdev import INTEGRATION_TYPES, IntegrationType +import json + + +def get_plugin_config_from_env(prefix: str) -> dict: + config_str = os.getenv(f"{prefix}_CONFIG_JSON", "{}") + try: + return json.loads(config_str) + except json.JSONDecodeError: + return {} + +@dataclass +class PluginConfig: + enabled: bool = False + levels: List[str] = None + config: Optional[dict] = None @dataclass @@ -36,6 +51,8 @@ class CliConfig: exclude_license_details: bool = False include_module_folders: bool = False version: str = __version__ + jira_plugin: PluginConfig = field(default_factory=PluginConfig) + @classmethod def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': parser = create_argument_parser() @@ -78,6 +95,13 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': 'include_module_folders': args.include_module_folders, 'version': __version__ } + config_args.update({ + "jira_plugin": PluginConfig( + enabled=os.getenv("SOCKET_JIRA_ENABLED", "false").lower() == "true", + levels=os.getenv("SOCKET_JIRA_LEVELS", "block,warn").split(","), + config=get_plugin_config_from_env("SOCKET_JIRA") + ) + }) if args.owner: config_args['integration_org_slug'] = args.owner diff --git a/socketsecurity/core/messages.py b/socketsecurity/core/messages.py index c54df14..db4c85f 100644 --- a/socketsecurity/core/messages.py +++ b/socketsecurity/core/messages.py @@ -588,25 +588,31 @@ def create_console_security_alert_table(diff: Diff) -> PrettyTable: def create_sources(alert: Issue, style="md") -> [str, str]: sources = [] manifests = [] + for source, manifest in alert.introduced_by: if style == "md": add_str = f"
  • {manifest}
  • " source_str = f"
  • {source}
  • " - else: + elif style == "plain": + add_str = f"• {manifest}" + source_str = f"• {source}" + else: # raw add_str = f"{manifest};" source_str = f"{source};" + if source_str not in sources: sources.append(source_str) if add_str not in manifests: manifests.append(add_str) - manifest_list = "".join(manifests) - source_list = "".join(sources) - source_list = source_list.rstrip(";") - manifest_list = manifest_list.rstrip(";") + if style == "md": - manifest_str = f"" - sources_str = f"" + manifest_str = f"" + sources_str = f"" + elif style == "plain": + manifest_str = "\n".join(manifests) + sources_str = "\n".join(sources) else: - manifest_str = manifest_list - sources_str = source_list + manifest_str = "".join(manifests).rstrip(";") + sources_str = "".join(sources).rstrip(";") + return manifest_str, sources_str diff --git a/socketsecurity/output.py b/socketsecurity/output.py index e4d3649..61b8f76 100644 --- a/socketsecurity/output.py +++ b/socketsecurity/output.py @@ -1,11 +1,11 @@ import json import logging -import sys from pathlib import Path from typing import Any, Dict, Optional from .core.messages import Messages from .core.classes import Diff, Issue from .config import CliConfig +from socketsecurity.plugins.manager import PluginManager class OutputHandler: @@ -24,6 +24,17 @@ def handle_output(self, diff_report: Diff) -> None: self.output_console_sarif(diff_report, self.config.sbom_file) else: self.output_console_comments(diff_report, self.config.sbom_file) + if hasattr(self.config, "jira_plugin") and self.config.jira_plugin.enabled: + jira_config = { + "enabled": self.config.jira_plugin.enabled, + "levels": self.config.jira_plugin.levels or [], + **(self.config.jira_plugin.config or {}) + } + + plugin_mgr = PluginManager({"jira": jira_config}) + + # The Jira plugin knows how to build title + description from diff/config + plugin_mgr.send(diff_report, config=self.config) self.save_sbom_file(diff_report, self.config.sbom_file) diff --git a/socketsecurity/plugins/__init__.py b/socketsecurity/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/socketsecurity/plugins/base.py b/socketsecurity/plugins/base.py new file mode 100644 index 0000000..3aac71c --- /dev/null +++ b/socketsecurity/plugins/base.py @@ -0,0 +1,6 @@ +class Plugin: + def __init__(self, config): + self.config = config + + def send(self, message, level): + raise NotImplementedError("Plugin must implement send()") \ No newline at end of file diff --git a/socketsecurity/plugins/jira.py b/socketsecurity/plugins/jira.py new file mode 100644 index 0000000..7dc6fe0 --- /dev/null +++ b/socketsecurity/plugins/jira.py @@ -0,0 +1,158 @@ +from .base import Plugin +import requests +import base64 +from socketsecurity.core.classes import Diff +from socketsecurity.config import CliConfig +from socketsecurity.core import log + + +class JiraPlugin(Plugin): + def send(self, diff: Diff, config: CliConfig): + if not self.config.get("enabled", False): + return + log.debug("Jira Plugin Enabled") + alert_levels = self.config.get("levels", ["block", "warn"]) + log.debug(f"Alert levels: {alert_levels}") + # has_blocking = any(getattr(a, "blocking", False) for a in diff.new_alerts) + # if "block" not in alert_levels and has_blocking: + # return + # if "warn" not in alert_levels and not has_blocking: + # return + parts = ["Security Issues found in Socket Security results"] + pr = getattr(config, "pr_number", "") + sha = getattr(config, "commit_sha", "")[:8] if getattr(config, "commit_sha", "") else "" + scan_link = getattr(diff, "diff_url", "") + + if pr and pr != "0": + parts.append(f"for PR {pr}") + if sha: + parts.append(f"- {sha}") + title = " ".join(parts) + + description_adf = { + "type": "doc", + "version": 1, + "content": [ + { + "type": "paragraph", + "content": [ + {"type": "text", "text": "Security issues were found in this scan:"}, + {"type": "text", "text": "\n"}, + { + "type": "text", + "text": "View Socket Security scan results", + "marks": [{"type": "link", "attrs": {"href": scan_link}}] + } + ] + }, + self.create_adf_table_from_diff(diff) + ] + } + # log.debug("ADF Description Payload:\n" + json.dumps(description_adf, indent=2)) + log.debug("Sending Jira Issue") + # 🛠️ Build and send the Jira issue + url = self.config["url"] + project = self.config["project"] + auth = base64.b64encode( + f"{self.config['email']}:{self.config['api_token']}".encode() + ).decode() + + payload = { + "fields": { + "project": {"key": project}, + "summary": title, + "description": description_adf, + "issuetype": {"name": "Task"} + } + } + + headers = { + "Authorization": f"Basic {auth}", + "Content-Type": "application/json" + } + jira_url = f"{url}/rest/api/3/issue" + log.debug(f"Jira URL: {jira_url}") + response = requests.post(jira_url, json=payload, headers=headers) + if response.status_code >= 300: + log.error(f"Jira error {response.status_code}: {response.text}") + else: + log.info(f"Jira ticket created: {response.json().get('key')}") + + @staticmethod + def flatten_adf_to_text(adf): + def extract_text(node): + if isinstance(node, dict): + if node.get("type") == "text": + return node.get("text", "") + return "".join(extract_text(child) for child in node.get("content", [])) + elif isinstance(node, list): + return "".join(extract_text(child) for child in node) + return "" + + return extract_text(adf) + + @staticmethod + def create_adf_table_from_diff(diff): + from socketsecurity.core.messages import Messages + + def make_cell(text): + return { + "type": "tableCell", + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": text}] + } + ] + } + + def make_link_cell(text, url): + return { + "type": "tableCell", + "content": [ + { + "type": "paragraph", + "content": [{ + "type": "text", + "text": text, + "marks": [{"type": "link", "attrs": {"href": url}}] + }] + } + ] + } + + # Header row (must use tableCell not tableHeader!) + header_row = { + "type": "tableRow", + "content": [ + make_cell("Alert"), + make_cell("Package"), + make_cell("Introduced by"), + make_cell("Manifest File"), + make_cell("CI") + ] + } + + rows = [header_row] + + for alert in diff.new_alerts: + manifest_str, source_str = Messages.create_sources(alert, "plain") + + row = { + "type": "tableRow", + "content": [ + make_cell(alert.title), + make_link_cell(alert.purl, alert.url) if alert.url else make_cell(alert.purl), + make_cell(source_str), + make_cell(manifest_str), + make_cell("🚫" if alert.error else "⚠️") + ] + } + + rows.append(row) + + # Final return is a block array + return { + "type": "table", + "content": rows + } diff --git a/socketsecurity/plugins/manager.py b/socketsecurity/plugins/manager.py new file mode 100644 index 0000000..b2397d1 --- /dev/null +++ b/socketsecurity/plugins/manager.py @@ -0,0 +1,21 @@ +from . import jira, webhook, slack, teams + +PLUGIN_CLASSES = { + "jira": jira.JiraPlugin, + "slack": slack.SlackPlugin, + "webhook": webhook.WebhookPlugin, + "teams": teams.TeamsPlugin, +} + +class PluginManager: + def __init__(self, config): + self.plugins = [] + for name, conf in config.items(): + if conf.get("enabled"): + plugin_cls = PLUGIN_CLASSES.get(name) + if plugin_cls: + self.plugins.append(plugin_cls(conf)) + + def send(self, diff, config): + for plugin in self.plugins: + plugin.send(diff, config) \ No newline at end of file diff --git a/socketsecurity/plugins/slack.py b/socketsecurity/plugins/slack.py new file mode 100644 index 0000000..bcd6efb --- /dev/null +++ b/socketsecurity/plugins/slack.py @@ -0,0 +1,12 @@ +from .base import Plugin +import requests + +class SlackPlugin(Plugin): + def send(self, message, level): + if not self.config.get("enabled", False): + return + if level not in self.config.get("levels", ["block", "warn"]): + return + + payload = {"text": message.get("title", "No title")} + requests.post(self.config["webhook_url"], json=payload) \ No newline at end of file diff --git a/socketsecurity/plugins/teams.py b/socketsecurity/plugins/teams.py new file mode 100644 index 0000000..def9522 --- /dev/null +++ b/socketsecurity/plugins/teams.py @@ -0,0 +1,12 @@ +from .base import Plugin +import requests + +class TeamsPlugin(Plugin): + def send(self, message, level): + if not self.config.get("enabled", False): + return + if level not in self.config.get("levels", ["block", "warn"]): + return + + payload = {"text": message.get("title", "No title")} + requests.post(self.config["webhook_url"], json=payload) \ No newline at end of file diff --git a/socketsecurity/plugins/webhook.py b/socketsecurity/plugins/webhook.py new file mode 100644 index 0000000..4793c67 --- /dev/null +++ b/socketsecurity/plugins/webhook.py @@ -0,0 +1,13 @@ +from .base import Plugin +import requests + +class WebhookPlugin(Plugin): + def send(self, message, level): + if not self.config.get("enabled", False): + return + if level not in self.config.get("levels", ["block", "warn"]): + return + + url = self.config["url"] + headers = self.config.get("headers", {"Content-Type": "application/json"}) + requests.post(url, json=message, headers=headers) \ No newline at end of file