Skip to content

Commit a36ba7e

Browse files
committed
Update the pipeline to have a test for resource_patch_matcher
Refactor the pipeline Signed-off-by: ziad hany <ziadhany2016@gmail.com>
1 parent 40f5f0e commit a36ba7e

4 files changed

Lines changed: 502 additions & 205 deletions

File tree

scanpipe/pipelines/analyze_symbols_reachability.py

Lines changed: 29 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ class SymbolReachability(Pipeline):
4747
4848
The analysis checks if vulnerable symbols are defined, imported, called, or
4949
exactly match a fingerprint within the project files. The results, including
50-
evidence and a reachability status (REACHABLE, UNKNOWN, or
50+
tool_details and a reachability status (REACHABLE, UNKNOWN, or
5151
NOT_REACHABLE), are stored in the `extra_data` of the matching resources
5252
under the `symbols_reachability` key.
5353
@@ -176,10 +176,10 @@ def collect_and_match_resources(self):
176176
fixed_symbols = patch_symbols.get("fixed", {})
177177

178178
matcher = ResourcePatchMatcher(resource_index=resource_index)
179-
vuln_evidence = matcher.match(vulnerable_symbols)
180-
fixed_evidence = matcher.match(fixed_symbols)
179+
vuln_details = matcher.match(vulnerable_symbols)
180+
fixed_details = matcher.match(fixed_symbols)
181181

182-
if not any([vuln_evidence, fixed_evidence]):
182+
if not any([vuln_details, fixed_details]):
183183
continue
184184

185185
report = {
@@ -188,10 +188,10 @@ def collect_and_match_resources(self):
188188
"commit_hash": commit_hash,
189189
},
190190
"advisory_uids": advisory_uids,
191-
"evidence": list(vuln_evidence.values()),
192-
"fixed_symbols": sorted(fixed_evidence.keys()),
193-
"vulnerable_symbols": sorted(vuln_evidence.keys()),
194-
"reachability_status": classify_reachability(vuln_evidence).value,
191+
"tool_details": list(vuln_details.values()),
192+
"fixed_symbols": sorted(fixed_details.keys()),
193+
"vulnerable_symbols": sorted(vuln_details.keys()),
194+
"is_reachable": classify_reachability(vuln_details).value,
195195
}
196196

197197
add_reachability_report(
@@ -223,40 +223,44 @@ def generate_advisory_reachability_report(self):
223223
ReachabilityStatus.NOT_REACHABLE.value: 1,
224224
}
225225

226-
advisory_reachability_report = {}
226+
advisory_reachability_report = {
227+
"purl": self.project.purl,
228+
"advisories": [],
229+
}
230+
231+
advisory_map = {}
227232
for resource in self.candidate_resources:
228233
for report in resource.extra_data.get("symbols_reachability", []):
229234
advisory_uids = report.get("advisory_uids", [])
230-
reachability_status = report.get("reachability_status")
235+
is_reachable = report.get("is_reachable")
231236
patch = report.get("patch", {})
232237

