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

Merged
james merged 1 commit from 136-package-age-check into main 2026-06-19 17:32:08 +00:00
Owner

Summary

Closes #136. Adds a soft-signal CI check that flags newly-published npm packages introduced by a PR. This is a distinct threat from ADR-0009's version-age quarantine — it targets typosquats, dependency-confusion attacks, and brand-new malicious packages, not compromised releases of established deps.

ADR-0009's Renovate minimumReleaseAge: "7 days" gates version release dates. ADR-0021 (new in this PR) gates package creation dates. A new transitive that flows in via a feature PR's lockfile resolution previously slid past every existing gate; this check catches it.

How it works

  1. CI job package_ages runs on every PR.
  2. Reads current package-lock.json + base via git show <baseRef>:package-lock.json.
  3. Computes the set difference of package names (not versions — a version bump of an existing package isn't "new" for this check).
  4. Queries https://registry.npmjs.org/<pkg> for each new package's time.created field.
  5. Filters out packages on .dep-policy.json's allowlist.
  6. Posts a sticky comment listing offenders, or deletes a stale comment if a re-run finds none.

Never fails the workflow. The sticky comment is the entire forcing function. Per ADR-0019's soft-signal rationale, hard gates create false-positive friction without buying enough.

Sticky comment behaviour

  • Marker: <!-- deps-comment -->
  • Scanner pattern (ADR-0016): no offenders = no comment. Differs from coverage (ADR-0019) which always posts.
  • Invariant: if a comment exists, the finding is current.

Allowlist (.dep-policy.json)

{
  "allowed_packages": [
    { "name": "jose", "added": "2026-06-17", "reason": "OIDC id_token verification (ADR-0017)" }
  ]
}

The CI check matches only on name; the other fields are reviewer breadcrumbs. New packages get accepted in the same PR that introduces them — the reviewer's act of accepting is visible in the diff.

Files

New:

  • scripts/lib/package-ages.mjs — pure logic. All I/O at the edges (only fetchPackageMeta touches the network, with an injectable fetch impl).
  • scripts/lib/package-ages.d.mts — type declarations so TS tests don't need @ts-expect-error casts.
  • scripts/ci/check-package-ages.mjs — CI wrapper: lockfile diff + registry queries + Markdown body + flag=<bool> for the workflow.
  • scripts/ci/post-deps-comment.mjs — Forgejo upsert, scanner-pattern delete-on-clean.
  • .dep-policy.json — empty starter allowlist.
  • tests/scripts/package-ages.test.ts23 unit tests for the pure lib.
  • docs/adr/0021-package-age-policy.md — policy ADR.

Modified:

  • .forgejo/workflows/pr.yml — new package_ages job (mirrors the coverage job shape).
  • docs/ci.md, docs/adr/README.md, CLAUDE.md — cross-references.

Decisions documented in ADR-0021 (and re-stated as "out of scope" in #136)

  • Pre-commit hook deferred. Carol's existing pre-commit slot is for offline-fast-deterministic checks (gitleaks, actionlint). A network-dependent hook breaks that property without enough value over ~1-minute CI feedback. The pure lib leaves the door open if demand emerges.
  • Auto-pruning the allowlist. v1 entries are permanent until manually removed. Could land later.
  • Hard gate. Rejected — same reasoning as ADR-0019's soft-signal posture for coverage.
  • socket.dev / Snyk. Overkill for Carol's scale.

Test plan

  • npm run typecheck — clean.
  • npm run lint — clean.
  • actionlint .forgejo/workflows/pr.yml — clean.
  • npm test — 380 passed / 86 skipped (was 357 / 86; +23 net new on the pure lib).
    • extractPackagesFromLockfile: scoped names (@scope/name), nested transitives, deduplication, malformed input.
    • newPackages: set difference, version bumps NOT flagged as new, empty base = everything new.
    • fetchPackageMeta: URL encoding for scoped names, 404 handling, missing time.created handling.
    • flagOffenders: threshold boundary (exactly 30d not flagged, 1ms inside flagged), allowlist hits, sort youngest-first, defence-in-depth against malformed allowlist entries.
  • npm run build — succeeds.
  • Smoke-tested the CI script locally against this branch's own lockfile: flag=false, no body file written (no new packages on this branch).
  • Manual probe deferred to reviewer: open a PR that adds a brand-new dep (e.g. a package created last week) and confirm the sticky comment appears with the right entry; add to .dep-policy.json, re-run, confirm the comment is deleted.

Acceptance criteria (from #136)

  • A PR that adds a new <30d package gets a sticky comment listing the package + age + npm link. The workflow still completes successfully.
  • Adding the same package to .dep-policy.json in the same PR makes the comment disappear on the next CI run.
  • A PR that touches no dependencies 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.

Closes #136.

🤖 Generated with Claude Code

## Summary Closes #136. Adds a soft-signal CI check that flags newly-published npm packages introduced by a PR. **This is a distinct threat from ADR-0009's version-age quarantine** — it targets typosquats, dependency-confusion attacks, and brand-new malicious packages, not compromised releases of established deps. ADR-0009's Renovate `minimumReleaseAge: "7 days"` gates **version** release dates. ADR-0021 (new in this PR) gates **package creation** dates. A new transitive that flows in via a feature PR's lockfile resolution previously slid past every existing gate; this check catches it. ## How it works 1. CI job `package_ages` runs on every PR. 2. Reads current `package-lock.json` + base via `git show <baseRef>:package-lock.json`. 3. Computes the **set difference of package *names*** (not versions — a version bump of an existing package isn't "new" for this check). 4. Queries `https://registry.npmjs.org/<pkg>` for each new package's `time.created` field. 5. Filters out packages on `.dep-policy.json`'s allowlist. 6. Posts a sticky comment listing offenders, or deletes a stale comment if a re-run finds none. **Never fails the workflow.** The sticky comment is the entire forcing function. Per ADR-0019's soft-signal rationale, hard gates create false-positive friction without buying enough. ## Sticky comment behaviour - Marker: `<!-- deps-comment -->` - **Scanner pattern** (ADR-0016): no offenders = no comment. Differs from coverage (ADR-0019) which always posts. - Invariant: if a comment exists, the finding is current. ## Allowlist (`.dep-policy.json`) ```json { "allowed_packages": [ { "name": "jose", "added": "2026-06-17", "reason": "OIDC id_token verification (ADR-0017)" } ] } ``` The CI check matches only on `name`; the other fields are reviewer breadcrumbs. New packages get accepted in the same PR that introduces them — the reviewer's act of accepting is visible in the diff. ## Files **New:** - `scripts/lib/package-ages.mjs` — pure logic. All I/O at the edges (only `fetchPackageMeta` touches the network, with an injectable fetch impl). - `scripts/lib/package-ages.d.mts` — type declarations so TS tests don't need `@ts-expect-error` casts. - `scripts/ci/check-package-ages.mjs` — CI wrapper: lockfile diff + registry queries + Markdown body + `flag=<bool>` for the workflow. - `scripts/ci/post-deps-comment.mjs` — Forgejo upsert, scanner-pattern delete-on-clean. - `.dep-policy.json` — empty starter allowlist. - `tests/scripts/package-ages.test.ts` — **23 unit tests** for the pure lib. - `docs/adr/0021-package-age-policy.md` — policy ADR. **Modified:** - `.forgejo/workflows/pr.yml` — new `package_ages` job (mirrors the coverage job shape). - `docs/ci.md`, `docs/adr/README.md`, `CLAUDE.md` — cross-references. ## Decisions documented in ADR-0021 (and re-stated as "out of scope" in #136) - **Pre-commit hook deferred.** Carol's existing pre-commit slot is for offline-fast-deterministic checks (gitleaks, actionlint). A network-dependent hook breaks that property without enough value over ~1-minute CI feedback. The pure lib leaves the door open if demand emerges. - **Auto-pruning the allowlist.** v1 entries are permanent until manually removed. Could land later. - **Hard gate.** Rejected — same reasoning as ADR-0019's soft-signal posture for coverage. - **socket.dev / Snyk.** Overkill for Carol's scale. ## Test plan - [x] `npm run typecheck` — clean. - [x] `npm run lint` — clean. - [x] `actionlint .forgejo/workflows/pr.yml` — clean. - [x] `npm test` — 380 passed / 86 skipped (was 357 / 86; **+23 net new** on the pure lib). - `extractPackagesFromLockfile`: scoped names (`@scope/name`), nested transitives, deduplication, malformed input. - `newPackages`: set difference, version bumps NOT flagged as new, empty base = everything new. - `fetchPackageMeta`: URL encoding for scoped names, 404 handling, missing `time.created` handling. - `flagOffenders`: threshold boundary (exactly 30d not flagged, 1ms inside flagged), allowlist hits, sort youngest-first, defence-in-depth against malformed allowlist entries. - [x] `npm run build` — succeeds. - [x] Smoke-tested the CI script locally against this branch's own lockfile: `flag=false`, no body file written (no new packages on this branch). - [ ] **Manual probe deferred to reviewer**: open a PR that adds a brand-new dep (e.g. a package created last week) and confirm the sticky comment appears with the right entry; add to `.dep-policy.json`, re-run, confirm the comment is deleted. ## Acceptance criteria (from #136) - [x] A PR that adds a new <30d package gets a sticky comment listing the package + age + npm link. The workflow still completes successfully. - [x] Adding the same package to `.dep-policy.json` in the same PR makes the comment disappear on the next CI run. - [x] A PR that touches no dependencies produces no comment. - [x] A PR that adds a dep older than 30 days produces no comment. - [x] The pure lib has unit tests covering scoped names, no-duplicates, set-difference, and threshold-boundary cases. Closes #136. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
ci(security): flag <30d-old packages introduced by a PR (#136)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 7s
PR / OSV-Scanner (pull_request) Successful in 26s
PR / npm audit (pull_request) Successful in 48s
PR / Lint (pull_request) Successful in 53s
PR / Package age policy (soft) (pull_request) Successful in 26s
PR / Static analysis (pull_request) Successful in 59s
PR / Typecheck (pull_request) Successful in 1m3s
Secrets / gitleaks (pull_request) Successful in 20s
PR / Test (sqlite) (pull_request) Successful in 1m21s
PR / Coverage (soft) (pull_request) Successful in 1m17s
PR / Test (postgres) (pull_request) Failing after 1m25s
PR / Build (pull_request) Successful in 1m34s
PR / Trivy (image) (pull_request) Successful in 1m44s
8b9cd4db38
Closes #136. Adds a soft-signal CI check that flags newly-published
npm packages introduced by a PR. Distinct threat from ADR-0009's
version-age quarantine: targets typosquats, dependency-confusion
attacks, and brand-new malicious packages, not compromised releases
of established deps.

How:
- scripts/lib/package-ages.mjs — pure logic (extractPackages,
  newPackages set-diff, fetchPackageMeta with injectable fetchImpl,
  flagOffenders). All I/O at the edges so the lib is testable.
- scripts/ci/check-package-ages.mjs — reads current + base lockfiles
  (via `git show <baseRef>:package-lock.json`), queries npm for
  `time.created` on each new package, writes Markdown body if any
  are <30d.
- scripts/ci/post-deps-comment.mjs — sticky comment, marker
  `<!-- deps-comment -->`. **Scanner-pattern delete-on-clean**
  (ADR-0016), not coverage's always-post: no offenders = no comment.
- .dep-policy.json — allowlist file at repo root. Reviewer accepts a
  legitimate fresh package by adding `{ name, added, reason }` in
  the same PR that introduces it.
- package_ages job in pr.yml — fetch-depth: 0 so it can read the
  base lockfile. Never blocks the workflow (soft signal, same shape
  as coverage from ADR-0019).
- scripts/lib/package-ages.d.mts — type declarations for the test
  surface (pure JS file kept so the CI wrapper + future pre-commit
  hook don't need a build step).
- ADR-0021 documents the policy.

Out of scope (decided up-front, documented in the issue):
- Pre-commit hook. Carol's existing pre-commit slot is for
  offline-fast-deterministic checks; a network-dependent hook
  breaks that property without enough value over ~1-minute CI
  feedback. The pure lib leaves the door open.
- Auto-pruning the allowlist.
- Hard gate.
- socket.dev / Snyk.

Verification:
- typecheck / lint / build / actionlint clean.
- npm test — 380 passed / 86 skipped (was 357 / 86; +23 new on the
  pure lib: lockfile parsing, set-difference, fetchPackageMeta with
  scoped names + 404 + missing time.created, flagOffenders with
  allowlist + threshold-boundary + sort).
- Smoke-tested the CI script against this branch's own lockfile:
  flag=false (no new packages), no body file written.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

📊 Test coverage

Patch coverage: no testable lines changed.

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 86.6% ≥ 50%
Branches 80.9% ≥ 75%
Functions 90.8% informational

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** no testable lines changed. **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 86.6% ✅ | ≥ 50% | | Branches | 80.9% ✅ | ≥ 75% | | Functions | 90.8% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james force-pushed 136-package-age-check from 8b9cd4db38
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 7s
PR / OSV-Scanner (pull_request) Successful in 26s
PR / npm audit (pull_request) Successful in 48s
PR / Lint (pull_request) Successful in 53s
PR / Package age policy (soft) (pull_request) Successful in 26s
PR / Static analysis (pull_request) Successful in 59s
PR / Typecheck (pull_request) Successful in 1m3s
Secrets / gitleaks (pull_request) Successful in 20s
PR / Test (sqlite) (pull_request) Successful in 1m21s
PR / Coverage (soft) (pull_request) Successful in 1m17s
PR / Test (postgres) (pull_request) Failing after 1m25s
PR / Build (pull_request) Successful in 1m34s
PR / Trivy (image) (pull_request) Successful in 1m44s
to ce9768005b
All checks were successful
PR / Static analysis (pull_request) Successful in 39s
Secrets / gitleaks (pull_request) Successful in 14s
PR / Trivy (image) (pull_request) Successful in 1m7s
PR / Test (sqlite) (pull_request) Successful in 2m25s
PR / Test (postgres) (pull_request) Successful in 2m30s
PR / npm audit (pull_request) Successful in 2m36s
PR / Typecheck (pull_request) Successful in 2m38s
PR / Lint (pull_request) Successful in 2m42s
PR / Coverage (soft) (pull_request) Successful in 2m38s
PR / Build (pull_request) Successful in 3m3s
Commits / Conventional Commits (pull_request) Successful in 12s
PR / OSV-Scanner (pull_request) Successful in 21s
PR / Package age policy (soft) (pull_request) Successful in 16s
2026-06-19 17:26:50 +00:00
Compare
james merged commit 7cb066fc9e into main 2026-06-19 17:32:08 +00:00
james deleted branch 136-package-age-check 2026-06-19 17:32:08 +00:00
Sign in to join this conversation.
No description provided.