diff --git a/guarddog/scanners/npm_project_scanner.py b/guarddog/scanners/npm_project_scanner.py index 7cd88d0dd..784d3c35c 100644 --- a/guarddog/scanners/npm_project_scanner.py +++ b/guarddog/scanners/npm_project_scanner.py @@ -13,6 +13,10 @@ log = logging.getLogger("guarddog") +NPM_ALIAS_PATTERN = re.compile( + r"^npm:(?P@[^/@\s]+/[^@\s]+|[^@\s]+)(?:@(?P.+))?$" +) + class NPMRequirementsScanner(ProjectScanner): """ @@ -48,6 +52,19 @@ def parse_requirements(self, raw_requirements: str) -> List[Dependency]: dev_dependencies_attr = ( package["devDependencies"] if "devDependencies" in package else {} ) + raw_requirement_lines = raw_requirements.splitlines() + + def resolve_dependency_spec(package_name: str, selector: str) -> tuple[str, str]: + """ + Normalizes npm alias selectors so scanning targets the real package. + ex: {"alias": "npm:react@19.2.3"} -> ("react", "19.2.3") + """ + match = NPM_ALIAS_PATTERN.match(selector) + if match is None: + return package_name, selector + + resolved_selector = match.group("selector") or "*" + return match.group("package"), resolved_selector def get_matched_versions(versions: set[str], semver_range: str) -> set[str]: """ @@ -86,12 +103,20 @@ def find_all_versions(package_name: str) -> set[str]: return versions merged = {} # type: dict[str, set[str]] + merged_original_names = {} # type: dict[str, set[str]] 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) + resolved_package, resolved_selector = resolve_dependency_spec( + package, selector + ) + if resolved_package not in merged: + merged[resolved_package] = set() + merged[resolved_package].add(resolved_selector) + + if resolved_package not in merged_original_names: + merged_original_names[resolved_package] = set() + merged_original_names[resolved_package].add(package) dependencies: List[Dependency] = [] for package, all_selectors in merged.items(): @@ -105,12 +130,13 @@ def find_all_versions(package_name: str) -> set[str]: log.error(f"Package/Version {package} not on NPM\n") continue + line_match_terms = merged_original_names.get(package, set([package])) idx = next( iter( [ ix - for ix, line in enumerate(raw_requirements.splitlines()) - if package in line + for ix, line in enumerate(raw_requirement_lines) + if any(term in line for term in line_match_terms) ] ), 0, diff --git a/tests/core/test_npm_requirements_scanner.py b/tests/core/test_npm_requirements_scanner.py index f722baa18..edcfbb974 100644 --- a/tests/core/test_npm_requirements_scanner.py +++ b/tests/core/test_npm_requirements_scanner.py @@ -55,3 +55,35 @@ def test_npm_requirements_scanner_github(): ) assert lookup is not None assert "https://github.com/expressjs/cors.git" in lookup.versions + + +def test_npm_requirements_scanner_alias(): + scanner = NPMRequirementsScanner() + result = scanner.parse_requirements(""" + { + "dependencies": { + "this-alias-should-not-be-scanned-directly": "npm:express@4.x" + } + } + """) + + assert "this-alias-should-not-be-scanned-directly" not in result + lookup = next(filter(lambda r: r.name == "express", result), None) + assert lookup is not None + assert len(lookup.versions) > 0 + + +def test_npm_requirements_scanner_scoped_alias(): + scanner = NPMRequirementsScanner() + result = scanner.parse_requirements(""" + { + "dependencies": { + "this-scoped-alias-should-not-be-scanned-directly": "npm:@types/node@*" + } + } + """) + + assert "this-scoped-alias-should-not-be-scanned-directly" not in result + lookup = next(filter(lambda r: r.name == "@types/node", result), None) + assert lookup is not None + assert len(lookup.versions) > 0