Skip to content
Merged
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,12 @@ GuardDog's behavior can be customized using environment variables:
| `GUARDDOG_TOP_PACKAGES_CACHE_LOCATION` | Location of the top packages cache directory | `guarddog/analyzer/metadata/resources` |
| `GUARDDOG_YARA_EXT_EXCLUDE` | Comma-separated list of file extensions to exclude from YARA scanning | `ini,md,rst,txt,lock,json,yaml,yml,toml,xml,html,csv,sql,pdf,doc,docx,ppt,pptx,xls,xlsx,odt,changelog,readme,makefile,dockerfile,pkg-info,d.ts` |

#### Metadata Rule Configuration

| Environment Variable | Description | Default Value |
|---------------------|-------------|---------------|
| `GUARDDOG_NEW_DEPENDENCY_RISK_THRESHOLD` | Minimum risk score for a newly introduced dependency to flag the parent package in the `risky_new_dependency` rule | `5.0` |

#### Archive Extraction Security Limits

GuardDog implements multiple security checks when extracting package archives to protect against compression bombs and file descriptor exhaustion attacks:
Expand Down
1 change: 1 addition & 0 deletions RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,5 +86,6 @@ Rules are categorized by their `identifies` field which determines how they part
| deceptive_author | `threat.metadata.deceptive-author` | This heuristic detects when an author is using a disposable email | medium | initial-access | :white_check_mark: | :white_check_mark: | | | | |
| metadata_mismatch | `threat.metadata.manifest-mismatch` | Identify packages with mismatches between registry metadata and the actual package manifest | medium | execution | :white_check_mark: | :white_check_mark: | | | | |
| direct_url_dependency | `threat.metadata.direct-url-dep` | Identify packages with direct URL dependencies. Dependencies fetched this way are not immutable and can be used to inject untrusted code or reduce the likelihood of a reproducible install. | medium | initial-access | | :white_check_mark: | | | | |
| risky_new_dependency | `threat.npm.risky-new-dependency` | Identify newly added dependencies that are themselves risky. A dependency introduced in this version but absent from the previous one is scanned as a package; it is flagged when its risk score is high. | high | initial-access | | :white_check_mark: | | | | |

<!-- END_RULE_LIST -->
4 changes: 4 additions & 0 deletions guarddog/analyzer/metadata/npm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
from guarddog.analyzer.metadata.npm.metadata_mismatch import NPMMetadataMismatchDetector
from guarddog.analyzer.metadata.npm.bundled_binary import NPMBundledBinary
from guarddog.analyzer.metadata.npm.deceptive_author import NPMDeceptiveAuthor
from guarddog.analyzer.metadata.npm.risky_new_dependency import (
NPMRiskyNewDependencyDetector,
)

NPM_METADATA_RULES = {}

Expand All @@ -22,6 +25,7 @@
NPMMetadataMismatchDetector,
NPMBundledBinary,
NPMDeceptiveAuthor,
NPMRiskyNewDependencyDetector,
]

for detectorClass in classes:
Expand Down
283 changes: 283 additions & 0 deletions guarddog/analyzer/metadata/npm/risky_new_dependency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
"""Risky New Dependency Detector

When a new version of a package adds a dependency that was not present in the
previous published version, that dependency is scanned like any other package.
If it scores at or above the risk threshold, the parent package is flagged so a
maliciously introduced dependency surfaces on the parent scan.
"""

import json
import logging
import os
import subprocess
import sys
from dataclasses import dataclass, field
from typing import List, Optional

from guarddog.analyzer.metadata.detector import Detector
from guarddog.analyzer.risk_engine import RiskLabel
from guarddog.utils.config import NEW_DEPENDENCY_RISK_THRESHOLD
from guarddog.utils.npm import highest_matching_version, resolve_npm_alias

log = logging.getLogger("guarddog")


@dataclass
class DependencyRisk:
"""Outcome of scanning a single newly added dependency."""

version: str
score: float
label: str
matched_rules: List[str] = field(default_factory=list)


# Upper bound on a single sub-dependency scan so one slow scan can't hang the parent.
SUBSCAN_TIMEOUT_SECONDS = 300

# Keys in the registry `time` object that are not version publish timestamps.
_NON_VERSION_TIME_KEYS = {"created", "modified"}

# Risk labels phrased to match GuardDog's overall assessment wording.
_LABEL_PHRASE = {
RiskLabel.HIGH_RISK.value: "high risk",
RiskLabel.SUSPICIOUS.value: "suspicious",
RiskLabel.LOW.value: "low risk",
}
Comment thread
christophetd marked this conversation as resolved.


class NPMRiskyNewDependencyDetector(Detector):
"""Detects dependencies newly introduced in a version that are themselves risky.

The previous published version is determined from the registry `time` map; any
dependency name present in the scanned version but absent from the previous one
is scanned as a standalone package. Each sub-scan runs as a subprocess invocation
of guarddog, so it is sandboxed identically to the parent, and excludes this rule
so the check never recurses beyond one level."""