233238
for adv_uid in advisory_uids:
234-
if adv_uid not in advisory_reachability_report:
235-
advisory_reachability_report[adv_uid] = {
236-
"reachable": ReachabilityStatus.NOT_REACHABLE.value,
239+
if adv_uid not in advisory_map:
240+
adv_data = {
241+
"advisory_uid": adv_uid,
242+
"is_reachable": ReachabilityStatus.NOT_REACHABLE.value,
237243
"details": [],
238244
}
245+
advisory_map[adv_uid] = adv_data
246+
advisory_reachability_report["advisories"].append(adv_data)
239247

240248
tool_details = {
241249
"resource_path": resource.path,
242250
"patch": patch,
243-
"reachability_status": reachability_status,
244-
"evidence": report.get("evidence", []),
251+
"is_reachable": is_reachable,
252+
"tool_details": report.get("tool_details", []),
245253
"vulnerable_symbols": report.get("vulnerable_symbols", []),
246254
"fixed_symbols": report.get("fixed_symbols", []),
247255
}
248256

249-
advisory_reachability_report[adv_uid]["details"].append(
250-
tool_details
251-
)
257+
advisory_map[adv_uid]["details"].append(tool_details)
252258

253-
current_status = advisory_reachability_report[adv_uid]["reachable"]
254-
if status_priority.get(
255-
reachability_status, 0
256-
) > status_priority.get(current_status, 0):
257-
advisory_reachability_report[adv_uid]["reachable"] = (
258-
reachability_status
259-
)
259+
current_status = advisory_map[adv_uid]["is_reachable"]
260+
if status_priority.get(is_reachable, 0) > status_priority.get(
261+
current_status, 0
262+
):
263+
advisory_map[adv_uid]["is_reachable"] = is_reachable
260264

261265
reachability_output_path = self.project.get_output_file_path(
262266
"reachability", "json"

scanpipe/pipes/reachability.py

Lines changed: 103 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@
3636

3737

3838
class ReachabilityStatus(str, Enum):
39-
REACHABLE = "YES"
40-
UNKNOWN = "UNKNOWN"
41-
NOT_REACHABLE = "NO"
39+
REACHABLE = "yes"
40+
UNKNOWN = "unknown"
41+
NOT_REACHABLE = "no"
4242

4343

4444
def normalize_text(content):
@@ -347,21 +347,21 @@ def analyze(
347347
return vuln_meta, fixed_meta, language
348348

349349

350-
def classify_reachability(evidence):
350+
def classify_reachability(tool_details):
351351
"""
352352
Classify the reachability status of a vulnerability based on the
353-
collected evidence from :class:`ResourcePatchMatcher`.
353+
collected tool_details from ResourcePatchMatcher.
354354
"""
355-
if not evidence:
355+
if not tool_details:
356356
return ReachabilityStatus.NOT_REACHABLE
357357

358358
status = ReachabilityStatus.NOT_REACHABLE
359-
for item in evidence.values():
360-
is_called = bool(item.get("called"))
359+
for item in tool_details.values():
360+
is_called = bool(item.get("is_called"))
361361
has_path = bool(item.get("reachable_from"))
362-
is_defined = bool(item.get("defined"))
363-
is_imported = bool(item.get("imported"))
364-
is_exact = bool(item.get("fingerprint"))
362+
is_defined = bool(item.get("is_defined"))
363+
is_imported = bool(item.get("is_imported"))
364+
is_exact = bool(item.get("is_exact"))
365365

366366
if is_exact or (is_imported and (is_called or has_path)):
367367
return ReachabilityStatus.REACHABLE
@@ -461,16 +461,68 @@ def __init__(self, resource_index):
461461
self.imports = resource_index.get("imports", {})
462462
self.callers_of = resource_index.get("callers_of", {})
463463
self.separator = resource_index.get("separator", ".")
464+
self.wildcard_modules = self.imports.get("*", [])
465+
466+
def _matches_first_component(
467+
self, qualified_name, abs_path, local_name, import_call_names
468+
):
469+
"""
470+
Check if the first component of qualified_name
471+
matches the end of abs_path.
472+
"""
473+
first_component = qualified_name.split(self.separator, 1)[0]
474+
if first_component != qualified_name and (
475+
abs_path.endswith(self.separator + first_component)
476+
or abs_path == first_component
477+
):
478+
remaining = qualified_name[len(first_component) :]
479+
import_call_names.add(f"{local_name}{remaining}")
480+
return True
481+
return False
482+
483+
def _matches_wildcard(self, qualified_name):
484+
"""Check if the qualified_name is covered by a wildcard import."""
485+
return any(
486+
qualified_name == mod or qualified_name.startswith(mod + self.separator)
487+
for mod in self.wildcard_modules
488+
)
489+
490+
def _get_import_info(self, qualified_name):
491+
"""Check if qualified_name is imported and return possible call names."""
492+
import_call_names = set()
493+
imported = False
494+
495+
for local_name, abs_path in self.imports.items():
496+
if local_name == "*":
497+
continue
498+
499+
if qualified_name in (local_name, abs_path):
500+
imported = True
501+
import_call_names.add(local_name)
502+
elif qualified_name.startswith(local_name + self.separator):
503+
imported = True
504+
import_call_names.add(qualified_name)
505+
elif qualified_name.startswith(abs_path + self.separator):
506+
imported = True
507+
remaining = qualified_name[len(abs_path) :]
508+
import_call_names.add(f"{local_name}{remaining}")
509+
elif abs_path.endswith(self.separator + qualified_name):
510+
imported = True
511+
import_call_names.add(local_name)
512+
elif self._matches_first_component(
513+
qualified_name, abs_path, local_name, import_call_names
514+
):
515+
imported = True
516+
517+
if not imported and self._matches_wildcard(qualified_name):
518+
return True, import_call_names
519+
520+
return imported, import_call_names
464521

465522
def match(self, patch_symbols_metadata):
466523
"""
467524
Match a set of patch symbols against the resource index and
468-
return evidence for each matched symbol.
469-
470-
For each symbol in patch_symbols_metadata, the method
471-
checks whether it is defined, imported, called, or has an
472-
exact fingerprint match in the resource. If at least one of
473-
these conditions is true, an evidence entry is created.
525+
return tool_details for each matched symbol.
474526
"""
475527
if not patch_symbols_metadata or not self.resource_index:
476528
return {}
@@ -480,42 +532,56 @@ def match(self, patch_symbols_metadata):
480532
qualified_name = metadata["qualified_name"]
481533
fingerprint = metadata["fingerprint"]
482534
defined = qualified_name in self.definitions
483-
fingerprint_hit = bool(fingerprint and fingerprint in self.fingerprints)
484-
485-
imported = (
486-
qualified_name in self.imports
487-
or qualified_name in self.imports.values()
535+
is_exact = bool(
536+
fingerprint
537+
and fingerprint in self.fingerprints
538+
and qualified_name in self.definitions
488539
)
540+
short_name = (
541+
qualified_name.rsplit(self.separator, 1)[-1]
542+
if self.separator in qualified_name
543+
else qualified_name
544+
)
545+
546+
imported, import_call_names = self._get_import_info(qualified_name)
489547

490-
callers = set(self.callers_of.get(qualified_name, set()))
491-
called = bool(callers)
548+
possible_call_names = {qualified_name}
549+
if imported or defined:
550+
possible_call_names.add(short_name)
551+
possible_call_names.update(import_call_names)
492552

493-
if not (defined or fingerprint_hit or called or imported):
553+
callers = set()
554+
for call_name in possible_call_names:
555+
callers.update(self.callers_of.get(call_name, set()))
556+
557+
called = bool(callers) and (
558+
imported or defined or bool(self.wildcard_modules)
559+
)
560+
if called and not imported and not defined and self.wildcard_modules:
561+
imported = True
562+
563+
if not (defined or is_exact or called or imported):
494564
continue
495565

496566
entry = matched.setdefault(
497567
qualified_name,
498568
{
499569
"symbol_name": qualified_name,
500-
"called": False,
501-
"defined": False,
502-
"imported": False,
503-
"fingerprint": None,
570+
"is_called": False,
571+
"is_defined": False,
572+
"is_imported": False,
573+
"is_exact": False,
504574
"reachable_from": [],
505575
},
506576
)
507577

508-
if defined:
509-
entry["defined"] = True
510-
if imported:
511-
entry["imported"] = True
578+
entry["is_defined"] = entry["is_defined"] or defined
579+
entry["is_imported"] = entry["is_imported"] or imported
580+
entry["is_exact"] = entry["is_exact"] or is_exact
581+
entry["is_called"] = entry["is_called"] or called
512582
if called:
513-
entry["called"] = True
514583
entry["reachable_from"] = sorted(callers)
515584

516-
if fingerprint_hit:
517-
entry["fingerprint"] = fingerprint
518-
519585
return matched
520586

521587

scanpipe/pipes/symbols.py

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,12 @@ class JavaTreeSitterQuery(LanguageQuery):
377377
(import_declaration (scoped_identifier) @import_name)
378378
(import_declaration (scoped_identifier) @module_name (asterisk) @import_name)
379379
"""
380-
syntax_config = {"self_keyword": "this", "separator": ".", "wildcard_symbol": "*"}
380+
syntax_config = {
381+
"self_keyword": "this",
382+
"separator": ".",
383+
"wildcard_symbol": "*",
384+
"import_local_name": "last",
385+
}
381386

382387

383388
TS_QUERIES = {
@@ -469,9 +474,29 @@ def extract_calls(self, node):
469474

470475
return calls
471476

477+
def resolve_local_name(self, imp_name, alias):
478+
"""Resolve the local name for an imported symbol."""
479+
separator = self.syntax_config.get("separator", ".")
480+
local_name = alias or imp_name
481+
482+
if not alias and separator in imp_name:
483+
if self.syntax_config.get("import_local_name") == "last":
484+
return imp_name.split(separator)[-1]
485+
return imp_name.split(separator)[0]
486+
487+
return local_name
488+
489+
def resolve_absolute_path(self, module_name, imp_name):
490+
"""Resolve the absolute path for an imported symbol."""
491+
separator = self.syntax_config.get("separator", ".")
492+
if module_name:
493+
if module_name == separator:
494+
return f"{separator}{imp_name}"
495+
return f"{module_name}{separator}{imp_name}"
496+
return imp_name
497+
472498
def extract_imports(self):
473499
"""Map every local alias to its absolute imported path."""
474-
separator = self.syntax_config.get("separator", ".")
475500
wildcard_sym = self.syntax_config.get("wildcard_symbol")
476501

477502
import_map = {}
@@ -487,17 +512,8 @@ def extract_imports(self):
487512
wildcard_modules.append(module_name)
488513
continue
489514

490-
local_name = alias or imp_name
491-
if not alias and separator in imp_name:
492-
local_name = imp_name.split(separator)[0]
493-
494-
if module_name:
495-
if module_name == separator:
496-
absolute_path = f"{separator}{imp_name}"
497-
else:
498-
absolute_path = f"{module_name}{separator}{imp_name}"
499-
else:
500-
absolute_path = imp_name
515+
local_name = self.resolve_local_name(imp_name, alias)
516+
absolute_path = self.resolve_absolute_path(module_name, imp_name)
501517

502518
import_map[local_name] = absolute_path
503519

0 commit comments

Comments
 (0)