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