Skip to content

Instantly share code, notes, and snippets.

@Hamid-K
Last active June 1, 2026 09:25
Show Gist options
  • Select an option

  • Save Hamid-K/9ce98023f73e5d3eb7ea101f9cacedb2 to your computer and use it in GitHub Desktop.

Select an option

Save Hamid-K/9ce98023f73e5d3eb7ea101f9cacedb2 to your computer and use it in GitHub Desktop.
Package manager release-age gate scanner and hardener
#!/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")
@Ar9av
Copy link
Copy Markdown

Ar9av commented May 26, 2026

This is super. I feel more than the hardening it is important to check for different signal in the package like newly published or single maintainer , these are directly tied to some heuristics we have in immunity-agent along with agent security

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment