ci(security): flag <30d-old packages introduced by a PR + allowlist #136

Closed
opened 2026-06-19 17:12:10 +00:00 by james · 0 comments
Owner

Problem

Carol's existing supply-chain defences address one specific threat: a new malicious version of an established package. ADR-0009's Renovate minimumReleaseAge: "7 days" quarantine, ADR-0010's install-script allowlist, and the existing scanner suite (npm audit, OSV, Trivy, gitleaks) all target version-level compromise of known dependencies.

They do NOT address the orthogonal threat: a fresh malicious package. Typosquats, dependency-confusion attacks, and brand-new sleeper packages are by definition newly-published. Renovate's version-age quarantine doesn't help — it gates by release date of a version, not by when the package first existed on npm. A new transitive pulled in via a feature PR's lockfile resolution flows past every existing gate.

Recent supply-chain attacks (the npm xz analogue cases) have consistently shown that brand-new packages are the most common shape. A 30-day "package must have existed for a month" policy catches the bulk of these without meaningfully delaying legitimate work.

Scope

  • scripts/lib/package-ages.mjs — pure logic, no I/O:
    • extractPackagesFromLockfile(lockfile) — walks npm v3 packages map, returns unique package names with their first-seen version (handles scoped names like @scope/name).
    • newPackages(base, current) — set difference: packages in current but not in base.
    • flagOffenders({ packageMetas, allowlist, thresholdDays, now }) — pure filter.
    • fetchPackageMeta(name, fetchImpl) — registry call (one I/O surface, injectable for tests).
  • scripts/ci/check-package-ages.mjs — wraps the lib. Reads current package-lock.json + base via git show origin/<base>:package-lock.json + .dep-policy.json. Queries https://registry.npmjs.org/<pkg> for time.created on each new package. Writes Markdown to a body file. Prints flag=<bool>.
  • scripts/ci/post-deps-comment.mjs — sticky-comment poster. Marker <!-- deps-comment -->. Scanner-pattern delete-on-clean (matches ADR-0016): no offenders → no comment. Differs from coverage's always-post.
  • .dep-policy.json at repo root. Format:
    {
      "allowed_packages": [
        { "name": "jose", "added": "2026-06-17", "reason": "OIDC id_token verification (ADR-0017)" }
      ]
    }
    
  • CI job in .forgejo/workflows/pr.ymlpackage_ages — runs on PRs, fetch-depth: 0 so it can git show origin/<base>:package-lock.json. Never blocks the workflow (soft signal, same shape as coverage).
  • tests/scripts/package-ages.test.ts — unit tests for the pure lib. Cover lockfile parsing (root entry skipped, scoped names handled, no-duplicates), newPackages set difference, flagOffenders (allowlist hits, threshold boundary, no-offenders empty result). Mock fetchPackageMeta via the injectable fetchImpl.
  • ADR-0020 documenting the policy. Distinct threat from ADR-0009 (which stays at 7d for version-level quarantine). Soft signal not gate, same rationale as ADR-0019.
  • docs/ci.md Package-ages section.
  • CLAUDE.md "Working in this repo" note + cross-reference.

Out of scope

  • Pre-commit hook evaluated and rejected: Carol's existing pre-commit slot is for offline-fast-deterministic checks (gitleaks, actionlint). A network-dependent hook breaks that property without buying enough — CI feedback is ~1min via the sticky comment. The shared lib leaves the door open for a future hook with aggressive caching if demand emerges.
  • Auto-pruning the allowlist when entries age past 30 days. v1 entries are permanent until manually removed. Could land later.
  • Hard gate. Per ADR-0019's soft-signal rationale, gating coverage-style checks creates false-positive friction. The sticky comment is the forcing function.
  • socket.dev / Snyk integration. Overkill for Carol's scale; revisit if false-positive tuning becomes a burden.

Acceptance

  • A PR that adds a new dependency whose package was first published <30 days ago gets a sticky comment listing the package, its age, and a link to its npm page. The workflow still completes successfully.
  • Adding the same package to .dep-policy.json in the same PR (or a follow-up commit) causes the comment to disappear on the next CI run.
  • A PR that touches no dependencies (doc-only / config-only) produces no comment.
  • A PR that adds a dep older than 30 days produces no comment.
  • The pure lib has unit tests covering scoped names, no-duplicates, set-difference, and threshold-boundary cases.
