1
1
import json
2
2
import os
3
+ import re
4
+ import json
3
5
6
+ from pathlib import Path
4
7
from mdutils import MdUtils
5
8
from socketsecurity .core .classes import Diff , Purl , Issue
6
9
from prettytable import PrettyTable
@@ -12,6 +15,10 @@ class Messages:
12
15
def map_severity_to_sarif (severity : str ) -> str :
13
16
"""
14
17
Map Socket severity levels to SARIF levels (GitHub code scanning).
18
+
19
+ 'low' -> 'note'
20
+ 'medium' or 'middle' -> 'warning'
21
+ 'high' or 'critical' -> 'error'
15
22
"""
16
23
severity_mapping = {
17
24
"low" : "note" ,
@@ -22,39 +29,147 @@ def map_severity_to_sarif(severity: str) -> str:
22
29
}
23
30
return severity_mapping .get (severity .lower (), "note" )
24
31
25
-
26
32
@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 :
28
34
"""
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
31
44
"""
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
+
34
138
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"
40
152
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
+
44
157
@staticmethod
45
158
def create_security_comment_sarif (diff : Diff ) -> dict :
46
159
"""
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.
48
163
"""
164
+ # Check if there's a blocking error in new alerts
49
165
scan_failed = False
50
166
if len (diff .new_alerts ) == 0 :
51
167
for alert in diff .new_alerts :
52
- alert : Issue
53
168
if alert .error :
54
169
scan_failed = True
55
170
break
56
171
57
- # Basic SARIF structure
172
+ # Basic SARIF skeleton
58
173
sarif_data = {
59
174
"$schema" : "https://json.schemastore.org/sarif-2.1.0.json" ,
60
175
"version" : "2.1.0" ,
@@ -76,38 +191,45 @@ def create_security_comment_sarif(diff: Diff) -> dict:
76
191
results_list = []
77
192
78
193
for alert in diff .new_alerts :
79
- alert : Issue
80
194
pkg_name = alert .pkg_name
81
195
pkg_version = alert .pkg_version
82
196
rule_id = f"{ pkg_name } =={ pkg_version } "
83
197
severity = alert .severity
84
198
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/>" )
89
202
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
91
211
introduced_list = alert .introduced_by
92
212
if introduced_list and isinstance (introduced_list [0 ], list ) and len (introduced_list [0 ]) > 1 :
93
213
manifest_file = introduced_list [0 ][1 ]
94
214
else :
95
215
manifest_file = alert .manifests or "requirements.txt"
96
216
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 )
98
218
99
- # Define the rule if not already defined
219
+ # If not already defined, create a rule for this package
100
220
if rule_id not in rules_map :
101
221
rules_map [rule_id ] = {
102
222
"id" : rule_id ,
103
223
"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 },
106
226
"helpUri" : alert .url ,
107
- "defaultConfiguration" : {"level" : Messages .map_severity_to_sarif (severity )},
227
+ "defaultConfiguration" : {
228
+ "level" : Messages .map_severity_to_sarif (severity )
229
+ },
108
230
}
109
231
110
- # Add the result
232
+ # Create a SARIF " result" referencing the line where we found the match
111
233
result_obj = {
112
234
"ruleId" : rule_id ,
113
235
"message" : {"text" : short_desc },
@@ -125,6 +247,7 @@ def create_security_comment_sarif(diff: Diff) -> dict:
125
247
}
126
248
results_list .append (result_obj )
127
249
250
+ # Attach our rules and results to the SARIF data
128
251
sarif_data ["runs" ][0 ]["tool" ]["driver" ]["rules" ] = list (rules_map .values ())
129
252
sarif_data ["runs" ][0 ]["results" ] = results_list
130
253
0 commit comments