11import json
22import os
3+ import re
4+ import json
35
6+ from pathlib import Path
47from mdutils import MdUtils
58from socketsecurity .core .classes import Diff , Purl , Issue
69from prettytable import PrettyTable
@@ -12,6 +15,10 @@ class Messages:
1215 def map_severity_to_sarif (severity : str ) -> str :
1316 """
1417 Map Socket severity levels to SARIF levels (GitHub code scanning).
18+
19+ 'low' -> 'note'
20+ 'medium' or 'middle' -> 'warning'
21+ 'high' or 'critical' -> 'error'
1522 """
1623 severity_mapping = {
1724 "low" : "note" ,
@@ -22,39 +29,147 @@ def map_severity_to_sarif(severity: str) -> str:
2229 }
2330 return severity_mapping .get (severity .lower (), "note" )
2431
25-
2632 @staticmethod
27- def find_line_in_file (pkg_name : str , manifest_file : str ) -> tuple [ int , str ] :
33+ def find_line_in_file (packagename : str , packageversion : str , manifest_file : str ) -> tuple :
2834 """
29- Search 'manifest_file' for 'pkg_name'.
30- Return (line_number, line_content) if found, else (1, fallback).
35+ Finds the line number and snippet of code for the given package/version in a manifest file.
36+ Returns a 2-tuple: (line_number, snippet_or_message).
37+
38+ Supports:
39+ 1) JSON-based manifest files (package-lock.json, Pipfile.lock, composer.lock)
40+ - Locates a dictionary entry with the matching package & version
41+ - Does a rough line-based search to find the actual line in the raw text
42+ 2) Text-based (requirements.txt, package.json, yarn.lock, etc.)
43+ - Uses compiled regex patterns to detect a match line by line
3144 """
32- if not manifest_file or not os .path .isfile (manifest_file ):
33- return 1 , f"[No { manifest_file or 'manifest' } found in repo]"
45+ # Extract just the file name to detect manifest type
46+ file_type = Path (manifest_file ).name
47+
48+ # ----------------------------------------------------
49+ # 1) JSON-based manifest files
50+ # ----------------------------------------------------
51+ if file_type in ["package-lock.json" , "Pipfile.lock" , "composer.lock" ]:
52+ try :
53+ # Read entire file so we can parse JSON and also do raw line checks
54+ with open (manifest_file , "r" , encoding = "utf-8" ) as f :
55+ raw_text = f .read ()
56+
57+ # Attempt JSON parse
58+ data = json .loads (raw_text )
59+
60+ # In practice, you may need to check data["dependencies"], data["default"], etc.
61+ # This is an example approach.
62+ packages_dict = (
63+ data .get ("packages" )
64+ or data .get ("default" )
65+ or data .get ("dependencies" )
66+ or {}
67+ )
68+
69+ found_key = None
70+ found_info = None
71+ # Locate a dictionary entry whose 'version' matches
72+ for key , value in packages_dict .items ():
73+ # For NPM package-lock, keys might look like "node_modules/axios"
74+ if key .endswith (packagename ) and "version" in value :
75+ if value ["version" ] == packageversion :
76+ found_key = key
77+ found_info = value
78+ break
79+
80+ if found_key and found_info :
81+ # Search lines to approximate the correct line number
82+ needle_key = f'"{ found_key } ":' # e.g. "node_modules/axios":
83+ needle_version = f'"version": "{ packageversion } "'
84+ lines = raw_text .splitlines ()
85+ best_line = - 1
86+ snippet = None
87+
88+ for i , line in enumerate (lines , start = 1 ):
89+ if (needle_key in line ) or (needle_version in line ):
90+ best_line = i
91+ snippet = line .strip ()
92+ break # On first match, stop
93+
94+ # If we found an approximate line, return it; else fallback to line 1
95+ if best_line > 0 and snippet :
96+ return best_line , snippet
97+ else :
98+ return 1 , f'"{ found_key } ": { found_info } '
99+ else :
100+ return - 1 , f"{ packagename } { packageversion } (not found in { manifest_file } )"
101+
102+ except (FileNotFoundError , json .JSONDecodeError ):
103+ return - 1 , f"Error reading { manifest_file } "
104+
105+ # ----------------------------------------------------
106+ # 2) Text-based / line-based manifests
107+ # ----------------------------------------------------
108+ # Define a dictionary of patterns for common manifest types
109+ search_patterns = {
110+ "package.json" : rf'"{ packagename } ":\s*"{ packageversion } "' ,
111+ "yarn.lock" : rf'{ packagename } @{ packageversion } ' ,
112+ "pnpm-lock.yaml" : rf'"{ re .escape (packagename )} "\s*:\s*\{{[^}}]*"version":\s*"{ re .escape (packageversion )} "' ,
113+ "requirements.txt" : rf'^{ re .escape (packagename )} \s*(?:==|===|!=|>=|<=|~=|\s+)?\s*{ re .escape (packageversion )} (?:\s*;.*)?$' ,
114+ "pyproject.toml" : rf'{ packagename } \s*=\s*"{ packageversion } "' ,
115+ "Pipfile" : rf'"{ packagename } "\s*=\s*"{ packageversion } "' ,
116+ "go.mod" : rf'require\s+{ re .escape (packagename )} \s+{ re .escape (packageversion )} ' ,
117+ "go.sum" : rf'{ re .escape (packagename )} \s+{ re .escape (packageversion )} ' ,
118+ "pom.xml" : rf'<artifactId>{ re .escape (packagename )} </artifactId>\s*<version>{ re .escape (packageversion )} </version>' ,
119+ "build.gradle" : rf'implementation\s+"{ re .escape (packagename )} :{ re .escape (packageversion )} "' ,
120+ "Gemfile" : rf'gem\s+"{ re .escape (packagename )} ",\s*"{ re .escape (packageversion )} "' ,
121+ "Gemfile.lock" : rf'\s+{ re .escape (packagename )} \s+\({ re .escape (packageversion )} \)' ,
122+ ".csproj" : rf'<PackageReference\s+Include="{ re .escape (packagename )} "\s+Version="{ re .escape (packageversion )} "\s*/>' ,
123+ ".fsproj" : rf'<PackageReference\s+Include="{ re .escape (packagename )} "\s+Version="{ re .escape (packageversion )} "\s*/>' ,
124+ "paket.dependencies" : rf'nuget\s+{ re .escape (packagename )} \s+{ re .escape (packageversion )} ' ,
125+ "Cargo.toml" : rf'{ re .escape (packagename )} \s*=\s*"{ re .escape (packageversion )} "' ,
126+ "build.sbt" : rf'"{ re .escape (packagename )} "\s*%\s*"{ re .escape (packageversion )} "' ,
127+ "Podfile" : rf'pod\s+"{ re .escape (packagename )} ",\s*"{ re .escape (packageversion )} "' ,
128+ "Package.swift" : rf'\.package\(name:\s*"{ re .escape (packagename )} ",\s*url:\s*".*?",\s*version:\s*"{ re .escape (packageversion )} "\)' ,
129+ "mix.exs" : rf'\{{:{ re .escape (packagename )} ,\s*"{ re .escape (packageversion )} "\}}' ,
130+ "composer.json" : rf'"{ re .escape (packagename )} ":\s*"{ re .escape (packageversion )} "' ,
131+ "conanfile.txt" : rf'{ re .escape (packagename )} /{ re .escape (packageversion )} ' ,
132+ "vcpkg.json" : rf'"{ re .escape (packagename )} ":\s*"{ re .escape (packageversion )} "' ,
133+ }
134+
135+ # If no specific pattern is found for this file name, fallback to a naive approach
136+ searchstring = search_patterns .get (file_type , rf'{ re .escape (packagename )} .*{ re .escape (packageversion )} ' )
137+
34138 try :
35- with open (manifest_file , "r" , encoding = "utf-8" ) as f :
36- lines = f .readlines ()
37- for i , line in enumerate (lines , start = 1 ):
38- if pkg_name .lower () in line .lower ():
39- return i , line .rstrip ("\n " )
139+ # Read file lines and search for a match
140+ with open (manifest_file , 'r' , encoding = "utf-8" ) as file :
141+ lines = [line .rstrip ("\n " ) for line in file ]
142+ for line_number , line_content in enumerate (lines , start = 1 ):
143+ # For Python conditional dependencies, ignore everything after first ';'
144+ line_main = line_content .split (";" , 1 )[0 ].strip ()
145+
146+ # Use a case-insensitive regex search
147+ if re .search (searchstring , line_main , re .IGNORECASE ):
148+ return line_number , line_content .strip ()
149+
150+ except FileNotFoundError :
151+ return - 1 , f"{ manifest_file } not found"
40152 except Exception as e :
41- return 1 , f"[Error reading { manifest_file } : { e } ]"
42- return 1 , f"[Package '{ pkg_name } ' not found in { manifest_file } ]"
43-
153+ return - 1 , f"Error reading { manifest_file } : { e } "
154+
155+ return - 1 , f"{ packagename } { packageversion } (not found)"
156+
44157 @staticmethod
45158 def create_security_comment_sarif (diff : Diff ) -> dict :
46159 """
47- Create SARIF-compliant output from the diff report.
160+ Create SARIF-compliant output from the diff report, including line references
161+ and a link to the Socket docs in the fullDescription. Also converts any \r \n
162+ into <br/> so they render properly in GitHub's SARIF display.
48163 """
164+ # Check if there's a blocking error in new alerts
49165 scan_failed = False
50166 if len (diff .new_alerts ) == 0 :
51167 for alert in diff .new_alerts :
52- alert : Issue
53168 if alert .error :
54169 scan_failed = True
55170 break
56171
57- # Basic SARIF structure
172+ # Basic SARIF skeleton
58173 sarif_data = {
59174 "$schema" : "https://json.schemastore.org/sarif-2.1.0.json" ,
60175 "version" : "2.1.0" ,
@@ -76,38 +191,45 @@ def create_security_comment_sarif(diff: Diff) -> dict:
76191 results_list = []
77192
78193 for alert in diff .new_alerts :
79- alert : Issue
80194 pkg_name = alert .pkg_name
81195 pkg_version = alert .pkg_version
82196 rule_id = f"{ pkg_name } =={ pkg_version } "
83197 severity = alert .severity
84198
85- # Title and descriptions
86- title = f"Alert generated for { pkg_name } =={ pkg_version } by Socket Security"
87- full_desc = f"{ alert .title } - { alert .description } "
88- short_desc = f"{ alert .props .get ('note' , '' )} \r \n \r \n Suggested Action:\r \n { alert .suggestion } "
199+ # Convert any \r\n in short desc to <br/> so they display properly
200+ short_desc_raw = f"{ alert .props .get ('note' , '' )} \r \n \r \n Suggested Action:\r \n { alert .suggestion } "
201+ short_desc = short_desc_raw .replace ("\r \n " , "<br/>" )
89202
90- # Find the manifest file and line details
203+ # Build link to Socket docs, e.g. "https://socket.dev/npm/package/foo/alerts/1.2.3"
204+ socket_url = f"https://socket.dev/npm/package/{ pkg_name } /alerts/{ pkg_version } "
205+
206+ # Also convert \r\n in the main description to <br/>, then append the Socket docs link
207+ base_desc = alert .description .replace ("\r \n " , "<br/>" )
208+ full_desc_raw = f"{ alert .title } - { base_desc } <br/>{ socket_url } "
209+
210+ # Identify the manifest file and line
91211 introduced_list = alert .introduced_by
92212 if introduced_list and isinstance (introduced_list [0 ], list ) and len (introduced_list [0 ]) > 1 :
93213 manifest_file = introduced_list [0 ][1 ]
94214 else :
95215 manifest_file = alert .manifests or "requirements.txt"
96216
97- line_number , line_content = Messages .find_line_in_file (pkg_name , manifest_file )
217+ line_number , line_content = Messages .find_line_in_file (pkg_name , pkg_version , manifest_file )
98218
99- # Define the rule if not already defined
219+ # If not already defined, create a rule for this package
100220 if rule_id not in rules_map :
101221 rules_map [rule_id ] = {
102222 "id" : rule_id ,
103223 "name" : f"{ pkg_name } =={ pkg_version } " ,
104- "shortDescription" : {"text" : title },
105- "fullDescription" : {"text" : full_desc },
224+ "shortDescription" : {"text" : f"Alert generated for { rule_id } by Socket Security" },
225+ "fullDescription" : {"text" : full_desc_raw },
106226 "helpUri" : alert .url ,
107- "defaultConfiguration" : {"level" : Messages .map_severity_to_sarif (severity )},
227+ "defaultConfiguration" : {
228+ "level" : Messages .map_severity_to_sarif (severity )
229+ },
108230 }
109231
110- # Add the result
232+ # Create a SARIF " result" referencing the line where we found the match
111233 result_obj = {
112234 "ruleId" : rule_id ,
113235 "message" : {"text" : short_desc },
@@ -125,6 +247,7 @@ def create_security_comment_sarif(diff: Diff) -> dict:
125247 }
126248 results_list .append (result_obj )
127249
250+ # Attach our rules and results to the SARIF data
128251 sarif_data ["runs" ][0 ]["tool" ]["driver" ]["rules" ] = list (rules_map .values ())
129252 sarif_data ["runs" ][0 ]["results" ] = results_list
130253
0 commit comments