Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 31 additions & 5 deletions guarddog/scanners/npm_project_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@

log = logging.getLogger("guarddog")

NPM_ALIAS_PATTERN = re.compile(
r"^npm:(?P<package>@[^/@\s]+/[^@\s]+|[^@\s]+)(?:@(?P<selector>.+))?$"
)


class NPMRequirementsScanner(ProjectScanner):
"""
Expand Down Expand Up @@ -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]:
"""
Expand Down Expand Up @@ -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():
Expand All @@ -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,
Expand Down
32 changes: 32 additions & 0 deletions tests/core/test_npm_requirements_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading