Harden Renovate caching and digest versioning

Strip image digests before deriving app directory versions, while continuing to reject digest-only images. Document the cache privacy boundary, restrict the workflow token to read-only, and bound full scans to 90 minutes.
This commit is contained in:
okxlin
2026-07-12 10:36:01 +08:00
parent 99f3cea995
commit d26004a6ca
5 changed files with 96 additions and 17 deletions
+14
View File
@@ -39,6 +39,20 @@ The workflow stores Renovate's public package lookup cache and repository extrac
GitHub Actions cache entries are immutable, so each run uses a unique key and restores the newest compatible prefix. The configuration hash separates policy generations, while the broader fallback retains public package lookup data after policy changes. Monitor the reported directory size and repository cache inventory to avoid churn against GitHub's default cache quota.
Restore and save are separate steps. A failed registry scan may still save valid package cache entries for the next run, but the workflow refuses to save a cache larger than 512 MiB.
## Cache security boundary
- Treat cache contents as readable by contributors who can open pull requests. Never write credentials, tokens, generated environment files, or private registry responses under `/tmp/renovate-cache`.
- Keep `RENOVATE_CACHE_PRIVATE_PACKAGES` set to `false`.
- Keep cache writes limited to this scheduled/manual workflow; do not add `pull_request` or `pull_request_target` triggers.
- Keep `actions/cache` pinned to a reviewed commit SHA.
- Keep the workflow's default `GITHUB_TOKEN` read-only. Renovate writes through the dedicated `GITHUBTOKEN` secret.
- Keep a bounded job timeout so a stalled registry cannot consume a runner indefinitely.
- The restored directory contains Renovate data, not executable project scripts. Do not add cached paths to `PATH`, source files from them, or execute binaries restored from the cache.
- A cache miss or corrupt entry must degrade to a fresh lookup. It must not bypass Renovate validation, sidecar guards, app-version checks, or maintainer review.
- Keep Docker Hub credentials scoped to `docker.io`, `index.docker.io`, and `registry-1.docker.io`. Do not use a hostless Docker rule that would send them to unrelated registries.
## Change checklist
After changing either controller:
+11 -8
View File
@@ -1,6 +1,11 @@
const username = process.env.RENOVATE_DOCKERHUB_USERNAME;
const password = process.env.RENOVATE_DOCKERHUB_TOKEN;
const dockerConfig = require("./renovate-docker.json");
const dockerHubHosts = [
"docker.io",
"index.docker.io",
"registry-1.docker.io",
];
if (!username || !password) {
throw new Error(
@@ -16,12 +21,10 @@ module.exports = {
branchPrefix: "selfhosted-renovate/",
dependencyDashboard: true,
dependencyDashboardTitle: "Dependency Dashboard (Docker Images)",
hostRules: [
{
hostType: "docker",
matchHost: "docker.io",
username,
password,
},
],
hostRules: dockerHubHosts.map((matchHost) => ({
hostType: "docker",
matchHost,
username,
password,
})),
};
+2 -1
View File
@@ -31,8 +31,9 @@ def services(compose: dict[str, Any]) -> dict[str, dict[str, Any]]:
def image_tag(image: object) -> str:
value = str(image or "").strip()
if not value or "@" in value:
if not value:
return ""
value = value.split("@", 1)[0].strip()
slash = value.rfind("/")
colon = value.rfind(":")
if colon <= slash:
+45 -7
View File
@@ -26,6 +26,19 @@ def compose(images):
class RenovateAppVersionTests(unittest.TestCase):
def test_image_tag_strips_digest_from_tagged_image(self):
module = load_module()
self.assertEqual(
"1.2.3",
module.image_tag("registry.example.com:5000/demo/app:v1.2.3@sha256:deadbeef"),
)
def test_image_tag_rejects_digest_only_image(self):
module = load_module()
self.assertEqual("", module.image_tag("example/demo@sha256:deadbeef"))
def test_single_service_uses_the_changed_service_tag(self):
module = load_module()
@@ -39,6 +52,18 @@ class RenovateAppVersionTests(unittest.TestCase):
self.assertEqual("1.0.1", selection.version)
self.assertEqual(["app"], selection.changed_services)
def test_single_service_uses_tag_without_digest_for_directory_version(self):
module = load_module()
selection = module.select_target_version(
"demo",
compose({"app": "example/demo:1.0.0@sha256:old"}),
compose({"app": "example/demo:1.0.1@sha256:new"}),
{},
)
self.assertEqual("1.0.1", selection.version)
def test_multi_service_sidecar_only_change_does_not_rename(self):
module = load_module()
@@ -176,6 +201,8 @@ class RenovateAppVersionTests(unittest.TestCase):
workflow = (REPO_ROOT / ".github" / "workflows" / "renovate.yml").read_text(encoding="utf-8")
self.assertIn("concurrency:\n group: renovate-full-scan\n cancel-in-progress: true", workflow)
self.assertIn("permissions:\n contents: read", workflow)
self.assertIn("timeout-minutes: 90", workflow)
def test_self_hosted_renovate_uses_semantic_entrypoint(self):
workflow = (REPO_ROOT / ".github" / "workflows" / "renovate.yml").read_text(encoding="utf-8")
@@ -201,7 +228,11 @@ class RenovateAppVersionTests(unittest.TestCase):
)
self.assertIn(
"uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0",
"uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0",
workflow,
)
self.assertIn(
"uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0",
workflow,
)
self.assertIn("path: /tmp/renovate-cache", workflow)
@@ -211,6 +242,9 @@ class RenovateAppVersionTests(unittest.TestCase):
self.assertIn("RENOVATE_CACHE_PRIVATE_PACKAGES: 'false'", workflow)
self.assertIn("chmod -R a+rwX /tmp/renovate-cache", workflow)
self.assertIn("du -sh /tmp/renovate-cache", workflow)
self.assertIn("max_cache_bytes=536870912", workflow)
self.assertNotIn("pull_request:\n", workflow)
self.assertNotIn("pull_request_target:\n", workflow)
def test_hosted_and_self_hosted_renovate_have_disjoint_manager_scopes(self):
hosted = json.loads((REPO_ROOT / "renovate.json").read_text(encoding="utf-8"))
@@ -260,16 +294,20 @@ class RenovateAppVersionTests(unittest.TestCase):
env = os.environ.copy()
env["RENOVATE_DOCKERHUB_USERNAME"] = "renovate-user"
env["RENOVATE_DOCKERHUB_TOKEN"] = "test-token"
expression = f"const c=require({json.dumps(str(config))}); console.log(JSON.stringify(c.hostRules[0]))"
expression = f"const c=require({json.dumps(str(config))}); console.log(JSON.stringify(c.hostRules))"
result = subprocess.run(["node", "-e", expression], text=True, capture_output=True, env=env, check=False)
host_rule = json.loads(result.stdout)
host_rules = json.loads(result.stdout)
self.assertEqual(0, result.returncode)
self.assertEqual("docker", host_rule["hostType"])
self.assertEqual("docker.io", host_rule["matchHost"])
self.assertEqual("renovate-user", host_rule["username"])
self.assertEqual("test-token", host_rule["password"])
self.assertEqual(
{"docker.io", "index.docker.io", "registry-1.docker.io"},
{rule["matchHost"] for rule in host_rules},
)
for host_rule in host_rules:
self.assertEqual("docker", host_rule["hostType"])
self.assertEqual("renovate-user", host_rule["username"])
self.assertEqual("test-token", host_rule["password"])
def test_self_hosted_renovate_branches_are_recognized_by_workflows(self):
workflows = REPO_ROOT / ".github" / "workflows"
+24 -1
View File
@@ -13,14 +13,19 @@ concurrency:
group: renovate-full-scan
cancel-in-progress: true
permissions:
contents: read
jobs:
renovate:
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Restore Renovate cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: renovate-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: /tmp/renovate-cache
key: renovate-docker-v1-${{ runner.os }}-v43-${{ hashFiles('.github/renovate-docker.json', '.github/renovate-global.js') }}-${{ github.run_id }}
@@ -55,10 +60,28 @@ jobs:
RENOVATE_REPOSITORIES: ${{ github.repository }}
RENOVATE_REPOSITORY_CACHE: enabled
- name: Report Renovate cache size
id: cache-metrics
if: always()
run: |
save_cache=false
max_cache_bytes=536870912
if [[ -d /tmp/renovate-cache ]]; then
du -sh /tmp/renovate-cache
cache_bytes=$(du -sb /tmp/renovate-cache | cut -f1)
if (( cache_bytes <= max_cache_bytes )); then
save_cache=true
else
echo "::warning::Renovate cache is ${cache_bytes} bytes; skip saving because it exceeds ${max_cache_bytes} bytes."
fi
else
echo "Renovate cache directory was not created."
fi
echo "save=$save_cache" >> "$GITHUB_OUTPUT"
- name: Save Renovate cache
if: always() && steps.cache-metrics.outputs.save == 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: /tmp/renovate-cache
key: ${{ steps.renovate-cache.outputs.cache-primary-key }}