From 7ec13cbc1a5b970c4c7f6d13c39e7d2ae7314c95 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Sat, 19 Sep 2026 23:05:39 +0200 Subject: [PATCH 1/3] Pin fetched dependencies to commit hashes instead of git tags CMake fetches pugixml, SingleApplication and the three KDE Frameworks modules by git tag. A tag is a mutable pointer that its owner can move, so two builds of the same QElectroTech commit can silently get different third-party sources, and a compromised upstream account can change what every builder downloads without anything changing in this repository. Pinning each dependency to the commit its tag currently points at closes that, while keeping the tag name in a trailing comment so the intended version stays readable. No versions change. Every pinned commit is the one its tag resolves to, checked with git ls-remote and confirmed by fetching each one and verifying that git describe reports exactly the tag. The three KDE modules live in separate repositories and therefore need separate commits, so the single KF_GIT_TAG variable becomes three per-module variables; passing -DKF_GIT_TAG= still selects one ref for all three, unpinned, exactly as before, and KF_GIT_TAG stays defined so the build summary in define_definitions.cmake is unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- cmake/fetch_kdeaddons.cmake | 24 +++++++++++++++++++----- cmake/fetch_pugixml.cmake | 5 ++++- cmake/fetch_singleapplication.cmake | 4 +++- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/cmake/fetch_kdeaddons.cmake b/cmake/fetch_kdeaddons.cmake index 76df0a3f4..7ba66392c 100644 --- a/cmake/fetch_kdeaddons.cmake +++ b/cmake/fetch_kdeaddons.cmake @@ -23,8 +23,22 @@ if(BUILD_WITH_KF) if(BUILD_KF) - if(NOT DEFINED KF_GIT_TAG) - # this is a more or less random version, taken as an conservative approach + # v6.10.0 is a more or less random version, taken as an conservative + # approach. Pinned to the commits those tags point at, not to the tags + # themselves; see the note in fetch_pugixml.cmake. Each module lives in its + # own repository, so the same release is a different commit in each. + set(KF_ECM_GIT_COMMIT 7dd28cc56c339c3f8fb356f7c53c0e8f61433d81) # v6.10.0 + set(KF_KCOREADDONS_GIT_COMMIT c569f974dab24b4784ad186a3db4b76b2fa36612) # v6.10.0 + set(KF_KWIDGETSADDONS_GIT_COMMIT 1abbed8a280d6626c59fb197f2c4667d2b1e7445) # v6.10.0 + + if(DEFINED KF_GIT_TAG) + # Explicit override: -DKF_GIT_TAG= selects one ref for all three + # modules, unpinned, exactly as it did before. + set(KF_ECM_GIT_COMMIT ${KF_GIT_TAG}) + set(KF_KCOREADDONS_GIT_COMMIT ${KF_GIT_TAG}) + set(KF_KWIDGETSADDONS_GIT_COMMIT ${KF_GIT_TAG}) + else() + # Keep KF_GIT_TAG defined: define_definitions.cmake reports it. set(KF_GIT_TAG v6.10.0) endif() # using a function in order to limit the scope of the variables @@ -53,19 +67,19 @@ if(BUILD_WITH_KF) FetchContent_Declare( ecm GIT_REPOSITORY https://invent.kde.org/frameworks/extra-cmake-modules.git - GIT_TAG ${KF_GIT_TAG}) + GIT_TAG ${KF_ECM_GIT_COMMIT}) FetchContent_MakeAvailable(ecm) FetchContent_Declare( kcoreaddons GIT_REPOSITORY https://invent.kde.org/frameworks/kcoreaddons.git - GIT_TAG ${KF_GIT_TAG}) + GIT_TAG ${KF_KCOREADDONS_GIT_COMMIT}) FetchContent_MakeAvailable(kcoreaddons) FetchContent_Declare( kwidgetsaddons GIT_REPOSITORY https://invent.kde.org/frameworks/kwidgetsaddons.git - GIT_TAG ${KF_GIT_TAG}) + GIT_TAG ${KF_KWIDGETSADDONS_GIT_COMMIT}) FetchContent_MakeAvailable(kwidgetsaddons) endfunction() qet_make_kf_available() diff --git a/cmake/fetch_pugixml.cmake b/cmake/fetch_pugixml.cmake index 6aef219f7..11bf33872 100644 --- a/cmake/fetch_pugixml.cmake +++ b/cmake/fetch_pugixml.cmake @@ -22,10 +22,13 @@ option(BUILD_PUGIXML "Build pugixml library, use system one otherwise" YES) if(BUILD_PUGIXML) + # Pinned to the commit v1.15 points at, not to the tag itself: a tag is a + # mutable pointer that the upstream owner can move, so fetching by tag means + # a future build can silently get different code than this one did. FetchContent_Declare( pugixml GIT_REPOSITORY https://github.com/zeux/pugixml.git - GIT_TAG v1.15) + GIT_TAG ee86beb30e4973f5feffe3ce63bfa4fbadf72f38) # v1.15 set(PUGIXML_INSTALL OFF CACHE INTERNAL "") FetchContent_MakeAvailable(pugixml) else() diff --git a/cmake/fetch_singleapplication.cmake b/cmake/fetch_singleapplication.cmake index c54de59ef..8685eea43 100644 --- a/cmake/fetch_singleapplication.cmake +++ b/cmake/fetch_singleapplication.cmake @@ -31,9 +31,11 @@ if(EXISTS "${CMAKE_SOURCE_DIR}/SingleApplication/CMakeLists.txt") set(FETCHCONTENT_SOURCE_DIR_SINGLEAPPLICATION "${CMAKE_SOURCE_DIR}/SingleApplication") endif() +# Pinned to the commit v3.2.0 points at, not to the tag itself; see the note in +# fetch_pugixml.cmake. FetchContent_Declare( SingleApplication GIT_REPOSITORY https://github.com/itay-grudev/SingleApplication.git - GIT_TAG v3.2.0) + GIT_TAG aede311d28d20179216c5419b581087be2a8409f) # v3.2.0 set(QT_DEFAULT_MAJOR_VERSION 6) FetchContent_MakeAvailable(SingleApplication) From 3f397f5f7807b3dbdfbfb8cbc08a7c85a316c851 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Sat, 19 Sep 2026 23:36:20 +0200 Subject: [PATCH 2/3] Explain why fetching by git tag is a supply chain risk The pinning comment stated that a tag is mutable but not what an attacker does with that, so the trade-off was hard to judge for anyone reviewing or later undoing the pins. Spell out the mechanism: a tag is a name pointing at a commit, anyone with push access upstream can force-push it elsewhere, and FetchContent resolves it at build time, so a stolen maintainer account or CI token makes every fresh build compile the attacker's code while nothing changes here and the tag name still reads correctly. A commit hash is derived from the content and cannot be moved that way. Name the two cases where this was actually exploited: tj-actions/changed- files in March 2025 (CVE-2025-30066), where tags v1 through v45.0.7 were retargeted to a commit leaking CI secrets into build logs across more than 23,000 repositories, and aquasecurity/trivy-action in March 2026 (CVE-2026-33634), where 76 of 77 version tags were force-pushed to a credential stealer for about twelve hours. Both were GitHub Actions rather than CMake dependencies, which the comment says, because the point is the shared mechanism of resolving a tag at build time. Also document how to upgrade a pin, including that git ls-remote reports the tag object for an annotated tag and the commit on the "^{}" line. The note lives in fetch_pugixml.cmake, which fetch_kdeaddons.cmake and fetch_singleapplication.cmake already refer to. Comments only; no build behaviour changes. Co-Authored-By: Claude Opus 5 (1M context) --- cmake/fetch_pugixml.cmake | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/cmake/fetch_pugixml.cmake b/cmake/fetch_pugixml.cmake index 11bf33872..6461701bd 100644 --- a/cmake/fetch_pugixml.cmake +++ b/cmake/fetch_pugixml.cmake @@ -22,9 +22,32 @@ option(BUILD_PUGIXML "Build pugixml library, use system one otherwise" YES) if(BUILD_PUGIXML) - # Pinned to the commit v1.15 points at, not to the tag itself: a tag is a - # mutable pointer that the upstream owner can move, so fetching by tag means - # a future build can silently get different code than this one did. + # Pinned to the commit v1.15 points at, not to the tag itself. + # + # A git tag is only a named pointer to a commit, and anyone with push access + # to the upstream repository can move it (git push --force) to any other + # commit. FetchContent fetches whatever the tag points at when the build + # runs, so if a maintainer account or CI token is compromised, the attacker + # can retarget a well-known release tag to malicious code: every fresh build + # of QElectroTech then compiles it, while nothing changes in this repository + # and the tag name still looks correct. A commit hash cannot be moved, because + # it is derived from the content: different code always has a different hash. + # + # This attack has been used in the wild: + # - March 2025, tj-actions/changed-files (CVE-2025-30066): tags v1 through + # v45.0.7 were retargeted to a commit that dumped CI secrets into build + # logs, affecting more than 23,000 repositories. + # - March 2026, aquasecurity/trivy-action (CVE-2026-33634): 76 of 77 + # version tags were force-pushed to a credential stealer and stayed + # malicious for about 12 hours. + # Both were GitHub Actions rather than CMake dependencies, but the mechanism + # is the same one FetchContent relies on here: resolving a git tag at build + # time. + # + # To upgrade, look up the new tag's commit with git ls-remote + # (for an annotated tag, take the hash on the "^{}" line, which is the + # commit; the other line is the tag object), check that it is the release you + # expect, and update both the hash and the trailing tag comment. FetchContent_Declare( pugixml GIT_REPOSITORY https://github.com/zeux/pugixml.git From 2e27f77b2820ce09983e5045c1777eef6956391c Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Sun, 20 Sep 2026 14:21:36 +0200 Subject: [PATCH 3/3] Added more verbose description of the difference between lightweight and annotated tags as comment. --- cmake/fetch_kdeaddons.cmake | 13 +++++++++++-- cmake/fetch_pugixml.cmake | 21 ++++++++++++++++++--- cmake/fetch_singleapplication.cmake | 6 +++++- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/cmake/fetch_kdeaddons.cmake b/cmake/fetch_kdeaddons.cmake index 7ba66392c..561ed3550 100644 --- a/cmake/fetch_kdeaddons.cmake +++ b/cmake/fetch_kdeaddons.cmake @@ -24,9 +24,18 @@ if(BUILD_WITH_KF) if(BUILD_KF) # v6.10.0 is a more or less random version, taken as an conservative - # approach. Pinned to the commits those tags point at, not to the tags + # approach. Pinned to the commits v6.10.0 points at, not to the tags # themselves; see the note in fetch_pugixml.cmake. Each module lives in its - # own repository, so the same release is a different commit in each. + # own repository, so the same v6.10.0 release is a different commit in + # each. + # + # KDE uses annotated tags, so "git ls-remote 'refs/tags/v6.10.0*'" + # prints two hashes per module: refs/tags/v6.10.0 is the tag object (the + # tagger, the date and the tag message) and refs/tags/v6.10.0^{} is the + # commit that object points at. The hashes below are the "^{}" ones, i.e. + # the commits. Lightweight tags, such as pugixml's v1.15 and + # SingleApplication's v3.2.0, have no tag object and print only the + # commit line. set(KF_ECM_GIT_COMMIT 7dd28cc56c339c3f8fb356f7c53c0e8f61433d81) # v6.10.0 set(KF_KCOREADDONS_GIT_COMMIT c569f974dab24b4784ad186a3db4b76b2fa36612) # v6.10.0 set(KF_KWIDGETSADDONS_GIT_COMMIT 1abbed8a280d6626c59fb197f2c4667d2b1e7445) # v6.10.0 diff --git a/cmake/fetch_pugixml.cmake b/cmake/fetch_pugixml.cmake index 6461701bd..25f235570 100644 --- a/cmake/fetch_pugixml.cmake +++ b/cmake/fetch_pugixml.cmake @@ -44,10 +44,25 @@ if(BUILD_PUGIXML) # is the same one FetchContent relies on here: resolving a git tag at build # time. # - # To upgrade, look up the new tag's commit with git ls-remote - # (for an annotated tag, take the hash on the "^{}" line, which is the - # commit; the other line is the tag object), check that it is the release you + # To upgrade, look up the commit the new tag points at with + # git ls-remote 'refs/tags/*', check that it is the release you # expect, and update both the hash and the trailing tag comment. + # + # How many lines that prints depends on which of the two kinds of tag + # upstream created: + # - A lightweight tag is nothing but a ref pointing straight at the commit, + # so ls-remote prints a single line, "refs/tags/", and its hash is + # the commit to pin. pugixml tags this way, which is why the v1.15 hash + # below is what "git ls-remote ... refs/tags/v1.15" reports directly; + # SingleApplication (v3.2.0) does the same. + # - An annotated tag is a git object in its own right, carrying a tagger, + # a date, a message and optionally a GPG signature, and pointing at the + # commit. ls-remote then prints two lines: "refs/tags/" is the tag + # object and "refs/tags/^{}" is that object dereferenced, i.e. the + # commit. The KDE Frameworks modules tag this way, so for them it is the + # "^{}" hash that belongs in the pin; the other hash identifies the tag + # object itself, which is not the source revision and changes whenever + # upstream re-creates the tag, even over the very same commit. FetchContent_Declare( pugixml GIT_REPOSITORY https://github.com/zeux/pugixml.git diff --git a/cmake/fetch_singleapplication.cmake b/cmake/fetch_singleapplication.cmake index 8685eea43..6983dfc2b 100644 --- a/cmake/fetch_singleapplication.cmake +++ b/cmake/fetch_singleapplication.cmake @@ -32,7 +32,11 @@ if(EXISTS "${CMAKE_SOURCE_DIR}/SingleApplication/CMakeLists.txt") endif() # Pinned to the commit v3.2.0 points at, not to the tag itself; see the note in -# fetch_pugixml.cmake. +# fetch_pugixml.cmake. v3.2.0 is a lightweight tag, a ref pointing straight at +# the commit, so "git ls-remote refs/tags/v3.2.0" prints that commit and +# nothing else. An annotated tag, as KDE uses in fetch_kdeaddons.cmake, would +# print the tag object under refs/tags/v3.2.0 as well, with the commit on the +# refs/tags/v3.2.0^{} line. FetchContent_Declare( SingleApplication GIT_REPOSITORY https://github.com/itay-grudev/SingleApplication.git