def __init__(self):
super().__init__(
name="risky_new_dependency",
description="Identify newly added dependencies that are themselves risky. "
"A dependency introduced in this version but absent from the previous one "
"is scanned as a package; it is flagged when its risk score is high.",
identifies="threat.npm.risky-new-dependency",
severity="high",
mitre_tactics="initial-access",
specificity="high",
sophistication="low",
)

def detect(
self,
package_info,
path: Optional[str] = None,
name: Optional[str] = None,
version: Optional[str] = None,
) -> tuple[bool, Optional[str]]:
package_name = name or package_info.get("name", "")
versions = package_info.get("versions", {})
current_version = version or package_info.get("dist-tags", {}).get("latest")
if not current_version or current_version not in versions:
log.debug(
f"[{self.name}] No usable version for '{package_name}' "
f"(resolved '{current_version}'); skipping"
)
return False, None

previous_version = self._previous_published_version(
package_info, current_version
)
if previous_version is None:
log.debug(
f"[{self.name}] '{package_name}@{current_version}' has no previous "
f"published version; skipping"
)
return False, None

current_deps = self._installed_dependencies(versions.get(current_version, {}))
previous_deps = self._installed_dependencies(versions.get(previous_version, {}))
new_dependencies = set(current_deps) - set(previous_deps)
if not new_dependencies:
log.debug(
f"[{self.name}] '{package_name}@{current_version}' adds no new "
f"dependencies vs '{previous_version}'; skipping"
)
return False, None

log.debug(
f"[{self.name}] '{package_name}@{current_version}' adds "
f"{len(new_dependencies)} new dependency(ies) vs '{previous_version}': "
f"{', '.join(sorted(new_dependencies))}"
)

findings = []
for dep_name in sorted(new_dependencies):
risk = self._scan_dependency(dep_name, current_deps[dep_name])
Comment thread
christophetd marked this conversation as resolved.
if risk is None:
log.debug(f"[{self.name}] Could not score new dependency '{dep_name}'")
continue
flagged = risk.score >= NEW_DEPENDENCY_RISK_THRESHOLD
log.debug(
f"[{self.name}] New dependency '{dep_name}@{risk.version}' scored "
f"{risk.score} ({risk.label}); threshold {NEW_DEPENDENCY_RISK_THRESHOLD} "
f"-> {'FLAGGED' if flagged else 'below threshold'}; "
f"matched rules: {', '.join(risk.matched_rules) or 'none'}"
)
if flagged:
matched = (
f" Matched rules: {', '.join(risk.matched_rules)}."
if risk.matched_rules
else ""
)
phrase = _LABEL_PHRASE.get(risk.label, risk.label or "risky")
findings.append(
f"Newly added dependency {dep_name}@{risk.version} is {phrase} "
f"(risk score {risk.score}/10).{matched} It was introduced in "
f"{package_name}@{current_version} and was not a dependency of "
f"the previous version {previous_version}."
)

log.debug(
f"[{self.name}] '{package_name}@{current_version}': "
f"{len(findings)} risky new dependency(ies) found"
)
return len(findings) != 0, "\n".join(findings)

def _previous_published_version(
self, package_info, current_version: str
) -> Optional[str]:
"""Return the version published immediately before `current_version`.

Publish times come from the registry `time` map; ISO 8601 timestamps sort
Comment thread
christophetd marked this conversation as resolved.
lexicographically, so the previous version is the one with the greatest
timestamp strictly before the current version's.
"""
published = {
v: t
for v, t in package_info.get("time", {}).items()
if v not in _NON_VERSION_TIME_KEYS and v in package_info.get("versions", {})
}
current_time = published.get(current_version)
if current_time is None:
return None

earlier = [(t, v) for v, t in published.items() if t < current_time]
if not earlier:
return None
return max(earlier)[1]

@staticmethod
def _installed_dependencies(version_info: dict) -> dict:
"""Map real package name -> version selector for the dependencies npm
installs by default: `dependencies` and `optionalDependencies` (optional
installs are non-fatal but still run). npm aliases are resolved to the real
package so the diff and sub-scan target the aliased package, not the local
alias name (e.g. "x": "npm:evil@1" -> {"evil": "1"})."""
resolved: dict = {}
for section in ("dependencies", "optionalDependencies"):
Comment thread
christophetd marked this conversation as resolved.
for name, spec in (version_info.get(section) or {}).items():
real_name, selector = resolve_npm_alias(name, spec)
resolved[real_name] = selector
return resolved

