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
timeobject, which maps each released version to an ISO-8601 upload timestamp. - For PyPI packages, it reads the
releasesobject and extracts theupload_time_iso_8601orupload_timeentry 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 likepoetry.lockorpackage-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
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.