Skip to content

Doug/add plugins #76

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Apr 15, 2025
Merged
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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "[email protected]", "api_token": "REPLACE_ME", "project": "REPLACE_ME" }
````


## File Selection Behavior

The CLI determines which files to scan based on the following logic:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
2 changes: 1 addition & 1 deletion socketsecurity/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
__author__ = 'socket.dev'
__version__ = '2.0.48'
__version__ = '2.0.50'
26 changes: 25 additions & 1 deletion socketsecurity/config.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
24 changes: 15 additions & 9 deletions socketsecurity/core/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<li>{manifest}</li>"
source_str = f"<li>{source}</li>"
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"<ul>{manifest_list}</ul>"
sources_str = f"<ul>{source_list}</ul>"
manifest_str = f"<ul>{''.join(manifests)}</ul>"
sources_str = f"<ul>{''.join(sources)}</ul>"
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
13 changes: 12 additions & 1 deletion socketsecurity/output.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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)

Expand Down
Empty file.
6 changes: 6 additions & 0 deletions socketsecurity/plugins/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class Plugin:
def __init__(self, config):
self.config = config

def send(self, message, level):
raise NotImplementedError("Plugin must implement send()")
158 changes: 158 additions & 0 deletions socketsecurity/plugins/jira.py
Original file line number Diff line number Diff line change
@@ -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
}
21 changes: 21 additions & 0 deletions socketsecurity/plugins/manager.py
Original file line number Diff line number Diff line change
@@ -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)
12 changes: 12 additions & 0 deletions socketsecurity/plugins/slack.py
Original file line number Diff line number Diff line change
@@ -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)
12 changes: 12 additions & 0 deletions socketsecurity/plugins/teams.py
Original file line number Diff line number Diff line change
@@ -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)
13 changes: 13 additions & 0 deletions socketsecurity/plugins/webhook.py
Original file line number Diff line number Diff line change
@@ -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)
Loading