@@ -15,16 +15,12 @@ class Messages:
1515 @staticmethod
1616 def map_severity_to_sarif(severity: str) -> str:
1717 """
18- Map Socket severity levels to SARIF levels (GitHub code scanning).
19-
20- 'low' -> 'note'
21- 'medium' or 'middle' -> 'warning'
22- 'high' or 'critical' -> 'error'
18+ Map Socket Security severity levels to SARIF levels.
2319 """
2420 severity_mapping = {
2521 "low": "note",
2622 "medium": "warning",
27- "middle": "warning", # older data might say "middle"
23+ "middle": "warning",
2824 "high": "error",
2925 "critical": "error",
3026 }
@@ -33,82 +29,67 @@ def map_severity_to_sarif(severity: str) -> str:
3329 @staticmethod
3430 def find_line_in_file(packagename: str, packageversion: str, manifest_file: str) -> tuple:
3531 """
36- Finds the line number and snippet of code for the given package/version in a manifest file.
37- Returns a 2-tuple: (line_number, snippet_or_message).
38-
39- Supports:
40- 1) JSON-based manifest files (package-lock.json, Pipfile.lock, composer.lock)
41- - Locates a dictionary entry with the matching package & version
42- - Does a rough line-based search to find the actual line in the raw text
43- 2) Text-based (requirements.txt, package.json, yarn.lock, etc.)
44- - Uses compiled regex patterns to detect a match line by line
32+ Given a manifest file, find the line number and snippet where the package is declared.
33+ For JSON-based manifests (package-lock.json, Pipfile.lock, composer.lock, package.json),
34+ we attempt to parse the JSON to verify the package is present, then search for the key.
35+ For text-based manifests, we use a regex search.
4536 """
46- # Extract just the file name to detect manifest type
4737 file_type = Path(manifest_file).name
4838
49- # ----------------------------------------------------
50- # 1) JSON-based manifest files
51- # ----------------------------------------------------
52- if file_type in ["package-lock.json", "Pipfile.lock", "composer.lock"]:
39+ # Handle JSON-based files.
40+ if file_type in ["package-lock.json", "Pipfile.lock", "composer.lock", "package.json"]:
5341 try:
54- # Read entire file so we can parse JSON and also do raw line checks
5542 with open(manifest_file, "r", encoding="utf-8") as f:
5643 raw_text = f.read()
57-
58- # Attempt JSON parse
59- data = json.loads(raw_text)
60-
61- # In practice, you may need to check data["dependencies"], data["default"], etc.
62- # This is an example approach.
63- packages_dict = (
64- data.get("packages")
65- or data.get("default")
66- or data.get("dependencies")
67- or {}
68- )
69-
70- found_key = None
71- found_info = None
72- # Locate a dictionary entry whose 'version' matches
73- for key, value in packages_dict.items():
74- # For NPM package-lock, keys might look like "node_modules/axios"
75- if key.endswith(packagename) and "version" in value:
76- if value["version"] == packageversion:
77- found_key = key
78- found_info = value
79- break
80-
81- if found_key and found_info:
82- # Search lines to approximate the correct line number
83- needle_key = f'"{found_key}":' # e.g. "node_modules/axios":
84- needle_version = f'"version": "{packageversion}"'
85- lines = raw_text.splitlines()
86- best_line = 1
87- snippet = None
88-
89- for i, line in enumerate(lines, start=1):
90- if (needle_key in line) or (needle_version in line):
91- best_line = i
92- snippet = line.strip()
93- break # On first match, stop
94-
95- # If we found an approximate line, return it; else fallback to line 1
96- if best_line > 0 and snippet:
97- return best_line, snippet
98- else:
99- return 1, f'"{found_key}": {found_info}'
44+ try:
45+ data = json.loads(raw_text)
46+ except json.JSONDecodeError:
47+ data = {}
48+
49+ found = False
50+ # For package.json, check dependencies and devDependencies.
51+ if file_type == "package.json":
52+ deps = data.get("dependencies", {})
53+ deps_dev = data.get("devDependencies", {})
54+ all_deps = {**deps, **deps_dev}
55+ if packagename in all_deps:
56+ actual_version = all_deps[packagename]
57+ # Allow for versions with caret/tilde prefixes.
58+ if actual_version == packageversion or actual_version.lstrip("^~") == packageversion:
59+ found = True
10060 else:
101- return 1, f"{packagename} {packageversion} (not found in {manifest_file})"
102-
103- except (FileNotFoundError, json.JSONDecodeError):
104- return 1, f"Error reading {manifest_file}"
61+ # For other JSON-based manifests, look into common keys.
62+ for key in ["packages", "default", "dependencies"]:
63+ if key in data:
64+ packages_dict = data[key]
65+ # In package-lock.json, keys can be paths (e.g. "node_modules/axios")
66+ for key_item, info in packages_dict.items():
67+ if key_item.endswith(packagename):
68+ # info may be a dict (with "version") or a simple version string.
69+ ver = info if isinstance(info, str) else info.get("version", "")
70+ if ver == packageversion:
71+ found = True
72+ break
73+ if found:
74+ break
10575
106- # ----------------------------------------------------
107- # 2) Text-based / line-based manifests
108- # ----------------------------------------------------
109- # Define a dictionary of patterns for common manifest types
76+ if not found:
77+ return 1, f'"{packagename}": not found in {manifest_file}'
78+
79+ # Now search the raw text to locate the declaration line.
80+ needle = f'"{packagename}":'
81+ lines = raw_text.splitlines()
82+ for i, line in enumerate(lines, start=1):
83+ if needle in line:
84+ return i, line.strip()
85+ return 1, f'"{packagename}": declaration not found'
86+ except FileNotFoundError:
87+ return 1, f"{manifest_file} not found"
88+ except Exception as e:
89+ return 1, f"Error reading {manifest_file}: {e}"
90+
91+ # For text-based files, define regex search patterns for common manifest types.
11092 search_patterns = {
111- "package.json": rf'"{packagename}":\s*"{packageversion}"',
11293 "yarn.lock": rf'{packagename}@{packageversion}',
11394 "pnpm-lock.yaml": rf'"{re.escape(packagename)}"\s*:\s*\{{[^}}]*"version":\s*"{re.escape(packageversion)}"',
11495 "requirements.txt": rf'^{re.escape(packagename)}\s*(?:==|===|!=|>=|<=|~=|\s+)?\s*{re.escape(packageversion)}(?:\s*;.*)?$',
@@ -132,33 +113,25 @@ def find_line_in_file(packagename: str, packageversion: str, manifest_file: str)
132113 "conanfile.txt": rf'{re.escape(packagename)}/{re.escape(packageversion)}',
133114 "vcpkg.json": rf'"{re.escape(packagename)}":\s*"{re.escape(packageversion)}"',
134115 }
135-
136- # If no specific pattern is found for this file name, fallback to a naive approach
137116 searchstring = search_patterns.get(file_type, rf'{re.escape(packagename)}.*{re.escape(packageversion)}')
138-
139117 try:
140- # Read file lines and search for a match
141118 with open(manifest_file, 'r', encoding="utf-8") as file:
142119 lines = [line.rstrip("\n") for line in file]
143120 for line_number, line_content in enumerate(lines, start=1):
144- # For Python conditional dependencies, ignore everything after first ';'
121+ # For cases where dependencies have conditionals (e.g. Python), only consider the main part.
145122 line_main = line_content.split(";", 1)[0].strip()
146-
147- # Use a case-insensitive regex search
148123 if re.search(searchstring, line_main, re.IGNORECASE):
149124 return line_number, line_content.strip()
150-
151125 except FileNotFoundError:
152126 return 1, f"{manifest_file} not found"
153127 except Exception as e:
154128 return 1, f"Error reading {manifest_file}: {e}"
155-
156129 return 1, f"{packagename} {packageversion} (not found)"
157130
158131 @staticmethod
159132 def get_manifest_type_url(manifest_file: str, pkg_name: str, pkg_version: str) -> str:
160133 """
161- Determine the correct URL path based on the manifest file type .
134+ Determine the URL prefix based on the manifest file.
162135 """
163136 manifest_to_url_prefix = {
164137 "package.json": "npm",
@@ -181,18 +154,20 @@ def get_manifest_type_url(manifest_file: str, pkg_name: str, pkg_version: str) -
181154 "composer.json": "composer",
182155 "vcpkg.json": "vcpkg",
183156 }
184-
185157 file_type = Path(manifest_file).name
186158 url_prefix = manifest_to_url_prefix.get(file_type, "unknown")
187159 return f"https://socket.dev/{url_prefix}/package/{pkg_name}/alerts/{pkg_version}"
188160
189161 @staticmethod
190162 def create_security_comment_sarif(diff) -> dict:
191163 """
192- Create SARIF-compliant output from the diff report, including dynamic URL generation
193- based on manifest type and improved <br/> formatting for GitHub SARIF display.
164+ Create a SARIF-compliant JSON object for alerts. This function now:
165+ - Accepts multiple manifest files (from alert.introduced_by or alert.manifests)
166+ - Generates one SARIF location per manifest file.
167+ - Supports various language-specific manifest types.
194168 """
195169 scan_failed = False
170+ # (Optional: handle scan failure based on alert.error flags)
196171 if len(diff.new_alerts) == 0:
197172 for alert in diff.new_alerts:
198173 if alert.error:
@@ -225,27 +200,30 @@ def create_security_comment_sarif(diff) -> dict:
225200 rule_id = f"{pkg_name}=={pkg_version}"
226201 severity = alert.severity
227202
228- # --- NEW LOGIC: Determine the list of manifest files ---
229- if alert.introduced_by and isinstance(alert.introduced_by[0], list):
230- # Extract file names from each introduced_by entry
231- manifest_files = [entry[1] for entry in alert.introduced_by]
232- elif alert.manifests:
233- # Split semicolon-delimited manifest string if necessary
234- manifest_files = [mf.strip() for mf in alert.manifests.split(";")]
203+ # --- Determine manifest files from alert data ---
204+ manifest_files = []
205+ if alert.introduced_by and isinstance(alert.introduced_by, list):
206+ for entry in alert.introduced_by:
207+ if isinstance(entry, list) and len(entry) >= 2:
208+ manifest_files.append(entry[1])
209+ elif isinstance(entry, str):
210+ manifest_files.extend([m.strip() for m in entry.split(";") if m.strip()])
211+ elif hasattr(alert, 'manifests') and alert.manifests:
212+ manifest_files = [mf.strip() for mf in alert.manifests.split(";") if mf.strip()]
235213 else:
236214 manifest_files = ["requirements.txt"]
237215
238- # Use the first file for generating the help URL.
216+ # Use the first manifest for URL generation .
239217 socket_url = Messages.get_manifest_type_url(manifest_files[0], pkg_name, pkg_version)
240218
241- # Prepare the description messages .
219+ # Prepare descriptions with HTML <br/> for GitHub display .
242220 short_desc = (
243221 f"{alert.props.get('note', '')}<br/><br/>Suggested Action:<br/>"
244222 f"{alert.suggestion}<br/><a href=\"{socket_url}\">{socket_url}</a>"
245223 )
246224 full_desc = "{} - {}".format(alert.title, alert.description.replace('\r\n', '<br/>'))
247225
248- # Create the rule if not already defined .
226+ # Create or reuse the rule definition .
249227 if rule_id not in rules_map:
250228 rules_map[rule_id] = {
251229 "id": rule_id,
@@ -258,12 +236,12 @@ def create_security_comment_sarif(diff) -> dict:
258236 },
259237 }
260238
261- # --- NEW LOGIC: Create separate locations for each manifest file ---
239+ # --- Build SARIF locations for each manifest file ---
262240 locations = []
263241 for mf in manifest_files:
264242 line_number, line_content = Messages.find_line_in_file(pkg_name, pkg_version, mf)
265243 if line_number < 1:
266- line_number = 1 # Ensure SARIF compliance.
244+ line_number = 1
267245 locations.append({
268246 "physicalLocation": {
269247 "artifactLocation": {"uri": mf},
@@ -274,15 +252,13 @@ def create_security_comment_sarif(diff) -> dict:
274252 }
275253 })
276254
277- # Add the SARIF result.
278255 result_obj = {
279256 "ruleId": rule_id,
280257 "message": {"text": short_desc},
281258 "locations": locations,
282259 }
283260 results_list.append(result_obj)
284261
285- # Attach rules and results.
286262 sarif_data["runs"][0]["tool"]["driver"]["rules"] = list(rules_map.values())
287263 sarif_data["runs"][0]["results"] = results_list
288264
0 commit comments