Dev.to WebDev 🛠 Dev 👁 0 📖 6 min read

Guarding CI Pipelines with Dependency Release Age Gates

When an attacker compromises a package maintainer's credentials or publishes a typo-squatted package to PyPI or npm, the malicious release is immediately visible to automated tooling. Continuous integration pipelines, au

When an attacker compromises a package maintainer's credentials or publishes a typo-squatted package to PyPI or npm, the malicious release is immediately visible to automated tooling. Continuous integration pipelines, automated upgrade bots, and routine builds that pull the newest pinned dependencies will fetch and execute that code right away.

In most cases, malicious releases are identified and yanked within a few days once security monitors and maintainers notice the compromise. However, if your automated builds pull new releases the moment they appear, your environment acts as the test bed during that dangerous early window.

A dependency release age gate solves this by enforcing a cooldown period. By auditing dependency manifests and lockfiles to verify that every pinned package has existed in the public registry for a minimum number of days, you introduce a buffer period where bad releases can be caught before your build consumes them.

The Problem

Development workflows frequently update lockfiles through automated bots or manual upgrades. When dependencies update immediately to a release published only hours earlier, the build takes on unvetted supply chain exposure.

Common threats during this early window include:

  • Compromised developer accounts publishing malicious minor or patch updates.
  • Fast package takeovers where existing package names are hijacked.
  • Accidental breakages or incomplete releases that get replaced shortly after publishing.

To mitigate this without completely halting updates, pipelines need an automated verification step: checking the upload timestamp of pinned dependencies against a cooldown policy. If an update is too young, the pipeline should flag it and recommend an older, verified version that meets the cooldown requirement.

Install & Usage

The tool is implemented as a self-contained Python script. It relies exclusively on the Python standard library, requiring no external packages from package managers.

Installation

Download the script file and place it in your project repository under the name depcooldown_dependency_release_age_gate.py.

The script runs on standard Python. If you are running Python 3.11 or later, it uses the built-in tomllib module to read TOML files. If you run an earlier Python release, it uses an internal fallback parser to process package definitions from lockfiles.

Command-Line Usage

To inspect the supported command-line options:

python depcooldown_dependency_release_age_gate.py --help

To run an audit against a Python requirements.txt file:

python depcooldown_dependency_release_age_gate.py requirements.txt

You can audit an npm lockfile by passing the path to your package-lock.json:

python depcooldown_dependency_release_age_gate.py package-lock.json

The script can also inspect modern Python lockfiles and manifests, such as poetry.lock, uv.lock, and pyproject.toml.

How It Works

The tool works through three main stages: parsing manifest files, fetching upload timestamps from registries, and evaluating ages against a cutoff window.

1. Extracting Pinned Dependencies

The tool inspects the structure of the input file to detect whether it belongs to the Python or npm ecosystem. It extracts package names and strictly pinned versions.

Here is the implementation from parse_requirements_txt:

def parse_requirements_txt(content: str) -> Dict[str, str]:
    """Extract strictly pinned dependencies (name==version) from requirements.txt."""
    pinned: Dict[str, str] = {}
    for raw_line in content.splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#") or line.startswith("-"):
            continue
        # Strip trailing comments and environment markers
        if " #" in line:
            line = line.split(" #", 1)[0].strip()
        if ";" in line:
            line = line.split(";", 1)[0].strip()

        # Match name[extras]==version
        match = re.match(r"^([A-Za-z0-9_.\-]+)(?:\[[^\]]*\])?\s*==\s*([A-Za-z0-9_.\-+]+)$", line)
        if match:
            pkg_name = match.group(1).lower().replace("_", "-")
            version = match.group(2).strip()
            pinned[pkg_name] = version
    return pinned

For npm, it reads package-lock.json and extracts versions from the packages dictionary (used in modern lockfiles) or the legacy dependencies block. For Python project manifests, it reads dependencies from pyproject.toml, poetry.lock, or uv.lock.