def _scan_dependency(self, dep_name: str, spec: str) -> Optional[DependencyRisk]:
"""Scan a single dependency as a subprocess and return its risk outcome.

The sub-scan runs with source-code rules only: every metadata rule is
excluded. A maliciously introduced dependency reveals itself through its
code (obfuscation, exec, exfiltration, install hooks), whereas metadata
rules (typosquatting, manifest mismatch, ...) are a weak, noisy signal in
this context. Excluding all metadata rules also excludes this one, so the
check never recurses. Returns None when the scan can't be run or produced
no score. The subprocess takes the same CLI path as a top-level scan, so
it is sandboxed identically to the parent."""
resolved_version = None
try:
resolved_version = highest_matching_version(dep_name, spec)
except Exception as e:
log.debug(f"Could not resolve version for {dep_name} ({spec}): {e}")
log.debug(
f"[{self.name}] Resolved '{dep_name}' spec '{spec}' to version "
f"'{resolved_version or 'latest'}'"
)

command = [
Comment thread
sobregosodd marked this conversation as resolved.
sys.executable,
"-m",
"guarddog",
"npm",
"scan",
dep_name,
"--output-format",
"json",
]
for metadata_rule in self._metadata_rule_names():
command += ["--exclude-rules", metadata_rule]
if resolved_version:
command += ["--version", resolved_version]

sandbox_choice = os.environ.get("GUARDDOG_SUBSCAN_SANDBOX")
Comment thread
christophetd marked this conversation as resolved.
if sandbox_choice == "1":
command.append("--sandbox")
elif sandbox_choice == "0":
command.append("--no-sandbox")

log.debug(f"[{self.name}] Scanning new dependency: {' '.join(command)}")
try:
completed = subprocess.run(
command,
capture_output=True,
text=True,
timeout=SUBSCAN_TIMEOUT_SECONDS,
)
except subprocess.TimeoutExpired:
log.debug(f"Timed out scanning new dependency {dep_name}")
return None

try:
result = json.loads(completed.stdout)
except json.JSONDecodeError:
log.debug(
f"Could not parse scan output for new dependency {dep_name}: "
f"{completed.stderr.strip()}"
)
return None

risk_score = result.get("risk_score") or {}
score = risk_score.get("score")
if score is None:
return None

return DependencyRisk(
version=resolved_version or "latest",
score=float(score),
label=risk_score.get("label", ""),
matched_rules=self._matched_rules(result),
)

@staticmethod
def _matched_rules(result: dict) -> List[str]:
"""Distinct rule names that flagged the dependency, in first-seen order.

Prefers the threat rules behind each risk; falls back to any metadata rule
that produced a message when no source-code risks were formed."""
rules: list[str] = []
for risk in result.get("risks", []):
rule = risk.get("threat_rule")
if rule and rule not in rules:
rules.append(rule)
if not rules:
for rule, message in (result.get("results") or {}).items():
if message and rule not in rules:
rules.append(rule)
return rules

@staticmethod
def _metadata_rule_names() -> List[str]:
"""All npm metadata rule names, excluded from the dependency sub-scan so it
runs source-code rules only. Imported lazily to avoid a circular import."""
from guarddog.analyzer.metadata import get_metadata_detectors
from guarddog.ecosystems import ECOSYSTEM

return list(get_metadata_detectors(ECOSYSTEM.NPM).keys())
4 changes: 4 additions & 0 deletions guarddog/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,10 @@ def _scan(
else:
sandbox = True

# Propagate the resolved sandbox decision so any sub-dependency scan spawned by a
# metadata rule (risky_new_dependency) is sandboxed identically to this scan.
os.environ["GUARDDOG_SUBSCAN_SANDBOX"] = "1" if sandbox else "0"

rule_param = _get_rule_param(rules, exclude_rules, ecosystem)
scanner = get_package_scanner(ecosystem)
if scanner is None:
Expand Down
22 changes: 12 additions & 10 deletions guarddog/reporters/human_readable.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,16 +231,18 @@ def _format_one_risk(
]
if desc:
block.append(" " + colored(desc, sev_color))

inspector_url = (
HumanReadableReporter._pypi_finding_inspector_url(deep_base, loc_raw)
if deep_base and loc_raw
else None
)
loc_line = colored(f"{loc_kw} {_sanitize(loc)}", "dark_grey")
if inspector_url:
loc_line = HumanReadableReporter._hyperlink(inspector_url, loc_line)
block.append(" " + loc_line)
# Metadata findings carry no file location; only show the line when there is
# one so it never renders as a bare "in ".
if loc:
inspector_url = (
HumanReadableReporter._pypi_finding_inspector_url(deep_base, loc_raw)
if deep_base and loc_raw
else None
)
loc_line = colored(f"{loc_kw} {_sanitize(loc)}", "dark_grey")
if inspector_url:
loc_line = HumanReadableReporter._hyperlink(inspector_url, loc_line)
block.append(" " + loc_line)

code = risk.get("threat_code", "")
if code:
Expand Down
Loading
Loading