Repository: DataDog/guarddog

Issue:
Only match against real requirements
I have noticed that guarddog still tries to scan the partial matches inside a requirements.txt. It should only match real requirements not partial matches or comments.

### Comments:

[pip-tools](https://github.com/jazzband/pip-tools) is a popular tool to generate requirements.txt files. It can add comments to list the source of a package so you can track the dep tree easier. 

Example:
![image](https://github.com/DataDog/guarddog/assets/490848/26978dbd-2677-4b45-a3a5-450b9997496e)

### Partial Matches

Sometimes it matches against substrings in other requirements.

Example: 

![image](https://github.com/DataDog/guarddog/assets/490848/9f5ba0ce-c529-474e-9df8-8d05fcdd5c26)

Hints from issue comments:

Solution patch:
diff --git a/guarddog/cli.py b/guarddog/cli.py
index e78c5e14..e3793c56 100644
--- a/guarddog/cli.py
+++ b/guarddog/cli.py
@@ -1,11 +1,10 @@
-""" Package Malware Scanner
+"""Package Malware Scanner
 
 CLI command that scans a package version for user-specified malware flags.
 Includes rules based on package registry metadata and source code analysis.
 """
 
 from functools import reduce
-import json as js
 import logging
 import os
 import sys
@@ -14,12 +13,12 @@
 
 import click
 from prettytable import PrettyTable
-from termcolor import colored
 
 from guarddog.analyzer.metadata import get_metadata_detectors
 from guarddog.analyzer.sourcecode import get_sourcecode_rules
 from guarddog.ecosystems import ECOSYSTEM
-from guarddog.reporters.sarif import report_verify_sarif
+from guarddog.reporters.reporter_factory import ReporterFactory, ReporterType
+
 from guarddog.scanners import get_package_scanner, get_project_scanner
 from guarddog.utils.archives import safe_extract
 
@@ -127,7 +126,7 @@ def _get_all_rules(ecosystem: ECOSYSTEM) -> set[str]:
 
 def _get_rule_param(
     rules: tuple[str, ...], exclude_rules: tuple[str, ...], ecosystem: ECOSYSTEM
-) -> Optional[set]:
+) -> Optional[set[str]]:
     """
     This function should return None if no rules are provided
     Else a set of rules to be used for scanning
@@ -162,28 +161,20 @@ def _verify(
         log.error(f"Command verify is not supported for ecosystem {ecosystem}")
         exit(1)
 
-    def display_result(result: dict) -> None:
-        identifier = (
-            result["dependency"]
-            if result["version"] is None
-            else f"{result['dependency']} version {result['version']}"
-        )
-        if output_format is None:
-            print_scan_results(result.get("result"), identifier)
-
-        if len(result.get("errors", [])) > 0:
-            print_errors(result.get("error"), identifier)
+    dependencies, results = scanner.scan_local(path=path, rules=rule_param)
 
-    results = scanner.scan_local(path, rule_param, display_result)
-    if output_format == "json":
-        return_value = js.dumps(results)
+    rule_docs = list(rule_param or _get_all_rules(ecosystem=ecosystem))
 
-    if output_format == "sarif":
-        sarif_rules = _get_all_rules(ecosystem)
-        return_value = report_verify_sarif(path, list(sarif_rules), results, ecosystem)
+    reporter = ReporterFactory.create_reporter(ReporterType.from_str(output_format))
+    stdout, stderr = reporter.render_verify(
+        dependency_files=dependencies,
+        rule_names=rule_docs,
+        scan_results=results,
+        ecosystem=ecosystem,
+    )
 
-    if output_format is not None:
-        print(return_value)
+    sys.stdout.write(stdout)
+    sys.stderr.write(stderr)
 
     if exit_non_zero_on_finding:
         exit_with_status_code([result["result"] for result in results])
@@ -231,10 +222,10 @@ def _scan(
         log.error(f"Error occurred while scanning target {identifier}: '{e}'\n")
         sys.exit(1)
 
-    if output_format == "json":
-        print(js.dumps(result))
-    else:
-        print_scan_results(result, result["package"])
+    reporter = ReporterFactory.create_reporter(ReporterType.from_str(output_format))
+    stdout, stderr = reporter.render_scan(result)
+    sys.stdout.write(stdout)
+    sys.stderr.write(stderr)
 
     if exit_non_zero_on_finding:
         exit_with_status_code([result])
@@ -262,6 +253,7 @@ class CliEcosystem(click.Group):
     Class that dynamically represents an ecosystem in click
     It dynamically selects the ruleset to the instantiated ecosystem
     """
+
     def __init__(self, ecosystem: ECOSYSTEM):
         super().__init__()
         self.name = ecosystem.name.lower()
@@ -288,7 +280,12 @@ def rule_options(fn):
         @scan_options
         @rule_options
         def scan_ecosystem(
-            target, version, rules, exclude_rules, output_format, exit_non_zero_on_finding
+            target,
+            version,
+            rules,
+            exclude_rules,
+            output_format,
+            exit_non_zero_on_finding,
         ):
             return _scan(
                 target,
@@ -304,7 +301,9 @@ def scan_ecosystem(
         @common_options
         @verify_options
         @rule_options
-        def verify_ecosystem(target, rules, exclude_rules, output_format, exit_non_zero_on_finding):
+        def verify_ecosystem(
+            target, rules, exclude_rules, output_format, exit_non_zero_on_finding
+        ):
             return _verify(
                 target,
                 rules,
@@ -314,7 +313,9 @@ def verify_ecosystem(target, rules, exclude_rules, output_format, exit_non_zero_
                 self.ecosystem,
             )
 
-        @click.command("list-rules", help=f"List available rules for {self.ecosystem.name}")
+        @click.command(
+            "list-rules", help=f"List available rules for {self.ecosystem.name}"
+        )
         def list_rules_ecosystem():
             return _list_rules(self.ecosystem)
 
@@ -333,7 +334,7 @@ def list_rules_ecosystem():
 @verify_options
 @legacy_rules_options
 def verify(target, rules, exclude_rules, output_format, exit_non_zero_on_finding):
-    return _verify(
+    return verify(
         target,
         rules,
         exclude_rules,
@@ -361,81 +362,6 @@ def scan(
     )
 
 
-# Pretty prints scan results for the console
-def print_scan_results(results, identifier):
-    num_issues = results.get("issues")
-    errors = results.get("errors", [])
-
-    if num_issues == 0:
-        print(
-            "Found "
-            + colored("0 potentially malicious indicators", "green", attrs=["bold"])
-            + " scanning "
-            + colored(identifier, None, attrs=["bold"])
-        )
-        print()
-    else:
-        print(
-            "Found "
-            + colored(
-                str(num_issues) + " potentially malicious indicators",
-                "red",
-                attrs=["bold"],
-            )
-            + " in "
-            + colored(identifier, None, attrs=["bold"])
-        )
-        print()
-
-        findings = results.get("results", [])
-        for finding in findings:
-            description = findings[finding]
-            if isinstance(description, str):  # package metadata
-                print(colored(finding, None, attrs=["bold"]) + ": " + description)
-                print()
-            elif isinstance(description, list):  # semgrep rule result:
-                source_code_findings = description
-                print(
-                    colored(finding, None, attrs=["bold"])
-                    + ": found "
-                    + str(len(source_code_findings))
-                    + " source code matches"
-                )
-                for finding in source_code_findings:
-                    print(
-                        "  * "
-                        + finding["message"]
-                        + " at "
-                        + finding["location"]
-                        + "\n    "
-                        + format_code_line_for_output(finding["code"])
-                    )
-                print()
-
-    if len(errors) > 0:
-        print_errors(errors, identifier)
-        print("\n")
-
-
-def print_errors(errors, identifier):
-    print(
-        colored("Some rules failed to run while scanning " + identifier + ":", "yellow")
-    )
-    print()
-    for rule in errors:
-        print(f"* {rule}: {errors[rule]}")
-    print()
-
-
-def format_code_line_for_output(code):
-    return "    " + colored(
-        code.strip().replace("\n", "\n    ").replace("\t", "  "),
-        None,
-        "on_red",
-        attrs=["bold"],
-    )
-
-
 # Given the results, exit with the appropriate status code
 def exit_with_status_code(results):
     for result in results:
diff --git a/guarddog/reporters/__init__.py b/guarddog/reporters/__init__.py
index e69de29b..f906ab7e 100644
--- a/guarddog/reporters/__init__.py
+++ b/guarddog/reporters/__init__.py
@@ -0,0 +1,28 @@
+from guarddog.scanners.scanner import DependencyFile
+from typing import List
+from guarddog.ecosystems import ECOSYSTEM
+
+
+class BaseReporter:
+    """
+    Base class for all reporters.
+    """
+
+    @staticmethod
+    def render_scan(scan_results: dict) -> tuple[str, str]:
+        """
+        Report the scans results.
+        """
+        raise NotImplementedError("Subclasses must implement this method.")
+
+    @staticmethod
+    def render_verify(
+        dependency_files: List[DependencyFile],
+        rule_names: list[str],
+        scan_results: list[dict],
+        ecosystem: ECOSYSTEM,
+    ) -> tuple[str, str]:
+        """
+        Report the scans results.
+        """
+        raise NotImplementedError("Subclasses must implement this method.")
diff --git a/guarddog/reporters/human_readable.py b/guarddog/reporters/human_readable.py
new file mode 100644
index 00000000..40c71713
--- /dev/null
+++ b/guarddog/reporters/human_readable.py
@@ -0,0 +1,138 @@
+from termcolor import colored
+from guarddog.reporters import BaseReporter
+from typing import List
+from guarddog.scanners.scanner import DependencyFile
+from guarddog.ecosystems import ECOSYSTEM
+
+
+class HumanReadableReporter(BaseReporter):
+    """
+    HumanReadableReporter is a class that formats and prints scan results in a human-readable format.
+    """
+
+    @staticmethod
+    def print_errors(identifier: str, results: dict) -> str:
+        errors = results.get("errors", [])
+        if not errors:
+            return ""
+
+        lines = []
+        lines.append("")
+        lines.append(
+            colored(
+                "Some rules failed to run while scanning " + identifier + ":",
+                "yellow",
+            )
+        )
+        lines.append("")
+        for rule in errors:
+            lines.append(f"* {rule}: {errors[rule]}")
+
+        return "\n".join(lines)
+
+    @staticmethod
+    def print_scan_results(identifier: str, results: dict) -> str:
+
+        def _format_code_line_for_output(code) -> str:
+            return "    " + colored(
+                code.strip().replace("\n", "\n    ").replace("\t", "  "),
+                None,
+                "on_red",
+                attrs=["bold"],
+            )
+
+        num_issues = results.get("issues")
+        lines = []
+
+        if num_issues == 0:
+            lines.append(
+                "Found "
+                + colored("0 potentially malicious indicators", "green", attrs=["bold"])
+                + " scanning "
+                + colored(identifier, None, attrs=["bold"])
+            )
+            lines.append("")
+        else:
+            lines.append(
+                "Found "
+                + colored(
+                    str(num_issues) + " potentially malicious indicators",
+                    "red",
+                    attrs=["bold"],
+                )
+                + " in "
+                + colored(identifier, None, attrs=["bold"])
+            )
+            lines.append("")
+
+            findings = results.get("results", [])
+            for finding in findings:
+                description = findings[finding]
+                if isinstance(description, str):  # package metadata
+                    lines.append(
+                        colored(finding, None, attrs=["bold"]) + ": " + description
+                    )
+                    lines.append("")
+                elif isinstance(description, list):  # semgrep rule result:
+                    source_code_findings = description
+                    lines.append(
+                        colored(finding, None, attrs=["bold"])
+                        + ": found "
+                        + str(len(source_code_findings))
+                        + " source code matches"
+                    )
+                    for finding in source_code_findings:
+                        lines.append(
+                            "  * "
+                            + finding["message"]
+                            + " at "
+                            + finding["location"]
+                            + "\n    "
+                            + _format_code_line_for_output(finding["code"])
+                        )
+                    lines.append("")
+
+        return "\n".join(lines)
+
+    @staticmethod
+    def render_scan(scan_results: dict) -> tuple[str, str]:
+        """
+        Report the scans results in a human-readable format.
+
+        Args:
+            scan_results (dict): The scan results to be reported.
+        """
+        return (
+            HumanReadableReporter.print_scan_results(
+                identifier=scan_results["package"], results=scan_results
+            ),
+            HumanReadableReporter.print_errors(
+                identifier=scan_results["package"], results=scan_results
+            ),
+        )
+
+    @staticmethod
+    def render_verify(
+        dependency_files: List[DependencyFile],
+        rule_names: list[str],
+        scan_results: list[dict],
+        ecosystem: ECOSYSTEM,
+    ) -> tuple[str, str]:
+        return (
+            "\n".join(
+                [
+                    HumanReadableReporter.print_scan_results(
+                        identifier=s["dependency"], results=s["result"]
+                    )
+                    for s in scan_results
+                ]
+            ),
+            "\n".join(
+                [
+                    HumanReadableReporter.print_errors(
+                        identifier=s["dependency"], results=s["result"]
+                    )
+                    for s in scan_results
+                ]
+            ),
+        )
diff --git a/guarddog/reporters/json.py b/guarddog/reporters/json.py
new file mode 100644
index 00000000..efb75eb1
--- /dev/null
+++ b/guarddog/reporters/json.py
@@ -0,0 +1,28 @@
+import json
+from typing import List
+from guarddog.scanners.scanner import DependencyFile
+from guarddog.ecosystems import ECOSYSTEM
+
+from guarddog.reporters import BaseReporter
+
+
+class JsonReporter(BaseReporter):
+    @staticmethod
+    def render_verify(
+        dependency_files: List[DependencyFile],
+        rule_names: list[str],
+        scan_results: list[dict],
+        ecosystem: ECOSYSTEM,
+    ) -> tuple[str, str]:
+        return json.dumps(scan_results), ""
+
+    @staticmethod
+    def render_scan(scan_results: dict) -> tuple[str, str]:
+        """
+        Report the scans results in a json format.
+
+        Args:
+            scan_results (dict): The scan results to be reported.
+        """
+        # this reporter will output the errors in stdout
+        return json.dumps(scan_results), ""
diff --git a/guarddog/reporters/reporter_factory.py b/guarddog/reporters/reporter_factory.py
new file mode 100644
index 00000000..4f1cab23
--- /dev/null
+++ b/guarddog/reporters/reporter_factory.py
@@ -0,0 +1,50 @@
+from enum import Enum, auto
+from typing import Optional
+
+from guarddog.reporters import BaseReporter
+from guarddog.reporters.human_readable import HumanReadableReporter
+from guarddog.reporters.sarif import SarifReporter
+from guarddog.reporters.json import JsonReporter
+
+
+class ReporterType(Enum):
+    """
+    Enum representing the different types of reporters available.
+    """
+
+    HUMAN_READABLE = auto()
+    SARIF = auto()
+    JSON = auto()
+
+    @classmethod
+    def from_str(cls, type: Optional[str]) -> "ReporterType":
+        if not type:
+            return cls.HUMAN_READABLE
+        match (type).lower():
+            case "human_readable":
+                return cls.HUMAN_READABLE
+            case "sarif":
+                return cls.SARIF
+            case "json":
+                return cls.JSON
+            case _:
+                raise ValueError(f"Unsupported reporter type: {type}")
+
+
+class ReporterFactory:
+    """
+    Factory class for creating reporter instances based on the reporter type.
+    """
+
+    @staticmethod
+    def create_reporter(reporter_type: ReporterType) -> type[BaseReporter]:
+        """
+        Create a reporter instance based on the reporter type.
+        """
+        match reporter_type:
+            case ReporterType.HUMAN_READABLE:
+                return HumanReadableReporter
+            case ReporterType.SARIF:
+                return SarifReporter
+            case ReporterType.JSON:
+                return JsonReporter
diff --git a/guarddog/reporters/sarif.py b/guarddog/reporters/sarif.py
index c284a8f6..456f59e3 100644
--- a/guarddog/reporters/sarif.py
+++ b/guarddog/reporters/sarif.py
@@ -4,181 +4,187 @@
 from guarddog.analyzer.sourcecode import get_sourcecode_rules
 from guarddog.analyzer.metadata import get_metadata_detectors
 from guarddog.ecosystems import ECOSYSTEM
+from guarddog.reporters import BaseReporter
+from guarddog.scanners.scanner import DependencyFile
+from typing import List
+from guarddog.reporters.human_readable import HumanReadableReporter
+
+
+class SarifReporter(BaseReporter):
+    """
+    Sarif is a class that formats and prints scan results in the SARIF format.
+    """
+
+    @staticmethod
+    def render_verify(
+        dependency_files: List[DependencyFile],
+        rule_names: list[str],
+        scan_results: list[dict],
+        ecosystem: ECOSYSTEM,
+    ) -> tuple[str, str]:
+        """
+        Report the scans results in the SARIF format.
+
+        Args:
+            scan_results (dict): The scan results to be reported.
+        """
+
+        def build_rules_help_list() -> dict:
+            """
+            Builds a dict with the names of all available rules and their documentation
+            @return: dict[name_of_rule, rule_description]
+            """
+            rules_documentation = {}
+            for ecosystem in ECOSYSTEM:
+                rules = get_metadata_detectors(ecosystem)
+                for name, instance in rules.items():
+                    detector_class = instance.__class__.__base__
+                    rules_documentation[name] = detector_class.__doc__
+                for sourcecode_rule in get_sourcecode_rules(ecosystem):
+                    rules_documentation[sourcecode_rule.id] = (
+                        sourcecode_rule.description
+                    )
+            return rules_documentation
+
+        def get_sarif_log(runs):
+            """
+            https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/sarif-support-for-code-scanning#sariflog-object
+            """
+            return {
+                "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
+                "version": "2.1.0",
+                "runs": runs,
+            }
 
+        def get_run(results, driver):
+            """
+            https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/sarif-support-for-code-scanning#run-object
+            """
+            return {"tool": {"driver": driver}, "results": results}
+
+        def get_driver(rules, ecosystem: str):
+            """
+            https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/sarif-support-for-code-scanning#toolcomponent-object
+            """
+            return {
+                "name": f"GuardDog-{ecosystem}",
+                "informationUri": "https://github.com/DataDog/guarddog",
+                "rules": rules,
+            }
 
-def build_rules_help_list() -> dict:
-    """
-    Builds a dict with the names of all available rules and their documentation
-    @return: dict[name_of_rule, rule_description]
-    """
-    rules_documentation = {}
-    for ecosystem in ECOSYSTEM:
-        rules = get_metadata_detectors(ecosystem)
-        for name, instance in rules.items():
-            detector_class = instance.__class__.__base__
-            rules_documentation[name] = detector_class.__doc__
-        for sourcecode_rule in get_sourcecode_rules(ecosystem):
-            rules_documentation[sourcecode_rule.id] = sourcecode_rule.description
-    return rules_documentation
-
-
-def get_sarif_log(runs):
-    """
-    https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/sarif-support-for-code-scanning#sariflog-object
-    """
-    return {
-        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
-        "version": "2.1.0",
-        "runs": runs
-    }
-
-
-def get_run(results, driver):
-    """
-    https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/sarif-support-for-code-scanning#run-object
-    """
-    return {
-        "tool": {
-            "driver": driver
-        },
-        "results": results
-    }
-
-
-def get_driver(rules, ecosystem: str):
-    """
-    https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/sarif-support-for-code-scanning#toolcomponent-object
-    """
-    return {
-        "name": f"GuardDog-{ecosystem}",
-        "rules": rules
-    }
-
-
-def get_rule(rule_name: str, rules_documentation) -> dict:
-    """
-    https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/sarif-support-for-code-scanning#reportingdescriptor-object
-    """
-    message = rules_documentation[rule_name] if rules_documentation[rule_name] is not None else ""
-    return {
-        "id": rule_name,
-        "defaultConfiguration": {
-            "level": "warning"
-        },
-        "shortDescription": {
-            "text": f"GuardDog rule: {rule_name}"
-        },
-        "fullDescription": {
-            "text": message
-        },
-        "help": {
-            "text": message,
-            "markdown": message
-        },
-        "properties": {
-            "precision": "medium"
-        }
-    }
-
-
-def get_result(rule_name, locations, text, partial_fingerprints):
-    """
-    https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/sarif-support-for-code-scanning#result-object
-    """
-    return {
-        "ruleId": rule_name,
-        "message": {
-            "text": text
-        },
-        "locations": locations,
-        "partialFingerprints": partial_fingerprints
-    }
-
-
-def get_location(physical_location):
-    """
-    https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/sarif-support-for-code-scanning#location-object
-    """
-    return {
-        "physicalLocation": physical_location
-    }
+        def get_rule(rule_name: str, rules_documentation) -> dict:
+            """
+            https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/sarif-support-for-code-scanning#reportingdescriptor-object
+            """
+            message = (
+                rules_documentation[rule_name]
+                if rules_documentation[rule_name] is not None
+                else ""
+            )
+            return {
+                "id": rule_name,
+                "defaultConfiguration": {"level": "warning"},
+                "shortDescription": {"text": f"GuardDog rule: {rule_name}"},
+                "fullDescription": {"text": message},
+                "help": {"text": message, "markdown": message},
+                "properties": {"precision": "medium"},
+            }
 
+        def get_result(rule_name, locations, text, partial_fingerprints):
+            """
+            https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/sarif-support-for-code-scanning#result-object
+            """
+            return {
+                "ruleId": rule_name,
+                "message": {"text": text},
+                "locations": locations,
+                "partialFingerprints": partial_fingerprints,
+            }
 
-def get_physical_location(uri, region):
-    """
-    https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/sarif-support-for-code-scanning#physicallocation-object
-    """
-    return {
-        "artifactLocation": {
-            "uri": uri
-        },
-        "region": region
-    }
-
-
-def get_region(package_raw: str, package: str) -> dict:
-    start_line = 0
-    start_column = 0
-    end_column = 0
-    for idx, val in enumerate(package_raw.split("\n")):
-        if package in val:
-            start_line = idx + 1
-            start_column = val.index(package) + 1
-            end_column = start_column + len(package)
-
-    return {
-        "startLine": start_line,
-        "endLine": start_line,
-        "startColumn": start_column,
-        "endColumn": end_column,
-    }
-
-
-def report_verify_sarif(
-    package_path: str,
-    rule_names: list[str],
-    scan_results: list[dict],
-    ecosystem: ECOSYSTEM,
-) -> str:
-    rules_documentation = build_rules_help_list()
-    rules = list(map(
-        lambda s: get_rule(s, rules_documentation),
-        rule_names
-    ))
-    driver = get_driver(rules, ecosystem.value)
-    results = []
-
-    with open(package_path, "r") as file:
-        package_raw = file.read()
-
-    for entry in scan_results:
-        if entry["result"]["issues"] == 0:
-            continue
-
-        region = get_region(package_raw, entry["dependency"])
-        uri = package_path[2:] if package_path.startswith('./') else package_path
-        physical_location = get_physical_location(uri, region)
-        location = get_location(physical_location)
-        scan_result_details = entry["result"]["results"]
-        package = entry["dependency"]
-        version = entry["version"]
-        for rule_name in scan_result_details.keys():
-            if scan_result_details[rule_name] is None or len(scan_result_details[rule_name]) == 0:
+        def get_location(physical_location):
+            """
+            https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/sarif-support-for-code-scanning#location-object
+            """
+            return {"physicalLocation": physical_location}
+
+        def get_physical_location(uri, region):
+            """
+            https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/sarif-support-for-code-scanning#physicallocation-object
+            """
+            return {"artifactLocation": {"uri": uri}, "region": region}
+
+        def get_region(
+            dependency_files: List[DependencyFile], package: str
+        ) -> tuple[DependencyFile, dict]:
+            for dependency_file in dependency_files:
+                for d in dependency_file.dependencies:
+                    if d.name == package:
+                        return dependency_file, {
+                            "startLine": list(d.versions)[0].location,
+                            "endLine": list(d.versions)[0].location,
+                            "startColumn": 1,
+                            "endColumn": len(package),
+                        }
+            raise ValueError(
+                f"Could not find the package {package} in the dependency files"
+            )
+
+        rules_documentation = build_rules_help_list()
+        rules = list(map(lambda s: get_rule(s, rules_documentation), rule_names))
+        driver = get_driver(rules, ecosystem.value)
+        results = []
+
+        for entry in scan_results:
+            if entry["result"]["issues"] == 0:
                 continue
-            text = f"On package: {package} version: {version}\n" + "\n".join(map(
-                lambda x: x["message"],
-                scan_result_details[rule_name]
-            )) if isinstance(scan_result_details[rule_name], list) else scan_result_details[rule_name]
-            key = f"{rule_name}-{text}"
-            partial_fingerprints = {
-                f"guarddog/v1/{rule_name}": hashlib.sha256(key.encode('utf-8')).hexdigest()
-            }
-            result = get_result(rule_name,
-                                [location],
-                                text,
-                                partial_fingerprints)
-            results.append(result)
-
-    runs = get_run(results, driver)
-    log = get_sarif_log([runs])
-    return json.dumps(log, indent=2)
+
+            dep_file, region = get_region(
+                dependency_files=dependency_files, package=entry["dependency"]
+            )
+            package_path = dep_file.file_path
+            uri = package_path[2:] if package_path.startswith("./") else package_path
+            physical_location = get_physical_location(uri, region)
+            location = get_location(physical_location)
+            scan_result_details = entry["result"]["results"]
+            package = entry["dependency"]
+            version = entry["version"]
+            for rule_name in scan_result_details.keys():
+                if (
+                    scan_result_details[rule_name] is None
+                    or len(scan_result_details[rule_name]) == 0
+                ):
+                    continue
+
+                text = (
+                    f"On package: {package} version: {version}\n"
+                    + "\n".join(
+                        map(
+                            lambda x: f"{x['message']} in file {x['location']}",
+                            scan_result_details[rule_name],
+                        )
+                    )
+                    if isinstance(scan_result_details[rule_name], list)
+                    else scan_result_details[rule_name]
+                )
+                key = f"{rule_name}-{text}"
+                partial_fingerprints = {
+                    f"guarddog/v1/{rule_name}": hashlib.sha256(
+                        key.encode("utf-8")
+                    ).hexdigest()
+                }
+                result = get_result(rule_name, [location], text, partial_fingerprints)
+                results.append(result)
+
+        runs = get_run(results, driver)
+        log = get_sarif_log([runs])
+
+        errors = "\n".join(
+            [
+                HumanReadableReporter.print_errors(
+                    identifier=r["dependency"], results=r["result"]
+                )
+                for r in scan_results
+            ]
+        )
+
+        return (json.dumps(log, indent=2), errors)
diff --git a/guarddog/scanners/github_action_project_scanner.py b/guarddog/scanners/github_action_project_scanner.py
index 76bc641f..fe1a6999 100644
--- a/guarddog/scanners/github_action_project_scanner.py
+++ b/guarddog/scanners/github_action_project_scanner.py
@@ -8,6 +8,7 @@
 
 from guarddog.scanners.github_action_scanner import GithubActionScanner
 from guarddog.scanners.scanner import ProjectScanner
+from guarddog.scanners.scanner import Dependency, DependencyVersion
 
 log = logging.getLogger("guarddog")
 
@@ -67,17 +68,40 @@ class GitHubActionDependencyScanner(ProjectScanner):
     def __init__(self) -> None:
         super().__init__(GithubActionScanner())
 
-    def parse_requirements(self, raw_requirements: str) -> dict[str, set[str]]:
+    def parse_requirements(self, raw_requirements: str) -> List[Dependency]:
         actions = self.parse_workflow_3rd_party_actions(raw_requirements)
+        dependencies: List[Dependency] = []
 
-        requirements: dict[str, set[str]] = {}
         for action in actions:
-            repo, version = action["name"], action["ref"]
-            if repo in requirements:
-                requirements[repo].add(version)
-            else:
-                requirements[repo] = {version}
-        return requirements
+            name = action["name"]
+            version = action["ref"]
+            idx = next(
+                iter(
+                    [
+                        ix
+                        for ix, line in enumerate(raw_requirements.splitlines())
+                        if name in line
+                    ]
+                ),
+                0,
+            )
+            # find the dep with the same name or create a new one
+            dep_versions = [DependencyVersion(version=version, location=idx + 1)]
+
+            dep = next(
+                filter(
+                    lambda d: d.name == name,
+                    dependencies,
+                ),
+                None,
+            )
+            if not dep:
+                dep = Dependency(name=name, versions=set())
+                dependencies.append(dep)
+
+            dep.versions.update(dep_versions)
+
+        return dependencies
 
     def parse_workflow_3rd_party_actions(
         self, workflow_file: str
@@ -105,7 +129,9 @@ def find_requirements(self, directory: str) -> list[str]:
         requirement_files = []
 
         if not os.path.isdir(os.path.join(directory, ".git")):
-            raise Exception("unable to find github workflows, not called from git directory")
+            raise Exception(
+                "unable to find github workflows, not called from git directory"
+            )
         workflow_folder = os.path.join(directory, ".github/workflows")
         if os.path.isdir(workflow_folder):
             for name in os.listdir(workflow_folder):
diff --git a/guarddog/scanners/go_project_scanner.py b/guarddog/scanners/go_project_scanner.py
index 39587201..0d8cc0ed 100644
--- a/guarddog/scanners/go_project_scanner.py
+++ b/guarddog/scanners/go_project_scanner.py
@@ -6,6 +6,7 @@
 
 from guarddog.scanners.go_package_scanner import GoModuleScanner
 from guarddog.scanners.scanner import ProjectScanner
+from guarddog.scanners.scanner import Dependency, DependencyVersion
 
 log = logging.getLogger("guarddog")
 
@@ -28,13 +29,39 @@ class GoDependenciesScanner(ProjectScanner):
     def __init__(self) -> None:
         super().__init__(GoModuleScanner())
 
-    def parse_requirements(self, raw_requirements: str) -> dict[str, set[str]]:
+    def parse_requirements(self, raw_requirements: str) -> List[Dependency]:
         main_mod = self.parse_go_mod_file(raw_requirements)
 
-        return {
-            requirement.module: set([requirement.version])
-            for requirement in main_mod.requirements
-        }
+        dependencies: List[Dependency] = []
+        for dependency in main_mod.requirements:
+            version = dependency.version
+            name = dependency.module
+            idx = next(
+                iter(
+                    [
+                        ix
+                        for ix, line in enumerate(raw_requirements.splitlines())
+                        if name in line
+                    ]
+                ),
+                0,
+            )
+
+            dep_versions = [DependencyVersion(version=version, location=idx + 1)]
+
+            dep = next(
+                filter(
+                    lambda d: d.name == name,
+                    dependencies,
+                ),
+                None
+            )
+            if not dep:
+                dep = Dependency(name=name, versions=set())
+                dependencies.append(dep)
+
+            dep.versions.update(dep_versions)
+        return dependencies
 
     # Read https://go.dev/ref/mod#go-mod-file to learn more about the go.mod syntax
     def parse_go_mod_file(self, go_mod_content: str) -> GoModule:
diff --git a/guarddog/scanners/npm_project_scanner.py b/guarddog/scanners/npm_project_scanner.py
index a9bde678..a0511404 100644
--- a/guarddog/scanners/npm_project_scanner.py
+++ b/guarddog/scanners/npm_project_scanner.py
@@ -2,13 +2,14 @@
 import logging
 import os
 import re
+from typing import List
 
 import requests
 from semantic_version import NpmSpec, Version  # type:ignore
 
-from guarddog.utils.config import VERIFY_EXHAUSTIVE_DEPENDENCIES
 from guarddog.scanners.npm_package_scanner import NPMPackageScanner
-from guarddog.scanners.scanner import ProjectScanner
+from guarddog.scanners.scanner import Dependency, DependencyVersion, ProjectScanner
+from guarddog.utils.config import VERIFY_EXHAUSTIVE_DEPENDENCIES
 
 log = logging.getLogger("guarddog")
 
@@ -24,7 +25,7 @@ class NPMRequirementsScanner(ProjectScanner):
     def __init__(self) -> None:
         super().__init__(NPMPackageScanner())
 
-    def parse_requirements(self, raw_requirements: str) -> dict:
+    def parse_requirements(self, raw_requirements: str) -> List[Dependency]:
         """
         Parses requirements.txt specification and finds all valid
         versions of each dependency
@@ -43,8 +44,8 @@ def parse_requirements(self, raw_requirements: str) -> dict:
             }
         """
         package = json.loads(raw_requirements)
-        dependencies = package["dependencies"] if "dependencies" in package else {}
-        dev_dependencies = (
+        dependencies_attr = package["dependencies"] if "dependencies" in package else {}
+        dev_dependencies_attr = (
             package["devDependencies"] if "devDependencies" in package else {}
         )
 
@@ -85,26 +86,58 @@ def find_all_versions(package_name: str) -> set[str]:
             return versions
 
         merged = {}  # type: dict[str, set[str]]
-        for package, selector in list(dependencies.items()) + list(
-            dev_dependencies.items()
+        for package, selector in list(dependencies_attr.items()) + list(
+            dev_dependencies_attr.items()
         ):
             if package not in merged:
                 merged[package] = set()
             merged[package].add(selector)
 
-        results = {}
+        dependencies: List[Dependency] = []
         for package, all_selectors in merged.items():
             versions = set()  # type: set[str]
             for selector in all_selectors:
                 versions = versions.union(
                     get_matched_versions(find_all_versions(package), selector)
                 )
+
             if len(versions) == 0:
                 log.error(f"Package/Version {package} not on NPM\n")
                 continue
 
-            results[package] = versions
-        return results
+            idx = next(
+                iter(
+                    [
+                        ix
+                        for ix, line in enumerate(raw_requirements.splitlines())
+                        if package in line
+                    ]
+                ),
+                0,
+            )
+
+            dep_versions = list(
+                map(
+                    lambda d: DependencyVersion(version=d, location=idx + 1),
+                    versions,
+                )
+            )
+
+            # find the dep with the same name or create a new one
+            dep = next(
+                filter(
+                    lambda d: d.name == package,
+                    dependencies,
+                ),
+                None,
+            )
+            if not dep:
+                dep = Dependency(name=package, versions=set())
+                dependencies.append(dep)
+
+            dep.versions.update(dep_versions)
+
+        return dependencies
 
     def find_requirements(self, directory: str) -> list[str]:
         requirement_files = []
diff --git a/guarddog/scanners/pypi_project_scanner.py b/guarddog/scanners/pypi_project_scanner.py
index 215f7acc..14156a50 100644
--- a/guarddog/scanners/pypi_project_scanner.py
+++ b/guarddog/scanners/pypi_project_scanner.py
@@ -1,12 +1,14 @@
 import logging
 import os
 import re
+from typing import List
+
 import pkg_resources
 import requests
 from packaging.specifiers import Specifier, Version
 
 from guarddog.scanners.pypi_package_scanner import PypiPackageScanner
-from guarddog.scanners.scanner import ProjectScanner
+from guarddog.scanners.scanner import Dependency, DependencyVersion, ProjectScanner
 from guarddog.utils.config import VERIFY_EXHAUSTIVE_DEPENDENCIES
 
 log = logging.getLogger("guarddog")
@@ -38,17 +40,20 @@ def _sanitize_requirements(self, requirements: list[str]) -> list[str]:
 
         for line in requirements:
             is_requirement = re.match(r"\w", line)
-            if is_requirement:
-                if "\\" in line:
-                    line = line.replace("\\", "")
 
-                stripped_line = line.strip()
-                if len(stripped_line) > 0:
-                    sanitized_lines.append(stripped_line)
+            if not is_requirement:
+                sanitized_lines.append("")  # empty line to keep the line number
+                continue
+
+            if "\\" in line:
+                line = line.replace("\\", "")
+
+            stripped_line = line.strip()
+            sanitized_lines.append(stripped_line)
 
         return sanitized_lines
 
-    def parse_requirements(self, raw_requirements: str) -> dict[str, set[str]]:
+    def parse_requirements(self, raw_requirements: str) -> List[Dependency]:
         """
         Parses requirements.txt specification and finds all valid
         versions of each dependency
@@ -58,17 +63,10 @@ def parse_requirements(self, raw_requirements: str) -> dict[str, set[str]]:
 
         Returns:
             dict: mapping of dependencies to valid versions
-
-            ex.
-            {
-                ....
-                <dependency-name>: [0.0.1, 0.0.2, ...],
-                ...
-            }
         """
         requirements = raw_requirements.splitlines()
         sanitized_requirements = self._sanitize_requirements(requirements)
-        dependencies = {}
+        dependencies: List[Dependency] = []
 
         def get_matched_versions(versions: set[str], semver_range: str) -> set[str]:
             """
@@ -143,7 +141,38 @@ def safe_parse_requirements(req):
                     )
                     continue
 
-                dependencies[requirement.project_name] = versions
+                idx = next(
+                    iter(
+                        [
+                            ix
+                            for ix, line in enumerate(requirements)
+                            if str(requirement) in line
+                        ]
+                    ),
+                    0,
+                )
+
+                dep_versions = list(
+                    map(
+                        lambda d: DependencyVersion(version=d, location=idx + 1),
+                        versions,
+                    )
+                )
+
+                # find the dep with the same name or create a new one
+                dep = next(
+                    filter(
+                        lambda d: d.name == requirement.project_name,
+                        dependencies,
+                    ),
+                    None,
+                )
+                if not dep:
+                    dep = Dependency(name=requirement.project_name, versions=set())
+                    dependencies.append(dep)
+
+                dep.versions.update(dep_versions)
+
         except Exception as e:
             log.error(f"Received error {str(e)}")
 
diff --git a/guarddog/scanners/scanner.py b/guarddog/scanners/scanner.py
index edbf5ff1..ebd76f03 100644
--- a/guarddog/scanners/scanner.py
+++ b/guarddog/scanners/scanner.py
@@ -2,11 +2,12 @@
 import json
 import logging
 import os
-import sys
 import tempfile
 import typing
 from abc import abstractmethod
 from concurrent.futures import ThreadPoolExecutor
+from dataclasses import dataclass
+from typing import List, Optional, Set, Tuple
 
 import requests
 
@@ -21,8 +22,195 @@ def noop(arg: typing.Any) -> None:
     pass
 
 
+@dataclass
+class DependencyVersion:
+    """
+    This class represents the identified dependency versions in a project,
+    usually defined in a specification file (requirements.txt, package.json, etc.)
+
+    Attributes:
+        version (str): The version of the dependency. e.g., "1.0.0"
+        location (int): This indicates the line number in the specification file where the dependency is defined.
+    """
+
+    version: str  # the version number of the dependency
+    location: int
+
+    def __eq__(self, other):
+        if isinstance(other, str):
+            return self.version == other
+        if isinstance(other, DependencyVersion):
+            return self.version == other.version
+        return NotImplemented
+
+    def __hash__(self):
+        return hash(self.version)
+
+    def __repr__(self):
+        return f"DependencyVersion({self.version!r})"
+
+
+@dataclass
+class Dependency:
+    """
+    This class represents a dependency in a project, usually defined in a specification file
+
+    Attributes:
+        name (str): The name of the dependency. e.g., "requests"
+        versions (Set[DependencyVersion]): A set of identified versions of the dependency.
+    """
+    name: str
+    versions: Set[DependencyVersion]
+
+    def __eq__(self, other):
+        if isinstance(other, str):
+            return self.name == other
+        if isinstance(other, Dependency):
+            return self.name == other.name
+        return NotImplemented
+
+    def __repr__(self):
+        return f"Dependency({self.name!r})"
+
+
+@dataclass
+class DependencyFile:
+    """
+    This class represents a specification file for a project (requirements.txt, package.json, etc.)
+    """
+
+    file_path: str
+    dependencies: List[Dependency]
+
+
+class PackageScanner:
+    """
+    Scans package for attack vectors based on source code and metadata rules
+
+    Attributes:
+        analyzer (Analyzer): Analyzer for source code and metadata rules
+    """
+
+    def __init__(self, analyzer: Analyzer):
+        super().__init__()
+        self.analyzer = analyzer
+
+    def scan_local(
+        self, path, rules=None, callback: typing.Callable[[dict], None] = noop
+    ) -> dict:
+        """
+        Scans local package
+
+        Args:
+            path (str): Path to the directory containing the package to analyze
+            rules (set, optional): Set of rule names to use. Defaults to all rules.
+            callback (typing.Callable[[dict], None], optional): Callback to apply to Analyzer output
+
+        Raises:
+            Exception: Analyzer exception
+
+        Returns:
+            dict: Analyzer output with rules to results mapping
+        """
+
+        if rules is not None:
+            rules = set(rules)
+
+        results = self.analyzer.analyze_sourcecode(path, rules=rules)
+        callback(results)
+
+        return results
+
+    @abstractmethod
+    def download_and_get_package_info(
+        self, directory: str, package_name: str, version=None
+    ) -> typing.Tuple[dict, str]:
+        raise NotImplementedError("download_and_get_package_info is not implemented")
+
+    def _scan_remote(
+        self, name, base_dir, version=None, rules=None, write_package_info=False
+    ):
+        directory = os.path.join(os.path.dirname(os.path.abspath(__file__)), base_dir)
+
+        file_path = None
+        package_info = None
+        try:
+            package_info, file_path = self.download_and_get_package_info(
+                directory, name, version
+            )
+        except Exception as e:
+            log.debug("Unable to download package, ignoring: " + str(e))
+            return {"issues": 0, "errors": {"download-package": str(e)}}
+
+        results = self.analyzer.analyze(file_path, package_info, rules, name, version)
+        if write_package_info:
+            package_name = name.replace("/", "-")
+            suffix = (
+                f"{package_name}-{version}" if version is not None else package_name
+            )
+            with open(
+                os.path.join(results["path"], f"package_info-{suffix}.json"), "w"
+            ) as file:
+                file.write(json.dumps(package_info))
+
+        return results
+
+    def scan_remote(
+        self, name, version=None, rules=None, base_dir=None, write_package_info=False
+    ):
+        """
+        Scans a remote package
+
+        Args:
+            * `name` (str): name of the package on PyPI
+            * `version` (str, optional): version of package (ex. 0.0.1). If not specified, the latest version is assumed
+            * `rules` (set, optional): Set of rule names to use. Defaults to all rules.
+            * `base_dir` (str, optional): directory to use to download package to. If not specified, a temporary folder
+            is created and cleaned up automatically. If not specified, the provided directory is not removed after the
+            scan.
+            * `write_package_info` (bool, default False): if set to true, the result of the PyPI metadata API is written
+             to a json file
+
+        Raises:
+            Exception: Analyzer exception
+
+        Returns:
+            dict: Analyzer output with rules to results mapping
+        """
+        if base_dir is not None:
+            return self._scan_remote(name, base_dir, version, rules, write_package_info)
+
+        with tempfile.TemporaryDirectory() as tmpdirname:
+            # Directory to download compressed and uncompressed package
+            return self._scan_remote(
+                name, tmpdirname, version, rules, write_package_info
+            )
+
+    def download_compressed(self, url, archive_path, target_path):
+        """Downloads a compressed file and extracts it
+
+        Args:
+            url (str): download link
+            archive_path (str): path to download compressed file
+            target_path (str): path to unzip compressed file
+        """
+
+        log.debug(f"Downloading package archive from {url} into {target_path}")
+        response = requests.get(url, stream=True)
+
+        with open(archive_path, "wb") as f:
+            f.write(response.raw.read())
+
+        try:
+            safe_extract(archive_path, target_path)
+            log.debug(f"Successfully extracted files to {target_path}")
+        finally:
+            log.debug(f"Removing temporary archive file {archive_path}")
+            os.remove(archive_path)
+
+
 class ProjectScanner:
-    def __init__(self, package_scanner):
+    def __init__(self, package_scanner: PackageScanner):
         super().__init__()
         self.package_scanner = package_scanner
 
@@ -47,15 +235,15 @@ def _authenticate_by_access_token(self) -> tuple[str, str]:
 
     def scan_dependencies(
         self,
-        dependencies: dict[str, set[str]],
+        dependencies: List[Dependency],
         rules=None,
         callback: typing.Callable[[dict], None] = noop,
-    ) :
+    ) -> list[dict]:
         """
         scans each possible dependency and version supplied
 
         Args:
-            dependencies (dict): a mapping of dependency name to set of versions used
+            dependencies a list of dependencies to scan
             rules: list of rules to apply
             callback: callback to call for each result
 
@@ -75,31 +263,31 @@ def scan_dependencies(
             }
         """
 
-        def scan_single_dependency(dependency, version):
+        def scan_single_dependency(dependency: str, version: Optional[str]) -> dict:
             log.debug(f"Scanning {dependency} version {version}")
             result = self.package_scanner.scan_remote(dependency, version, rules)
             return {"dependency": dependency, "version": version, "result": result}
 
         num_workers = PARALLELISM
 
-        log.info(
-            f"Scanning using at most {num_workers} parallel worker threads\n"
-        )
+        log.info(f"Scanning using at most {num_workers} parallel worker threads\n")
         with ThreadPoolExecutor(max_workers=num_workers) as pool:
             try:
                 futures: typing.List[concurrent.futures.Future] = []
-                for dependency, versions in dependencies.items():
-                    assert versions is None or len(versions) > 0
-                    if versions is None:
+                for dependency in dependencies:
+                    versions = dependency.versions
+                    if not versions:
                         # this will cause scan_remote to use the latest version
                         futures.append(
-                            pool.submit(scan_single_dependency, dependency, None)
+                            pool.submit(scan_single_dependency, dependency.name, None)
                         )
                     else:
                         futures.extend(
                             map(
                                 lambda version: pool.submit(
-                                    scan_single_dependency, dependency, version
+                                    scan_single_dependency,
+                                    dependency.name,
+                                    version.version,
                                 ),
                                 versions,
                             )
@@ -115,9 +303,11 @@ def scan_single_dependency(dependency, version):
                 log.warning("Received keyboard interrupt, cancelling scan\n")
                 pool.shutdown(wait=False, cancel_futures=True)
 
-        return results  # type: ignore
+        return results
 
-    def scan_remote(self, url: str, branch: str, requirements_name: str) -> dict:
+    def scan_remote(
+        self, url: str, branch: str, requirements_name: str
+    ) -> tuple[List[Dependency], list[dict]]:
         """
         Scans remote requirements.txt file
 
@@ -128,8 +318,8 @@ def scan_remote(self, url: str, branch: str, requirements_name: str) -> dict:
                 Defaults to "requirements.txt".
 
         Returns:
-            dict: mapping of dependencies to scan results
-
+            deps: list of dependencies to scan
+            results: mapping of dependencies to scan results
             ex.
             {
                 ....
@@ -145,22 +335,15 @@ def scan_remote(self, url: str, branch: str, requirements_name: str) -> dict:
 
         token = self._authenticate_by_access_token()
         githubusercontent_url = url.replace("github", "raw.githubusercontent")
-
         req_url = f"{githubusercontent_url}/{branch}/{requirements_name}"
         resp = requests.get(url=req_url, auth=token)
-
-        if resp.status_code == 200:
-            dependencies = self.parse_requirements(resp.content.decode())
-            return self.scan_dependencies(dependencies)
-        else:
-            log.error(
-                f"{req_url} does not exist. Check your link or branch name."
-            )
-            sys.exit(255)
+        resp.raise_for_status()
+        dependencies = self.parse_requirements(resp.content.decode())
+        return dependencies, self.scan_dependencies(dependencies)
 
     def scan_local(
         self, path, rules=None, callback: typing.Callable[[dict], None] = noop
-    ):
+    ) -> Tuple[List[DependencyFile], list[dict]]:
         """
         Scans a local requirements files (requirements.txt, package.json, etc.)
 
@@ -170,8 +353,8 @@ def scan_local(
             callback: callback to call for each result
 
         Returns:
-            dict: mapping of dependencies to scan results
-
+            deps: list of dependencies to scan
+            results: mapping of dependencies to scan results
             ex.
             {
                 ....
@@ -183,6 +366,7 @@ def scan_local(
                     },
                 ...
             }
+
         """
 
         requirement_paths = []
@@ -195,159 +379,30 @@ def scan_local(
             else:
                 raise ValueError(f"unable to find file or directory {path}")
 
-            dependencies: dict[str, set[str]] = {}
+            dep_files: List[DependencyFile] = []
 
             for req in requirement_paths:
                 with open(req, "r") as f:
-                    self._extend_requirements(dependencies, self.parse_requirements(f.read()))
-            self.scan_dependencies(dependencies, rules, callback)
-            return dependencies
+                    dep_files.append(
+                        DependencyFile(
+                            file_path=req,
+                            dependencies=self.parse_requirements(f.read()),
+                        )
+                    )
+            deps_to_scan = [d for d_file in dep_files for d in d_file.dependencies]
+            results = self.scan_dependencies(deps_to_scan, rules, callback)
+            return dep_files, results
         except Exception as e:
-            log.error(f"Received {e}")
-            sys.exit(255)
+            log.error(f"Error while scanning. Received {e}")
+            raise e
 
     @abstractmethod
-    def parse_requirements(
-        self, raw_requirements: str
-    ) -> dict[str, set[str]]:  # returns { package: version }
+    def parse_requirements(self, raw_requirements: str) -> List[Dependency]:
         pass
 
     @abstractmethod
     def find_requirements(
-            self, directory: str,
+        self,
+        directory: str,
     ) -> list[str]:  # returns paths of files
         pass
-
-    @staticmethod
-    def _extend_requirements(reqs_a : dict[str, set[str]], reqs_b: dict[str, set[str]]):
-        for name, versions in reqs_b.items():
-            if name not in reqs_a:
-                reqs_a[name] = versions
-            else:
-                reqs_a[name].update(versions)
-
-
-class PackageScanner:
-    """
-    Scans package for attack vectors based on source code and metadata rules
-
-    Attributes:
-        analyzer (Analyzer): Analyzer for source code and metadata rules
-    """
-
-    def __init__(self, analyzer: Analyzer):
-        super().__init__()
-        self.analyzer = analyzer
-
-    def scan_local(
-        self, path, rules=None, callback: typing.Callable[[dict], None] = noop
-    ) -> dict:
-        """
-        Scans local package
-
-        Args:
-            path (str): Path to the directory containing the package to analyze
-            rules (set, optional): Set of rule names to use. Defaults to all rules.
-            callback (typing.Callable[[dict], None], optional): Callback to apply to Analyzer output
-
-        Raises:
-            Exception: Analyzer exception
-
-        Returns:
-            dict: Analyzer output with rules to results mapping
-        """
-
-        if rules is not None:
-            rules = set(rules)
-
-        results = self.analyzer.analyze_sourcecode(path, rules=rules)
-        callback(results)
-
-        return results
-
-    @abstractmethod
-    def download_and_get_package_info(
-        self, directory: str, package_name: str, version=None
-    ) -> typing.Tuple[dict, str]:
-        raise NotImplementedError("download_and_get_package_info is not implemented")
-
-    def _scan_remote(
-        self, name, base_dir, version=None, rules=None, write_package_info=False
-    ):
-        directory = os.path.join(os.path.dirname(os.path.abspath(__file__)), base_dir)
-
-        file_path = None
-        package_info = None
-        try:
-            package_info, file_path = self.download_and_get_package_info(
-                directory, name, version
-            )
-        except Exception as e:
-            log.debug("Unable to download package, ignoring: " + str(e))
-            return {"issues": 0, "errors": {"download-package": str(e)}}
-
-        results = self.analyzer.analyze(file_path, package_info, rules, name, version)
-        if write_package_info:
-            package_name = name.replace("/", "-")
-            suffix = (
-                f"{package_name}-{version}" if version is not None else package_name
-            )
-            with open(
-                os.path.join(results["path"], f"package_info-{suffix}.json"), "w"
-            ) as file:
-                file.write(json.dumps(package_info))
-
-        return results
-
-    def scan_remote(
-        self, name, version=None, rules=None, base_dir=None, write_package_info=False
-    ):
-        """
-        Scans a remote package
-
-        Args:
-            * `name` (str): name of the package on PyPI
-            * `version` (str, optional): version of package (ex. 0.0.1). If not specified, the latest version is assumed
-            * `rules` (set, optional): Set of rule names to use. Defaults to all rules.
-            * `base_dir` (str, optional): directory to use to download package to. If not specified, a temporary folder
-            is created and cleaned up automatically. If not specified, the provided directory is not removed after the
-            scan.
-            * `write_package_info` (bool, default False): if set to true, the result of the PyPI metadata API is written
-             to a json file
-
-        Raises:
-            Exception: Analyzer exception
-
-        Returns:
-            dict: Analyzer output with rules to results mapping
-        """
-        if base_dir is not None:
-            return self._scan_remote(name, base_dir, version, rules, write_package_info)
-
-        with tempfile.TemporaryDirectory() as tmpdirname:
-            # Directory to download compressed and uncompressed package
-            return self._scan_remote(
-                name, tmpdirname, version, rules, write_package_info
-            )
-
-    def download_compressed(self, url, archive_path, target_path):
-        """Downloads a compressed file and extracts it
-
-        Args:
-            url (str): download link
-            archive_path (str): path to download compressed file
-            target_path (str): path to unzip compressed file
-        """
-
-        log.debug(f"Downloading package archive from {url} into {target_path}")
-        response = requests.get(url, stream=True)
-
-        with open(archive_path, "wb") as f:
-            f.write(response.raw.read())
-
-        try:
-            safe_extract(archive_path, target_path)
-            log.debug(f"Successfully extracted files to {target_path}")
-        finally:
-            log.debug(f"Removing temporary archive file {archive_path}")
-            os.remove(archive_path)


Test patch:
diff --git a/tests/core/test_github_action_project_scanner.py b/tests/core/test_github_action_project_scanner.py
index 2a27e3e2..a868e086 100644
--- a/tests/core/test_github_action_project_scanner.py
+++ b/tests/core/test_github_action_project_scanner.py
@@ -14,13 +14,24 @@ def test_githubactions_parse_requirements():
         os.path.join(pathlib.Path(__file__).parent.resolve(), "resources", "workflow.yaml"),
         "r",
     ) as f:
-        requirements = scanner.parse_requirements(f.read())
-        assert requirements == {
-            "actions/checkout": {"v4.2.2", "11bd71901bbe5b1630ceea73d27597364c9af683"},
-            "actions/setup-python": {"v5.3.0"},
-            "actions/create-github-app-token": {"0d564482f06ca65fa9e77e2510873638c82206f2"},
-            "peter-evans/create-pull-request": {"v7"},
-        }
+
+        result = scanner.parse_requirements(f.read())
+
+        for p, v in [
+            ("actions/checkout", "11bd71901bbe5b1630ceea73d27597364c9af683"),
+            ("actions/checkout", "v4.2.2"),
+            ("actions/setup-python", "v5.3.0"),
+            (
+                "actions/create-github-app-token",
+                "0d564482f06ca65fa9e77e2510873638c82206f2",
+            ),
+            ("peter-evans/create-pull-request", "v7"),
+        ]:
+            lookup = next(
+                filter(lambda r: r.name == p, result), None
+            )
+            assert lookup
+            assert v in lookup.versions
 
 def test_githubactions_find_requirements():
     scanner = GitHubActionDependencyScanner()
@@ -29,7 +40,7 @@ def test_githubactions_find_requirements():
     # fake a git repo in the test folder somehow
     repo_root = pathlib.Path(__file__).parent.parent.parent.resolve()
     requirements = scanner.find_requirements(
-        repo_root
+        str(repo_root)
     )
     assert requirements.sort() == [
         os.path.join(repo_root, ".github", "workflows", "docker-release.yml"),
@@ -40,7 +51,6 @@ def test_githubactions_find_requirements():
     ].sort()
 
 
-
 @pytest.mark.parametrize(
     "step,expected_action",
     [
diff --git a/tests/core/test_go_project_scanner.py b/tests/core/test_go_project_scanner.py
index 200b290b..490e1773 100644
--- a/tests/core/test_go_project_scanner.py
+++ b/tests/core/test_go_project_scanner.py
@@ -11,11 +11,17 @@ def test_go_parse_requirements():
         os.path.join(pathlib.Path(__file__).parent.resolve(), "resources", "go.mod"),
         "r",
     ) as f:
-        requirements = scanner.parse_requirements(f.read())
-        assert requirements == {
-            "go.uber.org/multierr": {"v1.11.0"},
-            "go.uber.org/zap": {"v1.27.0"},
-        }
+        result = scanner.parse_requirements(f.read())
+
+        for p, v in [
+            ("go.uber.org/multierr", "v1.11.0"),
+            ("go.uber.org/zap", "v1.27.0"),
+        ]:
+            lookup = next(
+                filter(lambda r: r.name == p, result), None
+            )
+            assert lookup
+            assert v in lookup.versions
 
 
 def test_go_find_requirements():
diff --git a/tests/core/test_npm_requirements_scanner.py b/tests/core/test_npm_requirements_scanner.py
index 2ad4921c..66564886 100644
--- a/tests/core/test_npm_requirements_scanner.py
+++ b/tests/core/test_npm_requirements_scanner.py
@@ -38,7 +38,14 @@ def test_npm_requirements_scanner_github():
         }
     }
     """)
-    assert "express" in result
-    assert "expressjs/express" in result["express"]
-    assert "cors" in result
-    assert "https://github.com/expressjs/cors.git" in result["cors"]
+    lookup = next(
+        filter(lambda r: r.name == "express", result), None
+    )
+    assert lookup is not None
+    assert "expressjs/express" in lookup.versions
+
+    lookup = next(
+        filter(lambda r: r.name == "cors", result), None
+    )
+    assert lookup is not None
+    assert "https://github.com/expressjs/cors.git" in lookup.versions
diff --git a/tests/core/test_pypi_requirements_scanner.py b/tests/core/test_pypi_requirements_scanner.py
index 228e4713..4ecdef7a 100644
--- a/tests/core/test_pypi_requirements_scanner.py
+++ b/tests/core/test_pypi_requirements_scanner.py
@@ -10,10 +10,17 @@ def test_requirements_scanner():
     result = scanner.parse_requirements(
         "\n".join(["not-a-real-package==1.0.0", "flask==2.2.2"])
     )
-    assert "not-a-real-package" not in result  # ignoring non existing packages
-    assert "flask" in result
+    assert "not-a-real-package" not in result
+
+    for p, v in [("flask", "2.2.2")]:
+        lookup = next(
+            filter(lambda r: r.name == p, result), None
+        )
+        assert lookup
+        assert v in lookup.versions
 
-def test_npm_find_requirements():
+
+def test_pypi_find_requirements():
     scanner = PypiRequirementsScanner()
 
 
@@ -35,7 +42,10 @@ def test_requirements_scanner_on_git_url_packages():
             ]
         )
     )