2. Fetching Release Timestamps

The RegistryClient queries public registry metadata endpoints using standard library urllib.request.

  • For npm packages, it inspects the registry JSON metadata and reads the time object, which maps each released version to an ISO-8601 upload timestamp.
  • For PyPI packages, it reads the releases object and extracts the upload_time_iso_8601 or upload_time entry associated with the release files.

The client stores fetched metadata in an in-memory dictionary and can serialize it to a local JSON cache file to prevent redundant network lookups across runs.

3. Evaluating Cooldown Violations

The audit core compares the release timestamp against the required age window:

def audit_dependency(
    ecosystem: str,
    package: str,
    version: str,
    client: RegistryClient,
    cooldown_days: int,
    reference_time: datetime.datetime,
) -> AuditResult:
    """Audit a single pinned package version against the cooldown window."""
    history = client.fetch_release_history(ecosystem, package)
    cutoff = reference_time - datetime.timedelta(days=cooldown_days)

    if not history or version not in history:
        return AuditResult(
            package=package,
            ecosystem=ecosystem,
            current_version=version,
            upload_time=None,
            age_days=None,
            is_violation=False,
            suggested_version=None,
            reason="Unverified (version metadata not found in registry)",
        )

    upload_dt = parse_iso_timestamp(history[version])
    age = (reference_time - upload_dt).total_seconds() / 86400.0

    if upload_dt > cutoff:
        # Pinned version is in the cooldown window! Find newest safe release
        eligible_versions: List[Tuple[Tuple[int, int, int, str], str]] = []
        for ver_str, ts_str in history.items():
            try:
                candidate_dt = parse_iso_timestamp(ts_str)
                if candidate_dt <= cutoff:
                    parsed_v = parse_semver(ver_str)
                    eligible_versions.append((parsed_v, ver_str))
            except Exception:
                continue

If a package version was uploaded after the cutoff date, audit_dependency marks it as a violation. It then loops through all historical releases in the registry metadata, identifies those uploaded prior to the cutoff, sorts them using parse_semver, and returns the newest safe version as a recommendation.

Limitations

Before deploying the script into your automation, keep the following structural limitations in mind:

  • Strict Pinning Requirement: The parser only evaluates exact equality pins (==). Flexible ranges such as >=1.2.0, ~=2.1, or caret dependencies in manifests are ignored unless resolved into a concrete lockfile like poetry.lock or package-lock.json.
  • Simplified Version Parsing: Version comparison relies on parse_semver, which splits versions into major, minor, patch, and prerelease components. Complex PEP 440 specifications (such as post-releases, epoch numbers, or local versions) are parsed as zero-fallbacks or plain integers rather than strictly compliant PEP 440 objects.
  • Minimal TOML Fallback: When running on Python versions older than 3.11 without tomllib, the script falls back to a basic line-by-line parser. While this fallback handles standard [[package]] entries in Poetry and uv lockfiles, complex or nested TOML structures may fail to parse.
  • Registry Unavailability: If the registry metadata endpoint is unreachable, timed out, or blocked by a proxy, the tool returns an unverified status for that dependency rather than aborting.

FAQ

Can this tool be run in an environment without internet access?

Yes. The RegistryClient supports an offline mode and local JSON cache files. If metadata was previously fetched and cached to disk, the script evaluates dependencies directly against the stored JSON cache without performing network calls.

How does the script handle npm scoped packages?

The script handles scoped npm packages (for example, @scope/package) by preserving the @ character while percent-encoding the path separator when querying the npm registry endpoint.

What happens if a brand-new package has no versions meeting the cooldown threshold?

If a package is entirely new and none of its released versions were published before the cutoff threshold, the tool flags the pinned version as a cooldown violation and leaves the suggested version empty, indicating that no safe historical release exists in the registry.

Full version and the rest of the toolkit: DepCooldown dependency release age gate on PyForge. Product page: https://py-forge.com/products/depcooldown-dependency-release-age-gate

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.