Last active
June 1, 2026 09:25
-
-
Save Hamid-K/9ce98023f73e5d3eb7ea101f9cacedb2 to your computer and use it in GitHub Desktop.
Package manager release-age gate scanner and hardener
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Scan and harden package-manager release-age gates. | |
| This script is intentionally self-contained: it uses only the Python standard | |
| library and writes only user-level config files. It covers the package-manager | |
| ecosystems inventoried by Perplexity Bumblebee plus additional common language | |
| package managers. | |
| Native age gates currently handled in harden mode: | |
| - npm: ~/.npmrc -> min-release-age=<days> | |
| - pnpm: global config.yaml -> minimumReleaseAge=<minutes> | |
| - Yarn: ~/.yarnrc.yml -> npmMinimalAgeGate="<days>d" (Yarn v4.10+ / Berry) | |
| - Bun: ~/.bunfig.toml -> [install].minimumReleaseAge=<seconds> | |
| - uv: ~/.config/uv/uv.toml -> exclude-newer="<RFC3339 cutoff>" | |
| Other package managers are scanned and reported, but not modified unless they | |
| have a native install-time publish/upload-age gate. Do not fake this control | |
| with unrelated settings. | |
| Existing files modified by harden mode get timestamped backups by default. | |
| Note on uv: recent uv docs describe relative exclude-newer durations, but older | |
| uv versions require an absolute RFC 3339 timestamp. This script writes the | |
| portable timestamp form, calculated as "now minus --days" when harden runs. | |
| Credits: | |
| - Hamid Kashfi, X: @hkashfi | |
| - OpenAI Codex, AI-assisted implementation | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import os | |
| import re | |
| import shutil | |
| import subprocess | |
| import sys | |
| import textwrap | |
| from dataclasses import dataclass, field | |
| from datetime import datetime, timedelta, timezone | |
| from pathlib import Path | |
| from typing import Callable, Iterable | |
| DEFAULT_DAYS = 7.0 | |
| HOME = Path.home() | |
| COLOR_ENABLED = False | |
| class Color: | |
| RESET = "\033[0m" | |
| BOLD = "\033[1m" | |
| DIM = "\033[2m" | |
| RED = "\033[31m" | |
| GREEN = "\033[32m" | |
| YELLOW = "\033[33m" | |
| BLUE = "\033[34m" | |
| MAGENTA = "\033[35m" | |
| CYAN = "\033[36m" | |
| def paint(text: str, *styles: str) -> str: | |
| if not COLOR_ENABLED or not styles: | |
| return text | |
| return "".join(styles) + text + Color.RESET | |
| def use_color(setting: str) -> bool: | |
| if setting == "always": | |
| return True | |
| if setting == "never" or os.environ.get("NO_COLOR"): | |
| return False | |
| return sys.stdout.isatty() | |
| class ColorHelpFormatter(argparse.RawDescriptionHelpFormatter): | |
| # argparse builds help text lazily, so coloring option names here keeps the | |
| # main parser definition readable and still respects --color/NO_COLOR. | |
| def start_section(self, heading: str) -> None: | |
| super().start_section(paint(heading, Color.BOLD, Color.CYAN)) | |
| def _format_action_invocation(self, action: argparse.Action) -> str: | |
| if not action.option_strings: | |
| return super()._format_action_invocation(action) | |
| parts = [] | |
| for option in action.option_strings: | |
| part = paint(option, Color.GREEN) | |
| if action.nargs != 0: | |
| default = self._get_default_metavar_for_optional(action) | |
| part = f"{part} {self._format_args(action, default)}" | |
| parts.append(part) | |
| return ", ".join(parts) | |
| @dataclass | |
| class Result: | |
| manager: str | |
| ecosystem: str | |
| native_age_gate: bool | |
| status: str | |
| target: str | |
| configured: str = "" | |
| command: str = "" | |
| version: str = "" | |
| files: list[str] = field(default_factory=list) | |
| notes: list[str] = field(default_factory=list) | |
| actions: list[str] = field(default_factory=list) | |
| def to_dict(self) -> dict[str, object]: | |
| return { | |
| "manager": self.manager, | |
| "ecosystem": self.ecosystem, | |
| "native_age_gate": self.native_age_gate, | |
| "status": self.status, | |
| "target": self.target, | |
| "configured": self.configured, | |
| "command": self.command, | |
| "version": self.version, | |
| "files": self.files, | |
| "notes": self.notes, | |
| "actions": self.actions, | |
| } | |
| @dataclass | |
| class Manager: | |
| name: str | |
| ecosystem: str | |
| command: str | None | |
| native_age_gate: bool | |
| scan: Callable[[float], Result] | |
| harden: Callable[[float, "WriteOptions", bool], list[str]] | None = None | |
| @dataclass | |
| class WriteOptions: | |
| dry_run: bool | |
| backup: bool | |
| backup_dir: Path | None = None | |
| def run(cmd: list[str], timeout: float = 3.0) -> tuple[int, str]: | |
| try: | |
| proc = subprocess.run( | |
| cmd, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.STDOUT, | |
| text=True, | |
| timeout=timeout, | |
| check=False, | |
| ) | |
| return proc.returncode, proc.stdout.strip() | |
| except Exception as exc: | |
| return 127, str(exc) | |
| def which(cmd: str | None) -> str: | |
| if not cmd: | |
| return "" | |
| return shutil.which(cmd) or "" | |
| def version_for(cmd: str | None) -> str: | |
| path = which(cmd) | |
| if not path: | |
| return "" | |
| candidates = { | |
| "mvn": ["mvn", "--version"], | |
| "gradle": ["gradle", "--version"], | |
| "go": ["go", "version"], | |
| "cargo": ["cargo", "--version"], | |
| "rustc": ["rustc", "--version"], | |
| "gem": ["gem", "--version"], | |
| "bundle": ["bundle", "--version"], | |
| "composer": ["composer", "--version"], | |
| "dotnet": ["dotnet", "--version"], | |
| } | |
| argv = candidates.get(cmd, [cmd, "--version"]) | |
| code, output = run(argv) | |
| if code != 0 and cmd == "yarn": | |
| code, output = run(["yarn", "-v"]) | |
| if code != 0: | |
| return "" | |
| return output.splitlines()[0].strip() | |
| def pnpm_config_path() -> Path: | |
| # pnpm's global YAML config follows platform conventions, not always XDG. | |
| xdg_config_home = os.environ.get("XDG_CONFIG_HOME") | |
| if xdg_config_home: | |
| return Path(xdg_config_home) / "pnpm" / "config.yaml" | |
| if sys.platform == "darwin": | |
| return HOME / "Library" / "Preferences" / "pnpm" / "config.yaml" | |
| if sys.platform == "win32": | |
| local_app_data = os.environ.get("LOCALAPPDATA") | |
| if local_app_data: | |
| return Path(local_app_data) / "pnpm" / "config" / "config.yaml" | |
| return HOME / ".config" / "pnpm" / "config.yaml" | |
| def fmt_days(days: float) -> str: | |
| return str(int(days)) if float(days).is_integer() else str(days) | |
| def target_duration(days: float) -> str: | |
| unit = "day" if days == 1 else "days" | |
| return f"{fmt_days(days)} {unit}" | |
| def cutoff_timestamp(days: float) -> str: | |
| cutoff = datetime.now(timezone.utc) - timedelta(days=days) | |
| return cutoff.replace(microsecond=0).isoformat().replace("+00:00", "Z") | |
| def color_setting_from_argv(argv: list[str]) -> str: | |
| for index, token in enumerate(argv): | |
| if token == "--color" and index + 1 < len(argv): | |
| return argv[index + 1] | |
| if token.startswith("--color="): | |
| return token.split("=", 1)[1] | |
| return "auto" | |
| def backup(path: Path, backup_dir: Path | None = None) -> Path | None: | |
| if not path.exists(): | |
| return None | |
| stamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") | |
| if backup_dir: | |
| backup_dir.mkdir(parents=True, exist_ok=True) | |
| target = backup_dir / f"{path.name}.bak-{stamp}" | |
| else: | |
| target = path.with_name(f"{path.name}.bak-{stamp}") | |
| shutil.copy2(path, target) | |
| return target | |
| def read_text(path: Path) -> str: | |
| try: | |
| return path.read_text(encoding="utf-8") | |
| except FileNotFoundError: | |
| return "" | |
| def write_text(path: Path, text: str, options: WriteOptions) -> str: | |
| # Keep harden mode idempotent: no rewrite and no backup if content matches. | |
| current = read_text(path) | |
| if current == text: | |
| return f"unchanged {path}" | |
| if options.dry_run: | |
| return f"would write {path}" | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| made_backup = backup(path, options.backup_dir) if options.backup else None | |
| path.write_text(text, encoding="utf-8") | |
| if made_backup: | |
| return f"wrote {path} (backup {made_backup})" | |
| return f"wrote {path}" | |
| def strip_quotes(value: str) -> str: | |
| value = value.strip() | |
| if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"": | |
| return value[1:-1] | |
| return value | |
| def parse_flat_config(path: Path, separator: str = "=") -> dict[str, str]: | |
| data: dict[str, str] = {} | |
| for raw in read_text(path).splitlines(): | |
| line = raw.strip() | |
| if not line or line.startswith("#") or line.startswith(";"): | |
| continue | |
| if separator in line: | |
| key, value = line.split(separator, 1) | |
| data[key.strip()] = strip_quotes(value.strip()) | |
| return data | |
| def parse_yaml_top_level(path: Path) -> dict[str, str]: | |
| data: dict[str, str] = {} | |
| for raw in read_text(path).splitlines(): | |
| if not raw.strip() or raw.lstrip().startswith("#"): | |
| continue | |
| if raw[:1].isspace(): | |
| continue | |
| if ":" not in raw: | |
| continue | |
| key, value = raw.split(":", 1) | |
| data[key.strip()] = strip_quotes(value.strip()) | |
| return data | |
| def parse_toml_simple(path: Path) -> dict[tuple[str, str], str]: | |
| data: dict[tuple[str, str], str] = {} | |
| section = "" | |
| for raw in read_text(path).splitlines(): | |
| line = raw.strip() | |
| if not line or line.startswith("#"): | |
| continue | |
| if line.startswith("[") and line.endswith("]"): | |
| section = line.strip("[]").strip() | |
| continue | |
| if "=" not in line: | |
| continue | |
| key, value = line.split("=", 1) | |
| value = value.split("#", 1)[0].strip() | |
| data[(section, key.strip())] = strip_quotes(value) | |
| return data | |
| def parse_duration_seconds(value: str) -> float | None: | |
| value = strip_quotes(str(value)).strip() | |
| if not value: | |
| return None | |
| # ISO 8601 durations used by uv, e.g. P7D or PT24H. | |
| iso = re.fullmatch( | |
| r"P(?:(?P<weeks>[\d.]+)W)?(?:(?P<days>[\d.]+)D)?" | |
| r"(?:T(?:(?P<hours>[\d.]+)H)?(?:(?P<minutes>[\d.]+)M)?(?:(?P<seconds>[\d.]+)S)?)?", | |
| value, | |
| re.IGNORECASE, | |
| ) | |
| if iso: | |
| total = 0.0 | |
| factors = { | |
| "weeks": 7 * 86400, | |
| "days": 86400, | |
| "hours": 3600, | |
| "minutes": 60, | |
| "seconds": 1, | |
| } | |
| for name, factor in factors.items(): | |
| if iso.group(name): | |
| total += float(iso.group(name)) * factor | |
| return total | |
| # Friendly durations: "7 days", "1w", "24h", "1440 minutes". | |
| match = re.fullmatch(r"([\d.]+)\s*([A-Za-z]+)", value) | |
| if match: | |
| amount = float(match.group(1)) | |
| unit = match.group(2).lower() | |
| units = { | |
| "s": 1, | |
| "sec": 1, | |
| "secs": 1, | |
| "second": 1, | |
| "seconds": 1, | |
| "m": 60, | |
| "min": 60, | |
| "mins": 60, | |
| "minute": 60, | |
| "minutes": 60, | |
| "h": 3600, | |
| "hr": 3600, | |
| "hrs": 3600, | |
| "hour": 3600, | |
| "hours": 3600, | |
| "d": 86400, | |
| "day": 86400, | |
| "days": 86400, | |
| "w": 7 * 86400, | |
| "week": 7 * 86400, | |
| "weeks": 7 * 86400, | |
| } | |
| if unit in units: | |
| return amount * units[unit] | |
| # RFC 3339 timestamp. Treat the effective age as now - timestamp. | |
| try: | |
| timestamp = value.replace("Z", "+00:00") | |
| dt = datetime.fromisoformat(timestamp) | |
| if dt.tzinfo is None: | |
| dt = dt.replace(tzinfo=timezone.utc) | |
| return (datetime.now(timezone.utc) - dt.astimezone(timezone.utc)).total_seconds() | |
| except ValueError: | |
| return None | |
| def numeric_at_least(value: str | None, target: float) -> bool: | |
| if value is None: | |
| return False | |
| try: | |
| return float(value) >= target | |
| except ValueError: | |
| return False | |
| def duration_at_least(value: str | None, target_seconds: float) -> bool: | |
| if value is None: | |
| return False | |
| seconds = parse_duration_seconds(value) | |
| return seconds is not None and seconds >= target_seconds | |
| def rfc3339_age_at_least(value: str | None, target_seconds: float) -> bool: | |
| if value is None: | |
| return False | |
| raw = strip_quotes(str(value)).strip().replace("Z", "+00:00") | |
| try: | |
| dt = datetime.fromisoformat(raw) | |
| except ValueError: | |
| return False | |
| if dt.tzinfo is None: | |
| dt = dt.replace(tzinfo=timezone.utc) | |
| age = datetime.now(timezone.utc) - dt.astimezone(timezone.utc) | |
| return age.total_seconds() >= target_seconds | |
| def is_rfc3339_datetime(value: str | None) -> bool: | |
| if value is None: | |
| return False | |
| raw = strip_quotes(str(value)).strip().replace("Z", "+00:00") | |
| try: | |
| datetime.fromisoformat(raw) | |
| return True | |
| except ValueError: | |
| return False | |
| def yarn_age_gate_at_least(value: str | None, target_minutes: float) -> bool: | |
| if value is None: | |
| return False | |
| raw = strip_quotes(str(value)).strip() | |
| if not raw: | |
| return False | |
| try: | |
| return float(raw) >= target_minutes | |
| except ValueError: | |
| pass | |
| seconds = parse_duration_seconds(raw) | |
| return seconds is not None and seconds >= target_minutes * 60 | |
| def update_key_value_file( | |
| path: Path, | |
| assignments: dict[str, str], | |
| remove_keys: Iterable[str] = (), | |
| options: WriteOptions | None = None, | |
| ) -> str: | |
| # Preserve unrelated comments/settings while normalizing the keys we own. | |
| options = options or WriteOptions(dry_run=False, backup=True) | |
| remove = set(remove_keys) | |
| seen: set[str] = set() | |
| lines = read_text(path).splitlines() | |
| out: list[str] = [] | |
| for raw in lines: | |
| stripped = raw.strip() | |
| if not stripped or stripped.startswith("#") or stripped.startswith(";") or "=" not in raw: | |
| out.append(raw) | |
| continue | |
| key = raw.split("=", 1)[0].strip() | |
| if key in remove: | |
| continue | |
| if key in assignments: | |
| if key not in seen: | |
| out.append(f"{key}={assignments[key]}") | |
| seen.add(key) | |
| continue | |
| out.append(raw) | |
| for key, value in assignments.items(): | |
| if key not in seen: | |
| out.append(f"{key}={value}") | |
| return write_text(path, "\n".join(out).rstrip() + "\n", options) | |
| def update_yaml_top_level(path: Path, assignments: dict[str, str], options: WriteOptions) -> str: | |
| seen: set[str] = set() | |
| lines = read_text(path).splitlines() | |
| out: list[str] = [] | |
| for raw in lines: | |
| if raw.strip() and not raw[:1].isspace() and ":" in raw and not raw.lstrip().startswith("#"): | |
| key = raw.split(":", 1)[0].strip() | |
| if key in assignments: | |
| if key not in seen: | |
| out.append(f"{key}: {assignments[key]}") | |
| seen.add(key) | |
| continue | |
| out.append(raw) | |
| for key, value in assignments.items(): | |
| if key not in seen: | |
| out.append(f"{key}: {value}") | |
| return write_text(path, "\n".join(out).rstrip() + "\n", options) | |
| def update_toml( | |
| path: Path, | |
| assignments: dict[tuple[str, str], str], | |
| options: WriteOptions, | |
| ) -> str: | |
| lines = read_text(path).splitlines() | |
| out: list[str] = [] | |
| seen: set[tuple[str, str]] = set() | |
| current_section = "" | |
| section_seen: set[str] = set() | |
| for raw in lines: | |
| stripped = raw.strip() | |
| if stripped.startswith("[") and stripped.endswith("]"): | |
| for (section, key), value in assignments.items(): | |
| if section == current_section and (section, key) not in seen: | |
| out.append(f"{key} = {value}") | |
| seen.add((section, key)) | |
| current_section = stripped.strip("[]").strip() | |
| section_seen.add(current_section) | |
| out.append(raw) | |
| continue | |
| if stripped and not stripped.startswith("#") and "=" in raw: | |
| key = raw.split("=", 1)[0].strip() | |
| slot = (current_section, key) | |
| if slot in assignments: | |
| if slot not in seen: | |
| out.append(f"{key} = {assignments[slot]}") | |
| seen.add(slot) | |
| continue | |
| out.append(raw) | |
| for (section, key), value in assignments.items(): | |
| if (section, key) in seen: | |
| continue | |
| if section == "": | |
| out.append(f"{key} = {value}") | |
| seen.add((section, key)) | |
| for (section, key), value in assignments.items(): | |
| if section == "" or (section, key) in seen: | |
| continue | |
| if section not in section_seen: | |
| if out and out[-1].strip(): | |
| out.append("") | |
| out.append(f"[{section}]") | |
| section_seen.add(section) | |
| out.append(f"{key} = {value}") | |
| seen.add((section, key)) | |
| return write_text(path, "\n".join(out).rstrip() + "\n", options) | |
| def scan_npm(days: float) -> Result: | |
| path = HOME / ".npmrc" | |
| data = parse_flat_config(path) | |
| configured = data.get("min-release-age", "") | |
| ok = numeric_at_least(configured, days) | |
| notes = [] | |
| if "minimum-release-age" in data: | |
| notes.append("stale key minimum-release-age is present; npm expects min-release-age") | |
| if "before" in data: | |
| notes.append("before is set and may override min-release-age in npm resolution") | |
| status = "ok" if ok and "minimum-release-age" not in data else "needs_hardening" | |
| return Result( | |
| manager="npm", | |
| ecosystem="npm", | |
| native_age_gate=True, | |
| status=status, | |
| target=f"min-release-age={fmt_days(days)}", | |
| configured=f"min-release-age={configured or '<unset>'}", | |
| command=which("npm"), | |
| version=version_for("npm"), | |
| files=[str(path)], | |
| notes=notes, | |
| actions=["set min-release-age; remove stale minimum-release-age; keep save-exact and ignore-scripts"], | |
| ) | |
| def harden_npm(days: float, options: WriteOptions, age_only: bool) -> list[str]: | |
| assignments = {"min-release-age": fmt_days(days)} | |
| if not age_only: | |
| assignments.update({"save-exact": "true", "ignore-scripts": "true"}) | |
| return [ | |
| update_key_value_file( | |
| HOME / ".npmrc", | |
| assignments, | |
| remove_keys=["minimum-release-age"], | |
| options=options, | |
| ) | |
| ] | |
| def scan_pnpm(days: float) -> Result: | |
| path = pnpm_config_path() | |
| data = parse_yaml_top_level(path) | |
| target_minutes = days * 1440 | |
| configured = data.get("minimumReleaseAge", "") | |
| ok = numeric_at_least(configured, target_minutes) | |
| notes = [] | |
| if not path.exists(): | |
| notes.append("pnpm v11 has a 1-day default, but explicit global config is absent") | |
| if data.get("minimumReleaseAgeStrict", "").lower() == "false": | |
| notes.append("minimumReleaseAgeStrict is false; resolution may fall back to newer versions") | |
| return Result( | |
| manager="pnpm", | |
| ecosystem="npm", | |
| native_age_gate=True, | |
| status="ok" if ok and data.get("minimumReleaseAgeStrict", "true").lower() != "false" else "needs_hardening", | |
| target=f"minimumReleaseAge={int(target_minutes)} minutes", | |
| configured=f"minimumReleaseAge={configured or '<unset>'}", | |
| command=which("pnpm"), | |
| version=version_for("pnpm"), | |
| files=[str(path)], | |
| notes=notes, | |
| actions=["set minimumReleaseAge, strict age enforcement, and related pnpm supply-chain checks"], | |
| ) | |
| def harden_pnpm(days: float, options: WriteOptions, age_only: bool) -> list[str]: | |
| assignments = { | |
| "minimumReleaseAge": str(int(days * 1440)), | |
| "minimumReleaseAgeStrict": "true", | |
| "minimumReleaseAgeIgnoreMissingTime": "false", | |
| } | |
| if not age_only: | |
| assignments.update( | |
| { | |
| "trustPolicy": "no-downgrade", | |
| "blockExoticSubdeps": "true", | |
| } | |
| ) | |
| return [update_yaml_top_level(pnpm_config_path(), assignments, options)] | |
| def scan_yarn(days: float) -> Result: | |
| path = HOME / ".yarnrc.yml" | |
| data = parse_yaml_top_level(path) | |
| value = data.get("npmMinimalAgeGate", "") | |
| desired = f"{fmt_days(days)}d" | |
| target_minutes = days * 1440 | |
| ok = yarn_age_gate_at_least(value, target_minutes) | |
| version = version_for("yarn") | |
| installed_classic = bool(re.match(r"^1\.", version)) | |
| if strip_quotes(value) != desired: | |
| status = "needs_hardening" | |
| elif ok and installed_classic: | |
| status = "configured_for_berry_installed_classic_unsupported" | |
| elif ok: | |
| status = "ok" | |
| else: | |
| status = "needs_hardening" | |
| notes = [ | |
| "age gate applies to Yarn Berry 4.10+; Yarn Classic 1.x does not support it", | |
| 'duration strings like "7d" match current Yarn documentation', | |
| ] | |
| return Result( | |
| manager="Yarn", | |
| ecosystem="npm", | |
| native_age_gate=True, | |
| status=status, | |
| target=f'npmMinimalAgeGate="{desired}"', | |
| configured=f"npmMinimalAgeGate={value or '<unset>'}", | |
| command=which("yarn"), | |
| version=version, | |
| files=[str(path), str(HOME / ".yarnrc")], | |
| notes=notes, | |
| actions=["set npmMinimalAgeGate in ~/.yarnrc.yml; optionally disable third-party install scripts"], | |
| ) | |
| def harden_yarn(days: float, options: WriteOptions, age_only: bool) -> list[str]: | |
| assignments = {"npmMinimalAgeGate": json.dumps(f"{fmt_days(days)}d")} | |
| if not age_only: | |
| assignments.update({"enableScripts": "false", "enableHardenedMode": "true"}) | |
| return [update_yaml_top_level(HOME / ".yarnrc.yml", assignments, options)] | |
| def scan_bun(days: float) -> Result: | |
| path = HOME / ".bunfig.toml" | |
| data = parse_toml_simple(path) | |
| value = data.get(("install", "minimumReleaseAge"), "") | |
| ok = numeric_at_least(value, days * 86400) | |
| return Result( | |
| manager="Bun", | |
| ecosystem="npm", | |
| native_age_gate=True, | |
| status="ok" if ok else "needs_hardening", | |
| target=f"[install].minimumReleaseAge={int(days * 86400)} seconds", | |
| configured=f"minimumReleaseAge={value or '<unset>'}", | |
| command=which("bun"), | |
| version=version_for("bun"), | |
| files=[str(path)], | |
| notes=["Bun also reads $XDG_CONFIG_HOME/.bunfig.toml if present"], | |
| actions=["set [install].minimumReleaseAge in ~/.bunfig.toml"], | |
| ) | |
| def harden_bun(days: float, options: WriteOptions, age_only: bool) -> list[str]: | |
| del age_only | |
| return [ | |
| update_toml( | |
| HOME / ".bunfig.toml", | |
| {("install", "minimumReleaseAge"): str(int(days * 86400))}, | |
| options, | |
| ) | |
| ] | |
| def scan_uv(days: float) -> Result: | |
| path = HOME / ".config" / "uv" / "uv.toml" | |
| data = parse_toml_simple(path) | |
| value = data.get(("", "exclude-newer"), "") | |
| pip_value = data.get(("pip", "exclude-newer"), "") | |
| ok = rfc3339_age_at_least(value, days * 86400) and rfc3339_age_at_least(pip_value, days * 86400) | |
| notes = [] | |
| if value and not pip_value: | |
| notes.append("top-level uv setting is present, but [pip].exclude-newer is absent for uv pip") | |
| for configured_value in (value, pip_value): | |
| if configured_value and not is_rfc3339_datetime(configured_value) and parse_duration_seconds(configured_value) is not None: | |
| notes.append("relative uv exclude-newer duration found; harden mode rewrites it to RFC 3339 for older uv compatibility") | |
| break | |
| return Result( | |
| manager="uv", | |
| ecosystem="pypi", | |
| native_age_gate=True, | |
| status="ok" if ok else "needs_hardening", | |
| target=f"exclude-newer cutoff at least {target_duration(days)} old at top level and [pip]", | |
| configured=f"exclude-newer={value or '<unset>'}; [pip].exclude-newer={pip_value or '<unset>'}", | |
| command=which("uv"), | |
| version=version_for("uv"), | |
| files=[str(path)], | |
| notes=notes, | |
| actions=["set exclude-newer globally for uv and uv pip"], | |
| ) | |
| def harden_uv(days: float, options: WriteOptions, age_only: bool) -> list[str]: | |
| del age_only | |
| value = json.dumps(cutoff_timestamp(days)) | |
| return [ | |
| update_toml( | |
| HOME / ".config" / "uv" / "uv.toml", | |
| { | |
| ("", "exclude-newer"): value, | |
| ("pip", "exclude-newer"): value, | |
| }, | |
| options, | |
| ) | |
| ] | |
| def unsupported_scan( | |
| manager: str, | |
| ecosystem: str, | |
| command: str | None, | |
| files: list[Path], | |
| note: str, | |
| ) -> Callable[[float], Result]: | |
| def _scan(days: float) -> Result: | |
| del days | |
| existing = [str(path) for path in files if path.exists()] | |
| configured = "no native release-age gate" | |
| return Result( | |
| manager=manager, | |
| ecosystem=ecosystem, | |
| native_age_gate=False, | |
| status="unsupported_native_age_gate", | |
| target="not available as a native package-manager setting", | |
| configured=configured, | |
| command=which(command), | |
| version=version_for(command), | |
| files=existing or [str(path) for path in files], | |
| notes=[note], | |
| actions=["use lockfiles, checksums/signatures, private registry/proxy policy, or uv/npm-family tools where applicable"], | |
| ) | |
| return _scan | |
| def manager_registry() -> list[Manager]: | |
| # This registry is the policy boundary: only managers with native age gates | |
| # get harden callbacks. The rest are visibility/reporting only. | |
| mac_go_env = HOME / "Library" / "Application Support" / "go" / "env" | |
| linux_go_env = HOME / ".config" / "go" / "env" | |
| return [ | |
| Manager("npm", "npm", "npm", True, scan_npm, harden_npm), | |
| Manager("pnpm", "npm", "pnpm", True, scan_pnpm, harden_pnpm), | |
| Manager("yarn", "npm", "yarn", True, scan_yarn, harden_yarn), | |
| Manager("bun", "npm", "bun", True, scan_bun, harden_bun), | |
| Manager("uv", "pypi", "uv", True, scan_uv, harden_uv), | |
| Manager( | |
| "pip", | |
| "pypi", | |
| "pip", | |
| False, | |
| unsupported_scan( | |
| "pip", | |
| "pypi", | |
| "pip", | |
| [HOME / ".config" / "pip" / "pip.conf", HOME / ".pip" / "pip.conf"], | |
| "pip has no native publish/upload-age gate; use uv for age-gated PyPI installs where possible.", | |
| ), | |
| ), | |
| Manager( | |
| "pipx", | |
| "pypi", | |
| "pipx", | |
| False, | |
| unsupported_scan( | |
| "pipx", | |
| "pypi", | |
| "pipx", | |
| [HOME / ".config" / "pipx" / "pipx_metadata.json"], | |
| "pipx shells out to pip and has no native publish/upload-age gate.", | |
| ), | |
| ), | |
| Manager( | |
| "Poetry", | |
| "pypi", | |
| "poetry", | |
| False, | |
| unsupported_scan( | |
| "Poetry", | |
| "pypi", | |
| "poetry", | |
| [HOME / ".config" / "pypoetry" / "config.toml"], | |
| "Poetry has no native package upload-age gate in its user config.", | |
| ), | |
| ), | |
| Manager( | |
| "PDM", | |
| "pypi", | |
| "pdm", | |
| False, | |
| unsupported_scan( | |
| "PDM", | |
| "pypi", | |
| "pdm", | |
| [HOME / ".config" / "pdm" / "config.toml"], | |
| "PDM has no native package upload-age gate in its user config.", | |
| ), | |
| ), | |
| Manager( | |
| "Pipenv", | |
| "pypi", | |
| "pipenv", | |
| False, | |
| unsupported_scan( | |
| "Pipenv", | |
| "pypi", | |
| "pipenv", | |
| [HOME / ".config" / "pipenv" / "pipenv.cfg"], | |
| "Pipenv uses pip underneath and has no native package upload-age gate.", | |
| ), | |
| ), | |
| Manager( | |
| "Go modules", | |
| "go", | |
| "go", | |
| False, | |
| unsupported_scan( | |
| "Go modules", | |
| "go", | |
| "go", | |
| [mac_go_env, linux_go_env], | |
| "Go modules have checksums via GOSUMDB but no native module publish-age gate.", | |
| ), | |
| ), | |
| Manager( | |
| "RubyGems", | |
| "rubygems", | |
| "gem", | |
| False, | |
| unsupported_scan( | |
| "RubyGems", | |
| "rubygems", | |
| "gem", | |
| [HOME / ".gemrc"], | |
| "RubyGems/Bundler have no native gem release-age gate.", | |
| ), | |
| ), | |
| Manager( | |
| "Bundler", | |
| "rubygems", | |
| "bundle", | |
| False, | |
| unsupported_scan( | |
| "Bundler", | |
| "rubygems", | |
| "bundle", | |
| [HOME / ".bundle" / "config"], | |
| "Bundler has no native gem release-age gate.", | |
| ), | |
| ), | |
| Manager( | |
| "Composer", | |
| "packagist", | |
| "composer", | |
| False, | |
| unsupported_scan( | |
| "Composer", | |
| "packagist", | |
| "composer", | |
| [HOME / ".composer" / "config.json", HOME / ".config" / "composer" / "config.json"], | |
| "Composer has audit and secure transport controls, but no native package release-age gate.", | |
| ), | |
| ), | |
| Manager( | |
| "Cargo", | |
| "crates.io", | |
| "cargo", | |
| False, | |
| unsupported_scan( | |
| "Cargo", | |
| "crates.io", | |
| "cargo", | |
| [HOME / ".cargo" / "config.toml", HOME / ".cargo" / "config"], | |
| "Cargo has lockfiles and checksum verification, but no native crate publish-age gate.", | |
| ), | |
| ), | |
| Manager( | |
| "Maven", | |
| "maven", | |
| "mvn", | |
| False, | |
| unsupported_scan( | |
| "Maven", | |
| "maven", | |
| "mvn", | |
| [HOME / ".m2" / "settings.xml"], | |
| "Maven has no native artifact publish-age gate; enforce this in a repository manager/proxy.", | |
| ), | |
| ), | |
| Manager( | |
| "Gradle", | |
| "maven", | |
| "gradle", | |
| False, | |
| unsupported_scan( | |
| "Gradle", | |
| "maven", | |
| "gradle", | |
| [HOME / ".gradle" / "gradle.properties", HOME / ".gradle" / "init.gradle", HOME / ".gradle" / "init.gradle.kts"], | |
| "Gradle has dependency verification, but no native artifact publish-age gate.", | |
| ), | |
| ), | |
| Manager( | |
| "NuGet", | |
| "nuget", | |
| "dotnet", | |
| False, | |
| unsupported_scan( | |
| "NuGet", | |
| "nuget", | |
| "dotnet", | |
| [HOME / ".nuget" / "NuGet" / "NuGet.Config"], | |
| "NuGet has signature/trusted-signer controls, but no native package publish-age gate.", | |
| ), | |
| ), | |
| Manager( | |
| "SwiftPM", | |
| "swift", | |
| "swift", | |
| False, | |
| unsupported_scan( | |
| "SwiftPM", | |
| "swift", | |
| "swift", | |
| [HOME / ".swiftpm" / "config" / "configuration.json"], | |
| "Swift Package Manager has no native package publish-age gate.", | |
| ), | |
| ), | |
| Manager( | |
| "Hex", | |
| "hex", | |
| "mix", | |
| False, | |
| unsupported_scan( | |
| "Hex", | |
| "hex", | |
| "mix", | |
| [HOME / ".hex" / "hex.config"], | |
| "Hex/Rebar3 have no native package publish-age gate.", | |
| ), | |
| ), | |
| Manager( | |
| "CPAN", | |
| "cpan", | |
| "cpan", | |
| False, | |
| unsupported_scan( | |
| "CPAN", | |
| "cpan", | |
| "cpan", | |
| [HOME / ".cpan" / "CPAN" / "MyConfig.pm"], | |
| "CPAN clients have no common native distribution publish-age gate.", | |
| ), | |
| ), | |
| Manager( | |
| "Dart pub", | |
| "pub", | |
| "dart", | |
| False, | |
| unsupported_scan( | |
| "Dart pub", | |
| "pub", | |
| "dart", | |
| [HOME / ".pub-cache" / "credentials.json"], | |
| "Dart pub has lockfiles, but no native package publish-age gate.", | |
| ), | |
| ), | |
| Manager( | |
| "Deno", | |
| "jsr/npm", | |
| "deno", | |
| False, | |
| unsupported_scan( | |
| "Deno", | |
| "jsr/npm", | |
| "deno", | |
| [HOME / ".deno" / "settings.json"], | |
| "Deno lockfiles can pin dependencies, but there is no native npm/JSR publish-age gate.", | |
| ), | |
| ), | |
| Manager( | |
| "LuaRocks", | |
| "luarocks", | |
| "luarocks", | |
| False, | |
| unsupported_scan( | |
| "LuaRocks", | |
| "luarocks", | |
| "luarocks", | |
| [HOME / ".luarocks" / "config.lua"], | |
| "LuaRocks has no native rock publish-age gate.", | |
| ), | |
| ), | |
| Manager( | |
| "Conda", | |
| "conda", | |
| "conda", | |
| False, | |
| unsupported_scan( | |
| "Conda", | |
| "conda", | |
| "conda", | |
| [HOME / ".condarc"], | |
| "Conda has channel priority and lockfile workflows, but no native package publish-age gate.", | |
| ), | |
| ), | |
| Manager( | |
| "Homebrew", | |
| "homebrew", | |
| "brew", | |
| False, | |
| unsupported_scan( | |
| "Homebrew", | |
| "homebrew", | |
| "brew", | |
| [HOME / ".Brewfile"], | |
| "Homebrew has no native formula/cask publish-age gate.", | |
| ), | |
| ), | |
| ] | |
| def select_managers(names: str | None) -> list[Manager]: | |
| managers = manager_registry() | |
| if not names: | |
| return managers | |
| wanted = [item.strip().lower() for item in names.split(",") if item.strip()] | |
| selected: list[Manager] = [] | |
| missing: list[str] = [] | |
| manager_names = {m.name.lower() for m in managers} | |
| ecosystems = {m.ecosystem.lower() for m in managers} | |
| for item in wanted: | |
| ecosystem_filter = item.removeprefix("ecosystem:") | |
| if item in manager_names: | |
| matches = [m for m in managers if m.name.lower() == item] | |
| elif item.startswith("ecosystem:") and ecosystem_filter in ecosystems: | |
| matches = [m for m in managers if m.ecosystem.lower() == ecosystem_filter] | |
| elif item in ecosystems: | |
| matches = [m for m in managers if m.ecosystem.lower() == item] | |
| else: | |
| matches = [] | |
| if not matches: | |
| missing.append(item) | |
| for match in matches: | |
| if match not in selected: | |
| selected.append(match) | |
| if missing: | |
| raise SystemExit(f"unknown manager/ecosystem filter: {', '.join(missing)}") | |
| return selected | |
| def print_table(results: list[Result]) -> None: | |
| def styled_status(status: str) -> str: | |
| if status == "ok": | |
| return paint(status, Color.GREEN, Color.BOLD) | |
| if status == "needs_hardening": | |
| return paint(status, Color.YELLOW, Color.BOLD) | |
| if status.startswith("configured_for_berry"): | |
| return paint(status, Color.YELLOW) | |
| return paint(status, Color.DIM) | |
| def styled_native(value: bool) -> str: | |
| return paint("yes", Color.GREEN) if value else paint("no", Color.DIM) | |
| rows = [ | |
| [ | |
| r.manager, | |
| "yes" if r.native_age_gate else "no", | |
| r.status, | |
| r.configured, | |
| r.target, | |
| ] | |
| for r in results | |
| ] | |
| headers = ["manager", "native_gate", "status", "configured", "target"] | |
| widths = [ | |
| max(len(str(row[i])) for row in rows + [headers]) | |
| for i in range(len(headers)) | |
| ] | |
| header_line = " ".join(headers[i].ljust(widths[i]) for i in range(len(headers))) | |
| print(paint(header_line, Color.BOLD, Color.CYAN)) | |
| print(paint(" ".join("-" * widths[i] for i in range(len(headers))), Color.DIM)) | |
| for result, row in zip(results, rows): | |
| cells = [ | |
| row[0].ljust(widths[0]), | |
| styled_native(result.native_age_gate).ljust(widths[1] + (len(styled_native(result.native_age_gate)) - len(row[1]))), | |
| styled_status(result.status).ljust(widths[2] + (len(styled_status(result.status)) - len(row[2]))), | |
| row[3].ljust(widths[3]), | |
| row[4].ljust(widths[4]), | |
| ] | |
| print(" ".join(cells)) | |
| def print_details(results: list[Result]) -> None: | |
| for result in results: | |
| if not result.notes and not result.actions: | |
| continue | |
| print(f"\n{paint(result.manager + ':', Color.BOLD, Color.BLUE)}") | |
| if result.command: | |
| version = f" ({result.version})" if result.version else "" | |
| print(f" {paint('command:', Color.CYAN)} {result.command}{version}") | |
| for path in result.files: | |
| print(f" {paint('file:', Color.CYAN)} {path}") | |
| for note in result.notes: | |
| print(f" {paint('note:', Color.YELLOW)} {note}") | |
| for action in result.actions: | |
| print(f" {paint('action:', Color.GREEN)} {action}") | |
| def confirm_or_exit(args: argparse.Namespace, results: list[Result]) -> None: | |
| if args.yes or args.dry_run: | |
| return | |
| pending = [r.manager for r in results if r.native_age_gate and r.status == "needs_hardening"] | |
| if not pending: | |
| return | |
| print(f"\n{paint('Harden mode will update user config for:', Color.BOLD, Color.YELLOW)} {', '.join(pending)}") | |
| answer = input(paint("Proceed? Type 'yes' to continue: ", Color.BOLD)).strip().lower() | |
| if answer != "yes": | |
| raise SystemExit("aborted") | |
| def main(argv: list[str] | None = None) -> int: | |
| global COLOR_ENABLED | |
| raw_argv = list(sys.argv[1:] if argv is None else argv) | |
| COLOR_ENABLED = use_color(color_setting_from_argv(raw_argv)) | |
| description = "Scan or harden package-manager release-age gate settings." | |
| epilog = textwrap.dedent( | |
| f""" | |
| {paint('Philosophy', Color.BOLD, Color.CYAN)} | |
| This tool prefers native package-manager controls over wrappers. In | |
| harden mode it only writes user-level config for managers with a real | |
| install-time package publish/upload-age gate. For ecosystems without | |
| that control, it reports the gap and leaves files untouched. | |
| {paint('What harden mode can set today', Color.BOLD, Color.CYAN)} | |
| npm ~/.npmrc min-release-age=<days> | |
| pnpm global config.yaml minimumReleaseAge=<minutes> | |
| Yarn ~/.yarnrc.yml npmMinimalAgeGate="<days>d" for Yarn Berry | |
| Bun ~/.bunfig.toml [install].minimumReleaseAge=<seconds> | |
| uv ~/.config/uv/uv.toml exclude-newer="<RFC3339 cutoff>" | |
| {paint('Examples', Color.BOLD, Color.CYAN)} | |
| %(prog)s scan | |
| %(prog)s harden --days 7 | |
| %(prog)s harden --days 3 --managers npm,pnpm,uv --yes | |
| %(prog)s scan --managers ecosystem:npm | |
| %(prog)s harden --dry-run --backup-dir ~/pkg-manager-config-backups | |
| %(prog)s scan --json | |
| {paint('Backups and colors', Color.BOLD, Color.CYAN)} | |
| Timestamped backups are enabled by default for existing files changed | |
| by harden mode. Use --no-backup only when you have another rollback | |
| path. Color defaults to auto, honors NO_COLOR, and can be forced with | |
| --color always or disabled with --color never. | |
| """ | |
| ) | |
| parser = argparse.ArgumentParser( | |
| description=description, | |
| epilog=epilog, | |
| formatter_class=ColorHelpFormatter, | |
| ) | |
| parser.add_argument( | |
| "mode", | |
| choices=["scan", "harden"], | |
| help="scan reports status; harden writes supported user-level config files", | |
| ) | |
| parser.add_argument( | |
| "--days", | |
| type=float, | |
| default=DEFAULT_DAYS, | |
| help=f"minimum package publish/upload age in days (default: {fmt_days(DEFAULT_DAYS)})", | |
| ) | |
| parser.add_argument( | |
| "--managers", | |
| help="comma-separated manager names or ecosystems; exact manager names win, e.g. npm,pnpm,uv,pypi,ecosystem:npm", | |
| ) | |
| parser.add_argument( | |
| "--age-only", | |
| action="store_true", | |
| help="only set age gates; skip adjacent hardening such as disabling npm/Yarn scripts", | |
| ) | |
| parser.add_argument( | |
| "--dry-run", | |
| action="store_true", | |
| help="show what harden would write without changing files", | |
| ) | |
| backup_group = parser.add_mutually_exclusive_group() | |
| backup_group.add_argument( | |
| "--backup", | |
| dest="backup", | |
| action="store_true", | |
| default=True, | |
| help="create timestamped backups for existing files before modification (default)", | |
| ) | |
| backup_group.add_argument( | |
| "--no-backup", | |
| dest="backup", | |
| action="store_false", | |
| help="do not create backups before modifying files", | |
| ) | |
| parser.add_argument( | |
| "--backup-dir", | |
| type=Path, | |
| help="write timestamped backups to this directory instead of beside each file", | |
| ) | |
| parser.add_argument( | |
| "--yes", | |
| action="store_true", | |
| help="do not prompt before hardening", | |
| ) | |
| parser.add_argument( | |
| "--json", | |
| action="store_true", | |
| help="emit JSON instead of the text table", | |
| ) | |
| parser.add_argument( | |
| "--color", | |
| choices=["auto", "always", "never"], | |
| default="auto", | |
| help="colorize terminal output and help text (default: auto)", | |
| ) | |
| args = parser.parse_args(raw_argv) | |
| if args.json: | |
| COLOR_ENABLED = False | |
| if args.days <= 0: | |
| raise SystemExit("--days must be greater than zero") | |
| managers = select_managers(args.managers) | |
| before = [m.scan(args.days) for m in managers] | |
| if args.mode == "scan": | |
| if args.json: | |
| print(json.dumps([r.to_dict() for r in before], indent=2)) | |
| else: | |
| print(f"{paint('Target release/upload age:', Color.BOLD)} {target_duration(args.days)}") | |
| print_table(before) | |
| print_details(before) | |
| return 0 | |
| confirm_or_exit(args, before) | |
| write_options = WriteOptions( | |
| dry_run=args.dry_run, | |
| backup=args.backup, | |
| backup_dir=args.backup_dir.expanduser() if args.backup_dir else None, | |
| ) | |
| changes: list[str] = [] | |
| for manager, result in zip(managers, before): | |
| if not manager.native_age_gate or result.status != "needs_hardening": | |
| continue | |
| if manager.harden is None: | |
| continue | |
| changes.extend(manager.harden(args.days, write_options, args.age_only)) | |
| after = [m.scan(args.days) for m in managers] | |
| if args.json: | |
| print(json.dumps({"changes": changes, "results": [r.to_dict() for r in after]}, indent=2)) | |
| else: | |
| print(f"{paint('Target release/upload age:', Color.BOLD)} {target_duration(args.days)}") | |
| if changes: | |
| print(f"\n{paint('Changes:', Color.BOLD, Color.GREEN)}") | |
| for change in changes: | |
| print(f" - {change}") | |
| else: | |
| print(f"\n{paint('No supported manager needed changes.', Color.GREEN)}") | |
| print() | |
| print_table(after) | |
| print_details(after) | |
| return 0 | |
| if __name__ == "__main__": | |
| try: | |
| raise SystemExit(main()) | |
| except KeyboardInterrupt: | |
| raise SystemExit("\ninterrupted") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This is super. I feel more than the hardening it is important to check for different signal in the package like
newly publishedorsingle maintainer, these are directly tied to some heuristics we have in immunity-agent along with agent security