-    assert "guarddog" in result
-    assert "git+https://github.com/DataDog/guarddog.git" in result["guarddog"]
+    lookup = next(
+        filter(lambda r: r.name == "guarddog", result), None
+    )
+    assert lookup is not None
+    assert "git+https://github.com/DataDog/guarddog.git" in [v.version for v in lookup.versions]
     assert "flask" in result
     assert len(result) == 2
diff --git a/tests/reporters/test_sarif.py b/tests/reporters/test_sarif.py
index 126acc96..91bd42d8 100644
--- a/tests/reporters/test_sarif.py
+++ b/tests/reporters/test_sarif.py
@@ -7,6 +7,8 @@
 
 from guarddog.cli import _verify
 from guarddog.ecosystems import ECOSYSTEM
+from guarddog.reporters.sarif import SarifReporter
+from guarddog.scanners.scanner import Dependency, DependencyFile, DependencyVersion
 
 dir_path = os.path.dirname(os.path.realpath(__file__))
 
@@ -137,24 +139,32 @@
 )
 def test_sarif_output(manifest, ecosystem, local_scan_results, warning_count):
 
-    def monkey_localscan(*args, **kwargs):
-        return local_scan_results
+    def build_dependencies():
+        return [
+            DependencyFile(
+                file_path=manifest,
+                dependencies=[
+                    Dependency(
+                        name=r["dependency"],
+                        versions={
+                            DependencyVersion(version=r["version"], location=1)
+                        },
+                    )
+                    for r in local_scan_results
+                ],
+            )
+        ]
 
-    MonkeyPatch().setattr(
-        "guarddog.scanners.scanner.ProjectScanner.scan_local", monkey_localscan
+    stdout, _ = SarifReporter.render_verify(
+        dependency_files=build_dependencies(),
+        rule_names=[],
+        scan_results=local_scan_results,
+        ecosystem=ecosystem,
     )
 
-    raw_output = _verify(
-        os.path.join(dir_path, "..", "core", "resources", manifest),
-        (),
-        (),
-        "sarif",
-        False,
-        ecosystem,
-    )
     with tempfile.TemporaryDirectory() as tmp_dirname:
         with open(os.path.join(tmp_dirname, "results.sarif"), "w") as fd:
-            fd.write(raw_output)
+            fd.write(stdout)
         sarif_data = load_sarif_file(os.path.join(tmp_dirname, "results.sarif"))
         stats_warning = sarif_data.get_report().get_issue_count_for_severity("warning")
         assert stats_warning == warning_count