diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 023baaa..6e9694e 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '1.0.43' +__version__ = '1.0.44' diff --git a/socketsecurity/core/messages.py b/socketsecurity/core/messages.py index 28295e5..f914bb9 100644 --- a/socketsecurity/core/messages.py +++ b/socketsecurity/core/messages.py @@ -1,6 +1,9 @@ import json import os +import re +import json +from pathlib import Path from mdutils import MdUtils from socketsecurity.core.classes import Diff, Purl, Issue from prettytable import PrettyTable @@ -12,6 +15,10 @@ class Messages: def map_severity_to_sarif(severity: str) -> str: """ Map Socket severity levels to SARIF levels (GitHub code scanning). + + 'low' -> 'note' + 'medium' or 'middle' -> 'warning' + 'high' or 'critical' -> 'error' """ severity_mapping = { "low": "note", @@ -22,39 +29,147 @@ def map_severity_to_sarif(severity: str) -> str: } return severity_mapping.get(severity.lower(), "note") - @staticmethod - def find_line_in_file(pkg_name: str, manifest_file: str) -> tuple[int, str]: + def find_line_in_file(packagename: str, packageversion: str, manifest_file: str) -> tuple: """ - Search 'manifest_file' for 'pkg_name'. - Return (line_number, line_content) if found, else (1, fallback). + Finds the line number and snippet of code for the given package/version in a manifest file. + Returns a 2-tuple: (line_number, snippet_or_message). + + Supports: + 1) JSON-based manifest files (package-lock.json, Pipfile.lock, composer.lock) + - Locates a dictionary entry with the matching package & version + - Does a rough line-based search to find the actual line in the raw text + 2) Text-based (requirements.txt, package.json, yarn.lock, etc.) + - Uses compiled regex patterns to detect a match line by line """ - if not manifest_file or not os.path.isfile(manifest_file): - return 1, f"[No {manifest_file or 'manifest'} found in repo]" + # Extract just the file name to detect manifest type + file_type = Path(manifest_file).name + + # ---------------------------------------------------- + # 1) JSON-based manifest files + # ---------------------------------------------------- + if file_type in ["package-lock.json", "Pipfile.lock", "composer.lock"]: + try: + # Read entire file so we can parse JSON and also do raw line checks + with open(manifest_file, "r", encoding="utf-8") as f: + raw_text = f.read() + + # Attempt JSON parse + data = json.loads(raw_text) + + # In practice, you may need to check data["dependencies"], data["default"], etc. + # This is an example approach. + packages_dict = ( + data.get("packages") + or data.get("default") + or data.get("dependencies") + or {} + ) + + found_key = None + found_info = None + # Locate a dictionary entry whose 'version' matches + for key, value in packages_dict.items(): + # For NPM package-lock, keys might look like "node_modules/axios" + if key.endswith(packagename) and "version" in value: + if value["version"] == packageversion: + found_key = key + found_info = value + break + + if found_key and found_info: + # Search lines to approximate the correct line number + needle_key = f'"{found_key}":' # e.g. "node_modules/axios": + needle_version = f'"version": "{packageversion}"' + lines = raw_text.splitlines() + best_line = -1 + snippet = None + + for i, line in enumerate(lines, start=1): + if (needle_key in line) or (needle_version in line): + best_line = i + snippet = line.strip() + break # On first match, stop + + # If we found an approximate line, return it; else fallback to line 1 + if best_line > 0 and snippet: + return best_line, snippet + else: + return 1, f'"{found_key}": {found_info}' + else: + return -1, f"{packagename} {packageversion} (not found in {manifest_file})" + + except (FileNotFoundError, json.JSONDecodeError): + return -1, f"Error reading {manifest_file}" + + # ---------------------------------------------------- + # 2) Text-based / line-based manifests + # ---------------------------------------------------- + # Define a dictionary of patterns for common manifest types + search_patterns = { + "package.json": rf'"{packagename}":\s*"{packageversion}"', + "yarn.lock": rf'{packagename}@{packageversion}', + "pnpm-lock.yaml": rf'"{re.escape(packagename)}"\s*:\s*\{{[^}}]*"version":\s*"{re.escape(packageversion)}"', + "requirements.txt": rf'^{re.escape(packagename)}\s*(?:==|===|!=|>=|<=|~=|\s+)?\s*{re.escape(packageversion)}(?:\s*;.*)?$', + "pyproject.toml": rf'{packagename}\s*=\s*"{packageversion}"', + "Pipfile": rf'"{packagename}"\s*=\s*"{packageversion}"', + "go.mod": rf'require\s+{re.escape(packagename)}\s+{re.escape(packageversion)}', + "go.sum": rf'{re.escape(packagename)}\s+{re.escape(packageversion)}', + "pom.xml": rf'{re.escape(packagename)}\s*{re.escape(packageversion)}', + "build.gradle": rf'implementation\s+"{re.escape(packagename)}:{re.escape(packageversion)}"', + "Gemfile": rf'gem\s+"{re.escape(packagename)}",\s*"{re.escape(packageversion)}"', + "Gemfile.lock": rf'\s+{re.escape(packagename)}\s+\({re.escape(packageversion)}\)', + ".csproj": rf'', + ".fsproj": rf'', + "paket.dependencies": rf'nuget\s+{re.escape(packagename)}\s+{re.escape(packageversion)}', + "Cargo.toml": rf'{re.escape(packagename)}\s*=\s*"{re.escape(packageversion)}"', + "build.sbt": rf'"{re.escape(packagename)}"\s*%\s*"{re.escape(packageversion)}"', + "Podfile": rf'pod\s+"{re.escape(packagename)}",\s*"{re.escape(packageversion)}"', + "Package.swift": rf'\.package\(name:\s*"{re.escape(packagename)}",\s*url:\s*".*?",\s*version:\s*"{re.escape(packageversion)}"\)', + "mix.exs": rf'\{{:{re.escape(packagename)},\s*"{re.escape(packageversion)}"\}}', + "composer.json": rf'"{re.escape(packagename)}":\s*"{re.escape(packageversion)}"', + "conanfile.txt": rf'{re.escape(packagename)}/{re.escape(packageversion)}', + "vcpkg.json": rf'"{re.escape(packagename)}":\s*"{re.escape(packageversion)}"', + } + + # If no specific pattern is found for this file name, fallback to a naive approach + searchstring = search_patterns.get(file_type, rf'{re.escape(packagename)}.*{re.escape(packageversion)}') + try: - with open(manifest_file, "r", encoding="utf-8") as f: - lines = f.readlines() - for i, line in enumerate(lines, start=1): - if pkg_name.lower() in line.lower(): - return i, line.rstrip("\n") + # Read file lines and search for a match + with open(manifest_file, 'r', encoding="utf-8") as file: + lines = [line.rstrip("\n") for line in file] + for line_number, line_content in enumerate(lines, start=1): + # For Python conditional dependencies, ignore everything after first ';' + line_main = line_content.split(";", 1)[0].strip() + + # Use a case-insensitive regex search + if re.search(searchstring, line_main, re.IGNORECASE): + return line_number, line_content.strip() + + except FileNotFoundError: + return -1, f"{manifest_file} not found" except Exception as e: - return 1, f"[Error reading {manifest_file}: {e}]" - return 1, f"[Package '{pkg_name}' not found in {manifest_file}]" - + return -1, f"Error reading {manifest_file}: {e}" + + return -1, f"{packagename} {packageversion} (not found)" + @staticmethod def create_security_comment_sarif(diff: Diff) -> dict: """ - Create SARIF-compliant output from the diff report. + Create SARIF-compliant output from the diff report, including line references + and a link to the Socket docs in the fullDescription. Also converts any \r\n + into
so they render properly in GitHub's SARIF display. """ + # Check if there's a blocking error in new alerts scan_failed = False if len(diff.new_alerts) == 0: for alert in diff.new_alerts: - alert: Issue if alert.error: scan_failed = True break - # Basic SARIF structure + # Basic SARIF skeleton sarif_data = { "$schema": "https://json.schemastore.org/sarif-2.1.0.json", "version": "2.1.0", @@ -76,38 +191,45 @@ def create_security_comment_sarif(diff: Diff) -> dict: results_list = [] for alert in diff.new_alerts: - alert: Issue pkg_name = alert.pkg_name pkg_version = alert.pkg_version rule_id = f"{pkg_name}=={pkg_version}" severity = alert.severity - # Title and descriptions - title = f"Alert generated for {pkg_name}=={pkg_version} by Socket Security" - full_desc = f"{alert.title} - {alert.description}" - short_desc = f"{alert.props.get('note', '')}\r\n\r\nSuggested Action:\r\n{alert.suggestion}" + # Convert any \r\n in short desc to
so they display properly + short_desc_raw = f"{alert.props.get('note', '')}\r\n\r\nSuggested Action:\r\n{alert.suggestion}" + short_desc = short_desc_raw.replace("\r\n", "
") - # Find the manifest file and line details + # Build link to Socket docs, e.g. "https://socket.dev/npm/package/foo/alerts/1.2.3" + socket_url = f"https://socket.dev/npm/package/{pkg_name}/alerts/{pkg_version}" + + # Also convert \r\n in the main description to
, then append the Socket docs link + base_desc = alert.description.replace("\r\n", "
") + full_desc_raw = f"{alert.title} - {base_desc}
{socket_url}" + + # Identify the manifest file and line introduced_list = alert.introduced_by if introduced_list and isinstance(introduced_list[0], list) and len(introduced_list[0]) > 1: manifest_file = introduced_list[0][1] else: manifest_file = alert.manifests or "requirements.txt" - line_number, line_content = Messages.find_line_in_file(pkg_name, manifest_file) + line_number, line_content = Messages.find_line_in_file(pkg_name, pkg_version, manifest_file) - # Define the rule if not already defined + # If not already defined, create a rule for this package if rule_id not in rules_map: rules_map[rule_id] = { "id": rule_id, "name": f"{pkg_name}=={pkg_version}", - "shortDescription": {"text": title}, - "fullDescription": {"text": full_desc}, + "shortDescription": {"text": f"Alert generated for {rule_id} by Socket Security"}, + "fullDescription": {"text": full_desc_raw}, "helpUri": alert.url, - "defaultConfiguration": {"level": Messages.map_severity_to_sarif(severity)}, + "defaultConfiguration": { + "level": Messages.map_severity_to_sarif(severity) + }, } - # Add the result + # Create a SARIF "result" referencing the line where we found the match result_obj = { "ruleId": rule_id, "message": {"text": short_desc}, @@ -125,6 +247,7 @@ def create_security_comment_sarif(diff: Diff) -> dict: } results_list.append(result_obj) + # Attach our rules and results to the SARIF data sarif_data["runs"][0]["tool"]["driver"]["rules"] = list(rules_map.values()) sarif_data["runs"][0]["results"] = results_list