diff --git a/README-cs.md b/README-cs.md index ece7d55..ef5f37f 100644 --- a/README-cs.md +++ b/README-cs.md @@ -157,10 +157,34 @@ Pozor na jedno omezení: **`renovate-config-validator` nevaliduje názvy managerů.** `matchManagers: ["npm", "pnpm", "yarn"]` projde čistě a nematchuje nic. Gate pokrývá schéma, ne význam. -## Pod vlastním účtem +### Co gate kontroluje -Repozitář si zkopíruj, ale **neforkuj ho pro každou organizaci zvlášť.** -Dědění je levnější: +`validate.sh` pouští dvě věci a ta druhá je důležitější: + +- `renovate-config-validator --strict` — presety jsou správně zapsané +- `tooling/test_policy.py` — presety **rozhodují** to, co mají + +Validátor kontroluje tvar, ne význam. Každá chyba, kterou tenhle preset vydal, +jím prošla: neukotvený vzor dávající důvěru namespace, který nikdo nevlastní, +pravidlo zařazené tak, že rušilo to nad sebou, dva neexistující názvy managerů. +Policy test je má zmrazené jako případy a spadne, kdyby obnova lockfilu zase +dostala automerge. + +Renovate matching reimplementuje, místo aby volal Renovate, takže se od +skutečného enginu může rozejít. Selhání je důvod se podívat; průchod je slabší +důkaz než dry run. + +## Tři způsoby, jak si to vzít, a co který stojí + +**Jen dědit.** Opravy k tobě dorazí ve chvíli, kdy vzniknou. Nic neudržuješ. + +```json +{ "extends": ["github>ownctrl/supply-chain"] } +``` + +**Dědit a přebít.** Totéž plus vlastní doplňky. Tohle použij pro druhou značku +nebo tým místo forku — jedna bezpečnostní oprava by se neměla dělat tolikrát, +kolik máš kopií. ```json { @@ -169,7 +193,59 @@ Dědění je levnější: } ``` -Fork znamená opravit každou bezpečnostní vadu tolikrát, kolik máš kopií. +**Vzít si kopii.** Tenhle repozitář je GitHub template. Sáhni po něm, když +politiku potřebuješ vlastnit — odstřižené prostředí, požadavek na compliance, +nesouhlas s nějakým rozhodnutím tady. + +Ale ber to s cenovkou: **kopie přestane dostávat opravy.** V tomhle presetu +se našly čtyři vady, na které by nikdo nepřišel čtením dokumentace, a kopie +vzatá před kteroukoli z nich si ji nechala. U bezpečnostní politiky je tohle +ten drahý směr. + +Když chceš vlastní adresu **i** opravy, ať tvoje kopie dědí odsud: + +```json +{ + "extends": ["github>ownctrl/supply-chain"], + "packageRules": [ /* jen tvoje odchylky */ ] +} +``` + +Workflow `Setup Owner` po ručním spuštění přepíše reference a LICENSE na +nového vlastníka. + +## Co to nedělá + +Preset rozhoduje, které aktualizace závislostí smějí přistát bez lidského +pohledu. To je celý jeho rozsah a stojí za to říct natvrdo, kde končí: + +- **Hlídá aktualizace, ne to, co už máš.** Hostilní verze, která ti už leží + v lockfilu, je mimo jeho dosah. Zkontroluj lockfile přímo a rotuj všechno, na + co ten balíček mohl dosáhnout. +- **Není to skener.** Neprohlíží obsah balíčků, neověřuje, že vydaný artefakt + odpovídá zdrojáku, ani nedetekuje kompromitaci. Kombinuj ho s něčím, co to + umí. +- **Odstup není záruka.** `minimumReleaseAge` kupuje čas, aby si někdo jiný + všiml špatného vydání. Nemusí si všimnout nikdo. Shai-Hulud zůstal + u některých balíčků nepovšimnutý déle než sedm dní. +- **Watch list je snímek.** 428 balíčků známých z jednoho útoku v listopadu + 2025. O tom příštím neříká nic. +- **Policy test reimplementuje Renovate matching**, místo aby volal Renovate, + takže se od skutečného enginu může rozejít. + +Poskytováno tak, jak je, bez záruky, pod [licencí MIT](./LICENSE). Rozhodnutí, +co smí tvůj projekt sloučit bez dozoru, je tvoje; tenhle preset je výchozí bod +s odůvodněním, ne náhrada za to rozhodnutí. + +## Workflow `Setup Owner` + +`.github/workflows/setup-owner.yml` je tu pro cestu s kopií. Po ručním +spuštění přepíše reference presetu a copyright v LICENSE na nového vlastníka +a pak sám sebe smaže. + +Běží s `contents: write` a pushuje na výchozí větev, což k té práci potřebuje. +Je jen `workflow_dispatch` — nic ho nespouští automaticky. Když preset dědíš +místo kopírování, nikdy ho nepustíš. ## Verzování diff --git a/README.md b/README.md index 3132e0f..758c607 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@
-[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![License: MIT](https://img.shields.io/badge/License-MIT-brightgreen.svg)](https://opensource.org/licenses/MIT) [![Renovate enabled](https://img.shields.io/badge/renovate-enabled-brightgreen.svg)](https://renovatebot.com/) ![Ecosystems](https://img.shields.io/badge/ecosystems-10+-blue.svg) @@ -160,11 +160,17 @@ Pin a release if you do not want your policy to change under you: { "extends": ["github>ownctrl/supply-chain#v1.0.0"] } ``` -### Using it under your own account +### Three ways to adopt this, and what each costs -Copy this repo, then reference your own copy. Do **not** fork it per -organisation — inherit instead, so one security fix does not have to be applied -once per copy: +**Just extend it.** Fixes reach you the moment they land. Nothing to maintain. + +```json +{ "extends": ["github>ownctrl/supply-chain"] } +``` + +**Extend it and override.** Same, plus your own additions. Use this for a +second brand or team rather than forking — one security fix should not have to +be applied once per copy. ```json { @@ -173,8 +179,26 @@ once per copy: } ``` -The `Setup Owner` workflow rewrites the examples and LICENSE to the new owner -when you dispatch it manually. +**Take a copy.** This repo is a GitHub template. Use it when you need to own +the policy outright — an air-gapped mirror, a compliance requirement, a +disagreement with a decision here. + +Be clear about the price: **a copy stops receiving fixes.** Four faults were +found in this preset that no amount of reading the documentation would have +surfaced, and a copy taken before each one kept it. For a security policy that +is the expensive direction. + +If you want your own address *and* the fixes, have your copy extend upstream: + +```json +{ + "extends": ["github>ownctrl/supply-chain"], + "packageRules": [ /* your deviations, and only those */ ] +} +``` + +The `Setup Owner` workflow rewrites the preset references and LICENSE to the +new owner when you dispatch it manually. ### JavaScript runtimes and package managers @@ -246,6 +270,23 @@ ln -s ../../tooling/validate.sh .git/hooks/pre-push A local hook is a convenience, not a boundary — anyone can skip it. CI stays the real gate. +### What the gate checks + +`validate.sh` runs two things, and the second matters more: + +- `renovate-config-validator --strict` — the presets are well formed +- `tooling/test_policy.py` — the presets *decide* what they should + +The validator checks shape, not meaning. Every fault this preset has shipped +passed it: an unanchored pattern granting trust to a namespace nobody owns, a +rule ordered so it undid the one above it, two manager names that do not exist. +The policy test freezes those as cases, and fails if lockfile maintenance ever +gets automerge back. + +It reimplements Renovate's matching rather than calling Renovate, so it can +drift from the real engine. A failure is a reason to look; a pass is weaker +evidence than a dry run. + ## Testing locally You can test this config locally before deploying: @@ -309,6 +350,40 @@ The full list lives in [`default.json`](./default.json). Affected version ranges --- +## What this does not do + +This preset decides which dependency updates may merge unattended. That is its +whole scope, and it is worth being blunt about the edges: + +- **It gates updates, not what you already have.** A hostile version already in + your lockfile is outside its reach. Check the lockfile directly and rotate + anything the package could have read. +- **It is not a scanner.** It does not inspect package contents, verify that a + published artifact matches its source, or detect compromise. Pair it with + something that does. +- **A delay is not a guarantee.** `minimumReleaseAge` buys time for someone + else to notice a bad release. Nobody may notice. Shai-Hulud went unnoticed + for longer than seven days in some packages. +- **The watch list is a snapshot.** 428 packages known compromised in one + attack, in November 2025. It says nothing about the next one. +- **The policy test reimplements Renovate's matching** rather than calling + Renovate, so it can drift from the real engine. + +Provided as is, without warranty, under the [MIT licence](./LICENSE). Deciding +what your project may merge unattended is your decision; this preset is a +starting point with reasoning attached, not a substitute for making it. + +## The `Setup Owner` workflow + +`.github/workflows/setup-owner.yml` exists for the copy path. Dispatch it +manually after taking a copy and it rewrites the preset references and the +LICENSE copyright to whoever now owns the repo, then deletes itself. + +It runs with `contents: write` and pushes to the default branch, which is what +it needs to do that job. It is `workflow_dispatch` only — nothing triggers it +automatically. If you are extending this preset rather than copying it, you +will never run it. + ## Versioning `main` is what an unpinned `github>ownctrl/supply-chain` resolves to, so every diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c3f4faa --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,45 @@ +# Security policy + +## Reporting + +Report a problem with this preset through +[private vulnerability reporting](https://github.com/ownctrl/supply-chain/security/advisories/new), +not a public issue. + +Worth reporting: + +- a rule that lets something merge unattended when it should not +- a matcher that is broader than it reads — an unanchored pattern over a + package prefix nobody owns is the fault this preset has shipped most often +- a package on the watch list that should not be, or one missing that should +- remediation text pointing somewhere a reader cannot follow + +## What this preset is and is not + +It decides **which dependency updates may land without a human looking at +them.** That is all. It does not scan code, detect compromise, or verify that a +published package matches its source. + +It cannot protect a repository that has already installed a hostile version. It +gates *updates*, so a compromised package already in your lockfile is outside +its reach — check the lockfile directly and rotate anything the package could +have read. + +## Known limitations + +- `renovate-config-validator` does not validate manager names. A typo'd manager + silently disables the rule containing it. `tooling/test_policy.py` checks the + names this preset uses against a hardcoded list, which needs updating when + Renovate adds a manager. +- `tooling/test_policy.py` reimplements Renovate's matching rather than calling + Renovate. It can drift from the real engine. A failure is a reason to look; a + pass is weaker evidence than a Renovate dry run. +- `minimumReleaseAge` does not apply to `pin`, `lockFileMaintenance`, + `lockfileUpdate`, `rollback`, `bump` or `replacement`. This preset disables + automerge for lockfile maintenance because of it. +- Renovate has a hosted app on GitHub.com only. On every other forge you run it + yourself, and its token can write to all of your repositories. + +## Supported versions + +The latest tag. Older tags do not get fixes — move the pin. diff --git a/aggressive.json b/aggressive.json index 996643b..1b16612 100644 --- a/aggressive.json +++ b/aggressive.json @@ -1,20 +1,14 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "description": "Low-friction: updates any time, no release-age floor, higher PR limit. Not for use during an active supply-chain incident.", - "extends": [ - "local>ownctrl/supply-chain" - ], - "schedule": [ - "at any time" - ], + "extends": ["local>ownctrl/supply-chain"], + "schedule": ["at any time"], "prConcurrentLimit": 10, "minimumReleaseAge": null, "packageRules": [ { "description": "Top-level minimumReleaseAge cannot lift the inherited npm rule -- a packageRule outranks it, so drop the floor here too", - "matchDatasources": [ - "npm" - ], + "matchDatasources": ["npm"], "minimumReleaseAge": null } ] diff --git a/default.json b/default.json index 735a572..ee07335 100644 --- a/default.json +++ b/default.json @@ -140,7 +140,14 @@ }, { "description": "Python: group together", - "matchManagers": ["pip_requirements", "pip-compile", "poetry", "pep621", "pipenv", "setup-cfg"], + "matchManagers": [ + "pip_requirements", + "pip-compile", + "poetry", + "pep621", + "pipenv", + "setup-cfg" + ], "groupName": "Python", "separateMinorPatch": false }, @@ -182,7 +189,15 @@ }, { "description": "Kubernetes: group together", - "matchManagers": ["kubernetes", "helmv3", "helm-values", "helm-requirements", "helmfile", "flux", "argocd"], + "matchManagers": [ + "kubernetes", + "helmv3", + "helm-values", + "helm-requirements", + "helmfile", + "flux", + "argocd" + ], "groupName": "Kubernetes", "separateMinorPatch": false }, diff --git a/lockdown.json b/lockdown.json index d7c235d..d30d96f 100644 --- a/lockdown.json +++ b/lockdown.json @@ -1,37 +1,26 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "description": "🔒 Lockdown: no automerge anywhere, 14-day floor, every update waits for dashboard approval. For use during an active supply-chain incident.", - "extends": [ - "local>ownctrl/supply-chain" - ], - "schedule": [ - "before 06:00 on monday" - ], + "extends": ["local>ownctrl/supply-chain"], + "schedule": ["before 06:00 on monday"], "minimumReleaseAge": "14 days", "prConcurrentLimit": 2, "dependencyDashboardApproval": true, "packageRules": [ { "description": "LOCKDOWN: npm floor to 14 days -- must be a packageRule, the top-level value is outranked by the inherited npm rule", - "matchDatasources": [ - "npm" - ], + "matchDatasources": ["npm"], "minimumReleaseAge": "14 days" }, { "description": "LOCKDOWN: Disable ALL automerge", "automerge": false, "dependencyDashboardApproval": true, - "matchPackageNames": [ - "*" - ] + "matchPackageNames": ["*"] }, { "description": "LOCKDOWN: Production deps require manual approval", - "matchDepTypes": [ - "dependencies", - "peerDependencies" - ], + "matchDepTypes": ["dependencies", "peerDependencies"], "prPriority": -50, "prBodyNotes": [ "🔒 **LOCKDOWN MODE ACTIVE**", @@ -47,9 +36,7 @@ }, { "description": "LOCKDOWN: Block all major updates", - "matchUpdateTypes": [ - "major" - ], + "matchUpdateTypes": ["major"], "enabled": false } ] diff --git a/no-automerge.json b/no-automerge.json index 4e1a055..365d7de 100644 --- a/no-automerge.json +++ b/no-automerge.json @@ -1,15 +1,11 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "description": "Disable automerge entirely, keeping every other policy from the base preset.", - "extends": [ - "local>ownctrl/supply-chain" - ], + "extends": ["local>ownctrl/supply-chain"], "packageRules": [ { "automerge": false, - "matchPackageNames": [ - "*" - ] + "matchPackageNames": ["*"] } ] } diff --git a/renovate.json b/renovate.json index 0a27d99..34842e5 100644 --- a/renovate.json +++ b/renovate.json @@ -1,18 +1,14 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "description": "This repo eats its own dog food: it consumes the preset it publishes. Without this, the action digests and the pinned renovate version in .github/workflows/ would rot, and a stale pin is worse than a floating tag.", - "extends": [ - "local>ownctrl/supply-chain" - ], + "description": "This repo eats its own dog food: it consumes the preset it publishes. Without this, the action digests in .github/workflows/ and the tool versions pinned in tooling/validate.sh would rot, and a stale pin is worse than a floating tag.", + "extends": ["local>ownctrl/supply-chain"], "customManagers": [ { - "description": "Keep the validator pin in tooling/validate.sh updated", + "description": "Keep the pinned tool versions in tooling/validate.sh updated", "customType": "regex", - "managerFilePatterns": [ - "/^tooling/validate\\.sh$/" - ], + "managerFilePatterns": ["/^tooling/validate\\.sh$/"], "matchStrings": [ - "# renovate: datasource=(?\\S+) depName=(?\\S+)\\s+RENOVATE_VERSION=\"\\$\\{RENOVATE_VERSION:-(?[^}\"]+)\\}\"" + "# renovate: datasource=(?\\S+) depName=(?\\S+)\\s+[A-Z_]+_VERSION=\"\\$\\{[A-Z_]+:-(?[^}\"]+)\\}\"" ] } ] diff --git a/tooling/test_policy.py b/tooling/test_policy.py new file mode 100755 index 0000000..6ac976f --- /dev/null +++ b/tooling/test_policy.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Assert what this preset lets merge unattended. + +`renovate-config-validator` checks that default.json is well formed. It does +not check what the rules *decide* -- it will happily accept a rule that grants +automerge to every package on npm, or one whose matcher never fires. Every +fault this preset has shipped was of that kind: + + * `^jest` unanchored matched every `jest-*` package on npm, a prefix nobody + owns, and those automerged with the trust meant for Jest. + * Per-tool rules sat after the anchored trust list and re-granted automerge + through looser patterns, undoing the anchoring above them. + * The GitHub Actions rule had no matchUpdateTypes, so it overrode + "major: automerge false" and merged bare digest moves unattended. + * `matchManagers: ["npm", "pnpm", "yarn"]` named two managers that do not + exist, so a rule that looked like it covered three ecosystems covered one. + +The validator passed all four. The cases below are those bugs, frozen. + +LIMITATION, and it is not a small one: this reimplements Renovate's matching +rather than calling Renovate. It can drift from the real engine. Treat a +failure here as a reason to look, and a pass as weaker evidence than a Renovate +dry run. It exists because the alternative is checking nothing. +""" + +import fnmatch +import json +import re +import sys +from pathlib import Path + +CONFIG = Path(__file__).resolve().parent.parent / "default.json" + + +def _matches_name(pattern: str, name: str) -> bool: + """Renovate matchPackageNames: exact, glob, or /regex/.""" + if pattern == "*": + return True + if len(pattern) > 1 and pattern.startswith("/") and pattern.endswith("/"): + return re.search(pattern[1:-1], name) is not None + if any(c in pattern for c in "*?["): + return fnmatch.fnmatch(name, pattern) + return pattern == name + + +def _rule_applies(rule, *, name, dep_type, manager, update_type, datasource): + for key, value in ( + ("matchManagers", manager), + ("matchDepTypes", dep_type), + ("matchUpdateTypes", update_type), + ("matchDatasources", datasource), + ): + if key in rule and value not in rule[key]: + return False + if "matchPackageNames" in rule: + if not any(_matches_name(p, name) for p in rule["matchPackageNames"]): + return False + return True + + +def resolve(rules, key, **ctx): + """Last matching rule wins, which is how Renovate layers packageRules.""" + value, source = None, "unset" + for rule in rules: + if _rule_applies(rule, **ctx) and key in rule: + value, source = rule[key], rule.get("description", "") + return value, source + + +# name, depType, manager, updateType, datasource, automerge expected +CASES = [ + # Trusted dev tooling merges unattended. That is the whole allowance. + ("jest", "devDependencies", "npm", "patch", "npm", True), + ("vitest", "devDependencies", "npm", "patch", "npm", True), + ("oxlint", "devDependencies", "npm", "patch", "npm", True), + ("typescript", "devDependencies", "npm", "patch", "npm", True), + ("@biomejs/biome", "devDependencies", "npm", "patch", "npm", True), + ("@types/node", "devDependencies", "npm", "patch", "npm", True), + # Neighbours in unowned npm prefixes must NOT inherit that trust. + ("jest-environment-foo", "devDependencies", "npm", "patch", "npm", False), + ("vitest-fetch-mock", "devDependencies", "npm", "patch", "npm", False), + ("oxlint-plugin-evil", "devDependencies", "npm", "patch", "npm", False), + ("typescript-eslint-hijack", "devDependencies", "npm", "patch", "npm", False), + # Production dependencies never merge unattended, whatever they are called. + ("typescript", "dependencies", "npm", "patch", "npm", False), + ("vitest", "dependencies", "npm", "patch", "npm", False), + ("@biomejs/biome", "dependencies", "npm", "patch", "npm", False), + ("express", "dependencies", "npm", "patch", "npm", False), + ("serde", "dependencies", "cargo", "patch", "crate", False), + ("django", "dependencies", "poetry", "patch", "pypi", False), + # Watch-listed packages stay gated even as devDependencies. + ("posthog-node", "dependencies", "npm", "patch", "npm", False), + ("tinycolor2", "devDependencies", "npm", "patch", "npm", False), + # CI actions: minor/patch only. A bare digest move on an unchanged tag is + # the tj-actions/changed-files vector and must wait for a human. + ("actions/checkout", None, "github-actions", "patch", "github-tags", True), + ("actions/checkout", None, "github-actions", "minor", "github-tags", True), + ("actions/checkout", None, "github-actions", "digest", "github-tags", False), + ("actions/checkout", None, "github-actions", "major", "github-tags", False), +] + + +def main() -> int: + config = json.loads(CONFIG.read_text()) + rules = config["packageRules"] + failures = [] + + for name, dep_type, manager, update_type, datasource, expected in CASES: + got, source = resolve( + rules, + "automerge", + name=name, + dep_type=dep_type, + manager=manager, + update_type=update_type, + datasource=datasource, + ) + if bool(got) is not expected: + failures.append( + f" {name} ({dep_type or manager}, {update_type}): " + f"expected automerge={expected}, got {bool(got)} " + f"from rule {source!r}" + ) + + # Lockfile refreshes are not covered by minimumReleaseAge, so automerging + # one would pull every transitive dependency in unreviewed and ungated. + if config["lockFileMaintenance"].get("automerge"): + failures.append( + " lockFileMaintenance.automerge is true: minimumReleaseAge does " + "not apply to lockfile refreshes, so this merges every transitive " + "update with no age gate and no review" + ) + + # Renovate has no pnpm or yarn manager; the npm manager reads all three + # lockfiles. A rule naming them looks broader than it is, and the schema + # validator does not catch it. + known = { + "npm", "bun", "bun-version", "deno", "cargo", "gomod", "docker", + "github-actions", "nix", "terraform", "ansible", "nuget", "composer", + "bundler", "pub", "swift", "cocoapods", "kubernetes", "helmv3", + "helm-values", "helm-requirements", "helmfile", "flux", "argocd", + "gradle", "gradle-wrapper", "maven", "maven-wrapper", "sbt", + "pip_requirements", "pip-compile", "poetry", "pep621", "pipenv", + "setup-cfg", + } + for rule in rules: + for manager in rule.get("matchManagers", []): + if manager not in known: + failures.append( + f" unknown manager {manager!r} in rule " + f"{rule.get('description', '')!r} -- it matches nothing. " + f"If Renovate added it, add it to `known` here." + ) + + if failures: + print(f"policy: {len(failures)} failure(s)\n") + print("\n".join(failures)) + return 1 + + print(f"policy: {len(CASES)} cases, lockfile gate and manager names all OK") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tooling/validate.sh b/tooling/validate.sh index a569c38..bca45a1 100755 --- a/tooling/validate.sh +++ b/tooling/validate.sh @@ -13,6 +13,8 @@ set -euo pipefail # renovate: datasource=npm depName=renovate RENOVATE_VERSION="${RENOVATE_VERSION:-44.39.1}" +# renovate: datasource=npm depName=@biomejs/biome +BIOME_VERSION="${BIOME_VERSION:-2.5.9}" cd "$(dirname "$0")/.." @@ -31,3 +33,12 @@ fi echo "validating ${#presets[@]} presets against renovate@${RENOVATE_VERSION}" npx --yes --package "renovate@${RENOVATE_VERSION}" -- \ renovate-config-validator --strict "${presets[@]}" + +# The validator checks that the rules are well formed, not what they decide. +# Every fault this preset has shipped passed it. +python3 tooling/test_policy.py + +# Formatting. Pinned to the version biome.json declares -- biome rejects a +# schema from a different release, so the two move together or not at all. +echo "checking formatting with @biomejs/biome@${BIOME_VERSION}" +npx --yes --package "@biomejs/biome@${BIOME_VERSION}" -- biome check .