## Problem Carol's existing supply-chain defences address one specific threat: **a new malicious version of an established package**. ADR-0009's Renovate `minimumReleaseAge: "7 days"` quarantine, ADR-0010's install-script allowlist, and the existing scanner suite (npm audit, OSV, Trivy, gitleaks) all target version-level compromise of known dependencies. They do NOT address the orthogonal threat: **a fresh malicious package**. Typosquats, dependency-confusion attacks, and brand-new sleeper packages are by definition newly-published. Renovate's version-age quarantine doesn't help — it gates by release date of a version, not by when the package first existed on npm. A new transitive pulled in via a feature PR's lockfile resolution flows past every existing gate. Recent supply-chain attacks (the npm `xz` analogue cases) have consistently shown that brand-new packages are the most common shape. A 30-day "package must have existed for a month" policy catches the bulk of these without meaningfully delaying legitimate work. ## Scope - [ ] `scripts/lib/package-ages.mjs` — pure logic, no I/O: - `extractPackagesFromLockfile(lockfile)` — walks npm v3 `packages` map, returns unique package names with their first-seen version (handles scoped names like `@scope/name`). - `newPackages(base, current)` — set difference: packages in `current` but not in `base`. - `flagOffenders({ packageMetas, allowlist, thresholdDays, now })` — pure filter. - `fetchPackageMeta(name, fetchImpl)` — registry call (one I/O surface, injectable for tests). - [ ] `scripts/ci/check-package-ages.mjs` — wraps the lib. Reads current `package-lock.json` + base via `git show origin/<base>:package-lock.json` + `.dep-policy.json`. Queries `https://registry.npmjs.org/<pkg>` for `time.created` on each new package. Writes Markdown to a body file. Prints `flag=<bool>`. - [ ] `scripts/ci/post-deps-comment.mjs` — sticky-comment poster. Marker `<!-- deps-comment -->`. **Scanner-pattern delete-on-clean** (matches ADR-0016): no offenders → no comment. Differs from coverage's always-post. - [ ] `.dep-policy.json` at repo root. Format: ```json { "allowed_packages": [ { "name": "jose", "added": "2026-06-17", "reason": "OIDC id_token verification (ADR-0017)" } ] } ``` - [ ] CI job in `.forgejo/workflows/pr.yml` — `package_ages` — runs on PRs, `fetch-depth: 0` so it can `git show origin/<base>:package-lock.json`. Never blocks the workflow (soft signal, same shape as coverage). - [ ] `tests/scripts/package-ages.test.ts` — unit tests for the pure lib. Cover lockfile parsing (root entry skipped, scoped names handled, no-duplicates), `newPackages` set difference, `flagOffenders` (allowlist hits, threshold boundary, no-offenders empty result). Mock `fetchPackageMeta` via the injectable `fetchImpl`. - [ ] ADR-0020 documenting the policy. Distinct threat from ADR-0009 (which stays at 7d for version-level quarantine). Soft signal not gate, same rationale as ADR-0019. - [ ] `docs/ci.md` Package-ages section. - [ ] `CLAUDE.md` "Working in this repo" note + cross-reference. ## Out of scope - **Pre-commit hook** evaluated and rejected: Carol's existing pre-commit slot is for offline-fast-deterministic checks (gitleaks, actionlint). A network-dependent hook breaks that property without buying enough — CI feedback is ~1min via the sticky comment. The shared lib leaves the door open for a future hook with aggressive caching if demand emerges. - **Auto-pruning the allowlist** when entries age past 30 days. v1 entries are permanent until manually removed. Could land later. - **Hard gate.** Per ADR-0019's soft-signal rationale, gating coverage-style checks creates false-positive friction. The sticky comment is the forcing function. - **socket.dev / Snyk integration.** Overkill for Carol's scale; revisit if false-positive tuning becomes a burden. ## Acceptance - A PR that adds a new dependency whose package was first published <30 days ago gets a sticky comment listing the package, its age, and a link to its npm page. The workflow still completes successfully. - Adding the same package to `.dep-policy.json` in the same PR (or a follow-up commit) causes the comment to disappear on the next CI run. - A PR that touches no dependencies (doc-only / config-only) produces no comment. - A PR that adds a dep older than 30 days produces no comment. - The pure lib has unit tests covering scoped names, no-duplicates, set-difference, and threshold-boundary cases.
james closed this issue 2026-06-19 17:32:08 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
james/carol#136
No description provided.