Skip to content

Commit 8cbf2da

Browse files
authored
Doug/add plugins (#76)
* version update * Finished adding Jira support * Updated the read me with Jira details
1 parent 96e6920 commit 8cbf2da

File tree

13 files changed

+296
-13
lines changed

13 files changed

+296
-13
lines changed

README.md

+20
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,26 @@ If you don't want to provide the Socket API Token every time then you can use th
7676
| --timeout | False | | Timeout in seconds for API requests |
7777
| --include-module-folders | False | False | If enabled will include manifest files from folders like node_modules |
7878

79+
#### Plugins
80+
81+
The Python CLI currently Supports the following plugins:
82+
83+
- Jira
84+
85+
##### Jira
86+
87+
| Environment Variable | Required | Default | Description |
88+
|:------------------------|:---------|:--------|:-----------------------------------|
89+
| SOCKET_JIRA_ENABLED | False | false | Enables/Disables the Jira Plugin |
90+
| SOCKET_JIRA_CONFIG_JSON | True | None | Required if the Plugin is enabled. |
91+
92+
Example `SOCKET_JIRA_CONFIG_JSON` value
93+
94+
````json
95+
{"url": "https://REPLACE_ME.atlassian.net", "email": "[email protected]", "api_token": "REPLACE_ME", "project": "REPLACE_ME" }
96+
````
97+
98+
7999
## File Selection Behavior
80100

81101
The CLI determines which files to scan based on the following logic:

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
66

77
[project]
88
name = "socketsecurity"
9-
version = "2.0.48"
9+
version = "2.0.50"
1010
requires-python = ">= 3.10"
1111
license = {"file" = "LICENSE"}
1212
dependencies = [

socketsecurity/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
__author__ = 'socket.dev'
2-
__version__ = '2.0.48'
2+
__version__ = '2.0.50'

socketsecurity/config.py

+25-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,24 @@
11
import argparse
22
import os
3-
from dataclasses import asdict, dataclass
3+
from dataclasses import asdict, dataclass, field
44
from typing import List, Optional
55
from socketsecurity import __version__
66
from socketdev import INTEGRATION_TYPES, IntegrationType
7+
import json
8+
9+
10+
def get_plugin_config_from_env(prefix: str) -> dict:
11+
config_str = os.getenv(f"{prefix}_CONFIG_JSON", "{}")
12+
try:
13+
return json.loads(config_str)
14+
except json.JSONDecodeError:
15+
return {}
16+
17+
@dataclass
18+
class PluginConfig:
19+
enabled: bool = False
20+
levels: List[str] = None
21+
config: Optional[dict] = None
722

823

924
@dataclass
@@ -36,6 +51,8 @@ class CliConfig:
3651
exclude_license_details: bool = False
3752
include_module_folders: bool = False
3853
version: str = __version__
54+
jira_plugin: PluginConfig = field(default_factory=PluginConfig)
55+
3956
@classmethod
4057
def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig':
4158
parser = create_argument_parser()
@@ -78,6 +95,13 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig':
7895
'include_module_folders': args.include_module_folders,
7996
'version': __version__
8097
}
98+
config_args.update({
99+
"jira_plugin": PluginConfig(
100+
enabled=os.getenv("SOCKET_JIRA_ENABLED", "false").lower() == "true",
101+
levels=os.getenv("SOCKET_JIRA_LEVELS", "block,warn").split(","),
102+
config=get_plugin_config_from_env("SOCKET_JIRA")
103+
)
104+
})
81105

82106
if args.owner:
83107
config_args['integration_org_slug'] = args.owner

socketsecurity/core/messages.py

+15-9
Original file line numberDiff line numberDiff line change
@@ -588,25 +588,31 @@ def create_console_security_alert_table(diff: Diff) -> PrettyTable:
588588
def create_sources(alert: Issue, style="md") -> [str, str]:
589589
sources = []
590590
manifests = []
591+
591592
for source, manifest in alert.introduced_by:
592593
if style == "md":
593594
add_str = f"<li>{manifest}</li>"
594595
source_str = f"<li>{source}</li>"
595-
else:
596+
elif style == "plain":
597+
add_str = f"• {manifest}"
598+
source_str = f"• {source}"
599+
else: # raw
596600
add_str = f"{manifest};"
597601
source_str = f"{source};"
602+
598603
if source_str not in sources:
599604
sources.append(source_str)
600605
if add_str not in manifests:
601606
manifests.append(add_str)
602-
manifest_list = "".join(manifests)
603-
source_list = "".join(sources)
604-
source_list = source_list.rstrip(";")
605-
manifest_list = manifest_list.rstrip(";")
607+
606608
if style == "md":
607-
manifest_str = f"<ul>{manifest_list}</ul>"
608-
sources_str = f"<ul>{source_list}</ul>"
609+
manifest_str = f"<ul>{''.join(manifests)}</ul>"
610+
sources_str = f"<ul>{''.join(sources)}</ul>"
611+
elif style == "plain":
612+
manifest_str = "\n".join(manifests)
613+
sources_str = "\n".join(sources)
609614
else:
610-
manifest_str = manifest_list
611-
sources_str = source_list
615+
manifest_str = "".join(manifests).rstrip(";")
616+
sources_str = "".join(sources).rstrip(";")
617+
612618
return manifest_str, sources_str

socketsecurity/output.py

+12-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import json
22
import logging
3-
import sys
43
from pathlib import Path
54
from typing import Any, Dict, Optional
65
from .core.messages import Messages
76
from .core.classes import Diff, Issue
87
from .config import CliConfig
8+
from socketsecurity.plugins.manager import PluginManager
99

1010

1111
class OutputHandler:
@@ -24,6 +24,17 @@ def handle_output(self, diff_report: Diff) -> None:
2424
self.output_console_sarif(diff_report, self.config.sbom_file)
2525
else:
2626
self.output_console_comments(diff_report, self.config.sbom_file)
27+
if hasattr(self.config, "jira_plugin") and self.config.jira_plugin.enabled:
28+
jira_config = {
29+
"enabled": self.config.jira_plugin.enabled,
30+
"levels": self.config.jira_plugin.levels or [],
31+
**(self.config.jira_plugin.config or {})
32+
}
33+
34+
plugin_mgr = PluginManager({"jira": jira_config})
35+
36+
# The Jira plugin knows how to build title + description from diff/config
37+
plugin_mgr.send(diff_report, config=self.config)
2738

2839
self.save_sbom_file(diff_report, self.config.sbom_file)
2940

socketsecurity/plugins/__init__.py

Whitespace-only changes.

socketsecurity/plugins/base.py

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
class Plugin:
2+
def __init__(self, config):
3+
self.config = config
4+
5+
def send(self, message, level):
6+
raise NotImplementedError("Plugin must implement send()")

socketsecurity/plugins/jira.py

+158
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
from .base import Plugin
2+
import requests
3+
import base64
4+
from socketsecurity.core.classes import Diff
5+
from socketsecurity.config import CliConfig
6+
from socketsecurity.core import log
7+
8+
9+
class JiraPlugin(Plugin):
10+
def send(self, diff: Diff, config: CliConfig):
11+
if not self.config.get("enabled", False):
12+
return
13+
log.debug("Jira Plugin Enabled")
14+
alert_levels = self.config.get("levels", ["block", "warn"])
15+
log.debug(f"Alert levels: {alert_levels}")
16+
# has_blocking = any(getattr(a, "blocking", False) for a in diff.new_alerts)
17+
# if "block" not in alert_levels and has_blocking:
18+
# return
19+
# if "warn" not in alert_levels and not has_blocking:
20+
# return
21+
parts = ["Security Issues found in Socket Security results"]
22+
pr = getattr(config, "pr_number", "")
23+
sha = getattr(config, "commit_sha", "")[:8] if getattr(config, "commit_sha", "") else ""
24+
scan_link = getattr(diff, "diff_url", "")
25+
26+
if pr and pr != "0":
27+
parts.append(f"for PR {pr}")
28+
if sha:
29+
parts.append(f"- {sha}")
30+
title = " ".join(parts)
31+
32+
description_adf = {
33+
"type": "doc",
34+
"version": 1,
35+
"content": [
36+
{
37+
"type": "paragraph",
38+
"content": [
39+
{"type": "text", "text": "Security issues were found in this scan:"},
40+
{"type": "text", "text": "\n"},
41+
{
42+
"type": "text",
43+
"text": "View Socket Security scan results",
44+
"marks": [{"type": "link", "attrs": {"href": scan_link}}]
45+
}
46+
]
47+
},
48+
self.create_adf_table_from_diff(diff)
49+
]
50+
}
51+
# log.debug("ADF Description Payload:\n" + json.dumps(description_adf, indent=2))
52+
log.debug("Sending Jira Issue")
53+
# 🛠️ Build and send the Jira issue
54+
url = self.config["url"]
55+
project = self.config["project"]
56+
auth = base64.b64encode(
57+
f"{self.config['email']}:{self.config['api_token']}".encode()
58+
).decode()
59+
60+
payload = {
61+
"fields": {
62+
"project": {"key": project},
63+
"summary": title,
64+
"description": description_adf,
65+
"issuetype": {"name": "Task"}
66+
}
67+
}
68+
69+
headers = {
70+
"Authorization": f"Basic {auth}",
71+
"Content-Type": "application/json"
72+
}
73+
jira_url = f"{url}/rest/api/3/issue"
74+
log.debug(f"Jira URL: {jira_url}")
75+
response = requests.post(jira_url, json=payload, headers=headers)
76+
if response.status_code >= 300:
77+
log.error(f"Jira error {response.status_code}: {response.text}")
78+
else:
79+
log.info(f"Jira ticket created: {response.json().get('key')}")
80+
81+
@staticmethod
82+
def flatten_adf_to_text(adf):
83+
def extract_text(node):
84+
if isinstance(node, dict):
85+
if node.get("type") == "text":
86+
return node.get("text", "")
87+
return "".join(extract_text(child) for child in node.get("content", []))
88+
elif isinstance(node, list):
89+
return "".join(extract_text(child) for child in node)
90+
return ""
91+
92+
return extract_text(adf)
93+
94+
@staticmethod
95+
def create_adf_table_from_diff(diff):
96+
from socketsecurity.core.messages import Messages
97+
98+
def make_cell(text):
99+
return {
100+
"type": "tableCell",
101+
"content": [
102+
{
103+
"type": "paragraph",
104+
"content": [{"type": "text", "text": text}]
105+
}
106+
]
107+
}
108+
109+
def make_link_cell(text, url):
110+
return {
111+
"type": "tableCell",
112+
"content": [
113+
{
114+
"type": "paragraph",
115+
"content": [{
116+
"type": "text",
117+
"text": text,
118+
"marks": [{"type": "link", "attrs": {"href": url}}]
119+
}]
120+
}
121+
]
122+
}
123+
124+
# Header row (must use tableCell not tableHeader!)
125+
header_row = {
126+
"type": "tableRow",
127+
"content": [
128+
make_cell("Alert"),
129+
make_cell("Package"),
130+
make_cell("Introduced by"),
131+
make_cell("Manifest File"),
132+
make_cell("CI")
133+
]
134+
}
135+
136+
rows = [header_row]
137+
138+
for alert in diff.new_alerts:
139+
manifest_str, source_str = Messages.create_sources(alert, "plain")
140+
141+
row = {
142+
"type": "tableRow",
143+
"content": [
144+
make_cell(alert.title),
145+
make_link_cell(alert.purl, alert.url) if alert.url else make_cell(alert.purl),
146+
make_cell(source_str),
147+
make_cell(manifest_str),
148+
make_cell("🚫" if alert.error else "⚠️")
149+
]
150+
}
151+
152+
rows.append(row)
153+
154+
# Final return is a block array
155+
return {
156+
"type": "table",
157+
"content": rows
158+
}

socketsecurity/plugins/manager.py

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from . import jira, webhook, slack, teams
2+
3+
PLUGIN_CLASSES = {
4+
"jira": jira.JiraPlugin,
5+
"slack": slack.SlackPlugin,
6+
"webhook": webhook.WebhookPlugin,
7+
"teams": teams.TeamsPlugin,
8+
}
9+
10+
class PluginManager:
11+
def __init__(self, config):
12+
self.plugins = []
13+
for name, conf in config.items():
14+
if conf.get("enabled"):
15+
plugin_cls = PLUGIN_CLASSES.get(name)
16+
if plugin_cls:
17+
self.plugins.append(plugin_cls(conf))
18+
19+
def send(self, diff, config):
20+
for plugin in self.plugins:
21+
plugin.send(diff, config)

socketsecurity/plugins/slack.py

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from .base import Plugin
2+
import requests
3+
4+
class SlackPlugin(Plugin):
5+
def send(self, message, level):
6+
if not self.config.get("enabled", False):
7+
return
8+
if level not in self.config.get("levels", ["block", "warn"]):
9+
return
10+
11+
payload = {"text": message.get("title", "No title")}
12+
requests.post(self.config["webhook_url"], json=payload)

socketsecurity/plugins/teams.py

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from .base import Plugin
2+
import requests
3+
4+
class TeamsPlugin(Plugin):
5+
def send(self, message, level):
6+
if not self.config.get("enabled", False):
7+
return
8+
if level not in self.config.get("levels", ["block", "warn"]):
9+
return
10+
11+
payload = {"text": message.get("title", "No title")}
12+
requests.post(self.config["webhook_url"], json=payload)

socketsecurity/plugins/webhook.py

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from .base import Plugin
2+
import requests
3+
4+
class WebhookPlugin(Plugin):
5+
def send(self, message, level):
6+
if not self.config.get("enabled", False):
7+
return
8+
if level not in self.config.get("levels", ["block", "warn"]):
9+
return
10+
11+
url = self.config["url"]
12+
headers = self.config.get("headers", {"Content-Type": "application/json"})
13+
requests.post(url, json=message, headers=headers)

0 commit comments

Comments
 (0)