From 3d5cb88841af91ebc226e9a7a20bc4d45827c5f3 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 7 Aug 2026 10:59:16 +0200 Subject: [PATCH 1/5] chore(gitignore): ignore local AUR key material MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le dépôt est public et la génération de la clé de déploiement AUR laisse aur_ci, aur_ci.pub et aur_known_hosts à la racine. La privée est détruite après enregistrement dans les secrets, mais rien n'empêchait un git add -A de la committer entre-temps. --- .gitignore | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.gitignore b/.gitignore index d77d3e72e..a1822ee05 100644 --- a/.gitignore +++ b/.gitignore @@ -124,3 +124,11 @@ workbench/runs/ # Fixture d'évaluation — enregistrement réel de l'utilisateur, jamais versionné workbench/fixtures/ + +# Artefacts de setup de la publication AUR (scripts/ + .github/workflows/aur-publish.yml). +# La clé privée ne vit que dans le secret GitHub AUR_SSH_PRIVATE_KEY ; si `aur_ci` +# réapparaît ici c'est une régénération locale, à détruire (`shred -u`) après usage. +# Le dépôt est public : rien de tout ça ne doit pouvoir partir dans un commit. +/aur_ci +/aur_ci.pub +/aur_known_hosts From b5297b4d416467bc3964a75f715824b380f87e8c Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 7 Aug 2026 10:59:16 +0200 Subject: [PATCH 2/5] fix(ci): pin winget-releaser to a commit SHA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un tag git est mutable et cette action tierce reçoit WINGET_ACC_TOKEN : un tag repointé exfiltrerait le token sans qu'aucun diff n'apparaisse ici. v2 resolves to 4ffc7888bffd451b357355dc214d43bb9f23917e. --- .github/workflows/publish-winget.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-winget.yml b/.github/workflows/publish-winget.yml index 12fd0404a..2657874ba 100644 --- a/.github/workflows/publish-winget.yml +++ b/.github/workflows/publish-winget.yml @@ -15,7 +15,11 @@ jobs: runs-on: windows-latest if: (github.event_name == 'workflow_dispatch' || !github.event.release.prerelease) && vars.WINGET_IDENTIFIER != '' steps: - - uses: vedantmgoyal9/winget-releaser@v2 + # Épinglé sur le SHA de v2 : un tag git est mutable, et cette action tierce + # reçoit WINGET_ACC_TOKEN. Un tag repointé suffirait à exfiltrer le token + # sans qu'aucun changement n'apparaisse ici. Pour bouger de version, + # re-résoudre le tag et remplacer le SHA explicitement. + - uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2 with: identifier: ${{ vars.WINGET_IDENTIFIER }} # Matches the Windows installer asset attached to each release, From ba273bffa92dc05650e0202ea76c1c994b8eba1a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 7 Aug 2026 10:59:17 +0200 Subject: [PATCH 3/5] fix(ci): harden the AUR publish pipeline and repair its makepkg install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le PKGBUILD publié sur l'AUR n'est pas versionné ici : il appartient au mainteneur du paquet et peut changer sans passer par une PR. Le workflow le clonait, le patchait et le repoussait sans jamais regarder ce qu'il contenait. - makepkg *source* le PKGBUILD ; il s'exécutait donc dans un runner qui détenait déjà la clé de déploiement. Clone en HTTPS, clé écrite seulement après validation. - Audit du préambule, allowlist des sources sur notre dépôt, et refus de pousser un diff qui déborde de pkgver/pkgrel/sha256sums. - Le tag de workflow_dispatch est du texte libre et finissait dans une expression sed : v1.0|e id| donnait une exécution de shell. Regex stricte, qui bloque aussi la publication d'une RC (pkgver interdit le tiret). - Un seul checksum était réécrit ; celui du LICENSE, épinglé sur le tag, périmait en silence et cassait makepkg chez les utilisateurs. - L'étape d'installation ne pouvait pas aboutir : pacman-package-manager ne fournit pas makepkg mais s'installe sans erreur, rendant le repli mort. makepkg seul ne suffit pas non plus, il résout pacman par type -P. Jamais détecté car le job n'avait jamais dépassé son if: — vars.AUR_PACKAGE_NAME n'existait pas, les 10 dernières releases sont skipped. --- .github/workflows/aur-publish.yml | 262 +++++++++++++++++++++++++----- 1 file changed, 217 insertions(+), 45 deletions(-) diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml index 03a1b3c51..f378dc03b 100644 --- a/.github/workflows/aur-publish.yml +++ b/.github/workflows/aur-publish.yml @@ -13,12 +13,19 @@ on: permissions: contents: read +env: + # Toute source déclarée par le PKGBUILD doit vivre sous ce préfixe. + # Le PKGBUILD est hébergé sur l'AUR, pas ici : son mainteneur peut le modifier + # à tout moment sans passer par une PR. Cette allowlist est le seul point où + # l'on constate qu'une source a été substituée avant de republier. + ALLOWED_SOURCE_PREFIX: "https://github.com/getopenscreen/openscreen/" + jobs: publish: runs-on: ubuntu-latest if: (github.event_name == 'workflow_dispatch' || !github.event.release.prerelease) && vars.AUR_PACKAGE_NAME != '' steps: - - name: Resolve tag and version + - name: Resolve and validate tag id: meta env: GH_EVENT_TAG: ${{ github.event.release.tag_name }} @@ -30,6 +37,18 @@ jobs: echo "::error::No tag resolved from release event or workflow input" exit 1 fi + # Borne stricte, pour deux raisons distinctes : + # 1. Le tag finit dans des URLs et des réécritures de PKGBUILD. + # `workflow_dispatch` accepte du texte libre — aucune règle de ref + # git ne s'y applique — donc une valeur comme `v1.0|e id|` sortait + # de l'expression `sed` et exécutait du shell dans le runner. + # 2. `pkgver` interdit le tiret côté Arch. Le filtre `prerelease` ne + # couvre que l'événement `release` : un dispatch manuel sur un tag + # `-rc.N` produisait un paquet invalide publié aux utilisateurs. + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Refusing tag '$TAG' — expected a stable vMAJOR.MINOR.PATCH tag" + exit 1 + fi VERSION="${TAG#v}" echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" @@ -66,91 +85,244 @@ jobs: echo "name=$PACMAN_NAME" >> "$GITHUB_OUTPUT" echo "Found pacman asset: $PACMAN_NAME" - - name: Download and compute sha256 + # Clone en HTTPS et non en SSH, délibérément. Tout ce qui suit manipule un + # PKGBUILD tiers, et `makepkg` le *source* (c'est un script bash) pour en + # extraire les variables. La clé de déploiement n'est donc écrite sur le + # disque qu'à la toute dernière étape, une fois le contenu validé : du code + # non revu ne s'exécute jamais dans un runner qui la détient. + - name: Clone AUR repository (read-only, no deploy key on disk) if: steps.aur_secret.outputs.configured == 'true' - id: sha env: - REPO: ${{ github.repository }} - TAG: ${{ steps.meta.outputs.tag }} - ASSET: ${{ steps.asset.outputs.name }} + PACKAGE: ${{ vars.AUR_PACKAGE_NAME }} run: | set -euo pipefail - BASE="https://github.com/${REPO}/releases/download/${TAG}" - curl -fsSL --retry 3 -o /tmp/pkg.pacman "${BASE}/${ASSET}" - PKG_SHA=$(sha256sum /tmp/pkg.pacman | awk '{print $1}') - echo "sha256=$PKG_SHA" >> "$GITHUB_OUTPUT" + git clone "https://aur.archlinux.org/${PACKAGE}.git" aur-repo - - name: Setup SSH for AUR + # Garde-fou principal. `makepkg --printsrcinfo` exécute le code de premier + # niveau du PKGBUILD ; les corps de fonctions sont seulement définis, pas + # appelés. On refuse donc de lancer makepkg sur un fichier dont le préambule + # contient de quoi exécuter quoi que ce soit. + - name: Audit PKGBUILD before executing it if: steps.aur_secret.outputs.configured == 'true' + working-directory: aur-repo env: - AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} - AUR_KNOWN_HOSTS: ${{ vars.AUR_KNOWN_HOSTS }} + PACKAGE: ${{ vars.AUR_PACKAGE_NAME }} run: | set -euo pipefail - if [[ -z "$AUR_KNOWN_HOSTS" ]]; then - echo "::error::AUR_KNOWN_HOSTS variable is required for secure AUR SSH" + + if [[ ! -f PKGBUILD ]]; then + echo "::error::No PKGBUILD in the AUR repository" exit 1 fi - mkdir -p ~/.ssh - echo "$AUR_SSH_PRIVATE_KEY" > ~/.ssh/aur_key - chmod 600 ~/.ssh/aur_key - printf '%s\n' "$AUR_KNOWN_HOSTS" > ~/.ssh/aur_known_hosts - cat >> ~/.ssh/config <<'SSHCONF' - Host aur.archlinux.org - HostName aur.archlinux.org - User aur - IdentityFile ~/.ssh/aur_key - StrictHostKeyChecking yes - UserKnownHostsFile ~/.ssh/aur_known_hosts - SSHCONF - - name: Clone AUR repository + # Le préambule = tout ce qui précède la première définition de fonction. + PREAMBLE=$(awk '/^[a-zA-Z_]+\(\)[[:space:]]*\{/{exit} {print}' PKGBUILD) + + FAILED=0 + while IFS= read -r pattern; do + if grep -qE "$pattern" <<<"$PREAMBLE"; then + echo "::error::PKGBUILD preamble contains a code-execution construct: /$pattern/" + FAILED=1 + fi + done <<'PATTERNS' + \$\( + ` + (^|[;&|[:space:]])(eval|source|curl|wget|bash|sh|python3?)[[:space:]] + PATTERNS + + if [[ "$(grep -cE '^pkgname=' PKGBUILD)" != "1" ]]; then + echo "::error::Expected exactly one pkgname= declaration" + FAILED=1 + fi + if ! grep -qE "^pkgname=${PACKAGE}\$" PKGBUILD; then + echo "::error::pkgname does not match AUR_PACKAGE_NAME (${PACKAGE})" + FAILED=1 + fi + + # Les deux réécritures qui suivent travaillent ligne à ligne. Un tableau + # étalé sur plusieurs lignes — formatage parfaitement légal en amont — + # laisserait des lignes orphelines et produirait un PKGBUILD invalide. + # makepkg finirait par le refuser en le sourçant, mais le diff, lui, ne + # verrait rien : les lignes orphelines sont inchangées. Autant refuser + # ici, avec un message qui dit quoi regarder. + if [[ "$(grep -cE '^sha256sums=' PKGBUILD)" != "1" ]]; then + echo "::error::Expected exactly one sha256sums= line" + FAILED=1 + elif ! grep -E '^sha256sums=' PKGBUILD | grep -q ')'; then + echo "::error::sha256sums= spans several lines; this workflow only rewrites a single-line array" + FAILED=1 + fi + + if [[ "$(grep -cE '^source=\(' PKGBUILD)" != "1" ]]; then + echo "::error::Expected exactly one source=( declaration" + FAILED=1 + elif ! awk '/^source=\(/{f=1} f&&/\)[[:space:]]*$/{found=1; exit} END{exit !found}' PKGBUILD; then + echo "::error::Could not find the closing paren of source=()" + FAILED=1 + fi + + if [[ "$FAILED" != "0" ]]; then + echo "::error::Refusing to run makepkg on this PKGBUILD. Review it by hand." + exit 1 + fi + echo "PKGBUILD preamble clean." + + - name: Bump version and recompute every checksum if: steps.aur_secret.outputs.configured == 'true' + working-directory: aur-repo env: - PACKAGE: ${{ vars.AUR_PACKAGE_NAME }} + VERSION: ${{ steps.meta.outputs.version }} + ASSET: ${{ steps.asset.outputs.name }} run: | set -euo pipefail - git clone "ssh://aur@aur.archlinux.org/${PACKAGE}.git" aur-repo + + # awk plutôt que `sed -i "s|...|${VERSION}|"` : la valeur est passée par + # -v et n'est jamais reparsée comme partie de l'expression. + awk -v v="$VERSION" ' + /^pkgver=/ { print "pkgver=" v; next } + /^pkgrel=/ { print "pkgrel=1"; next } + { print } + ' PKGBUILD > PKGBUILD.new && mv PKGBUILD.new PKGBUILD + + # Les URLs du bloc source=(), une fois ${pkgver} résolu. On les + # retélécharge toutes pour recalculer *tous* les checksums : l'ancienne + # version n'en réécrivait qu'un seul, celui du .pacman. Le LICENSE est + # pourtant épinglé sur `raw/v${pkgver}/LICENSE`, donc sa somme devient + # fausse dès que ce fichier change — et makepkg échoue alors chez tous + # les utilisateurs, pas chez nous. + SRC_BLOCK=$(awk '/^source=\(/{f=1} f{print} f&&/\)[[:space:]]*$/{exit}' PKGBUILD) + mapfile -t URLS < <(grep -oE 'https?://[^"'"'"'[:space:]]+' <<<"$SRC_BLOCK" \ + | sed -e "s/\\\${pkgver}/${VERSION}/g" -e "s/\\\$pkgver/${VERSION}/g") + + if [[ "${#URLS[@]}" -eq 0 ]]; then + echo "::error::Could not parse any source URL from the PKGBUILD" + exit 1 + fi + + SUMS=() + for url in "${URLS[@]}"; do + # Allowlist : une source pointant ailleurs que sur notre dépôt signifie + # que le PKGBUILD distribue autre chose que ce que nous publions. + if [[ "$url" != "${ALLOWED_SOURCE_PREFIX}"* ]]; then + echo "::error::Source URL outside the allowlist: $url" + echo "::error::Expected everything under ${ALLOWED_SOURCE_PREFIX}" + exit 1 + fi + echo "Fetching $url" + curl -fsSL --retry 3 -o /tmp/src.bin "$url" + SUMS+=("$(sha256sum /tmp/src.bin | awk '{print $1}')") + done + + # Le .pacman référencé par le PKGBUILD doit être exactement l'asset + # trouvé sur la release. Sinon le paquet sert un binaire que cette + # release n'a pas produit. + if ! printf '%s\n' "${URLS[@]}" | grep -qE "/${ASSET}\$"; then + echo "::error::PKGBUILD does not reference the release asset ${ASSET}" + printf ' source: %s\n' "${URLS[@]}" + exit 1 + fi + + NEW_SUMS="sha256sums=($(printf "'%s' " "${SUMS[@]}" | sed 's/ $//'))" + awk -v line="$NEW_SUMS" '/^sha256sums=/ { print line; next } { print }' \ + PKGBUILD > PKGBUILD.new && mv PKGBUILD.new PKGBUILD + echo "Recomputed ${#SUMS[@]} checksum(s)." - name: Install makepkg if: steps.aur_secret.outputs.configured == 'true' run: | set -euo pipefail + # Les DEUX paquets, et c'est subtil : + # - `pacman-package-manager` ne livre que pacman, pacman-conf, + # pacman-db-upgrade, pacman-key et repo-add — jamais makepkg. Il + # s'installait pourtant sans erreur, donc le `|| apt-get install + # makepkg` qui suivait était du code mort et l'étape mourait + # systématiquement sur « makepkg still missing after install ». + # - `makepkg` seul ne suffit pas non plus : il dépend de libalpm mais + # pas du binaire `pacman`, qu'il résout par `type -P pacman` au + # démarrage. Sans lui il sort sur « An unknown error has occurred ». + # Vérifié en local sur noble, l'image d'ubuntu-latest. sudo apt-get update -qq - sudo apt-get install -y -qq pacman-package-manager 2>/dev/null || \ - sudo apt-get install -y -qq makepkg 2>/dev/null || { - echo "::error::Unable to install makepkg. Install pacman-package-manager or makepkg." - exit 1 - } + sudo apt-get install -y -qq makepkg pacman-package-manager command -v makepkg >/dev/null || { echo "::error::makepkg still missing after install." exit 1 } + # Sans tube : `makepkg --version | head -1` prend un SIGPIPE que + # `pipefail` remonte en échec d'étape. + makepkg --version - - name: Update PKGBUILD and .SRCINFO + - name: Regenerate .SRCINFO if: steps.aur_secret.outputs.configured == 'true' working-directory: aur-repo - env: - VERSION: ${{ steps.meta.outputs.version }} - SHA256: ${{ steps.sha.outputs.sha256 }} - ASSET: ${{ steps.asset.outputs.name }} - REPO: ${{ github.repository }} - TAG: ${{ steps.meta.outputs.tag }} run: | set -euo pipefail - sed -i -E "s|^pkgver=.*|pkgver=${VERSION}|" PKGBUILD - sed -i -E "s|^pkgrel=.*|pkgrel=1|" PKGBUILD - sed -i -E "s|^sha256sums=\('[^']*'|sha256sums=('${SHA256}'|" PKGBUILD makepkg --printsrcinfo > .SRCINFO echo "Updated .SRCINFO" + # Dernière barrière avant le push : on republie sous notre automatisation, + # donc on vérifie que le commit ne porte que le bump attendu. Toute autre + # ligne touchée (package(), depends, source, install…) veut dire que le + # PKGBUILD a changé en amont et mérite un œil humain avant publication. + - name: Verify the diff contains only the expected bump + if: steps.aur_secret.outputs.configured == 'true' + working-directory: aur-repo + run: | + set -euo pipefail + + UNEXPECTED=$(git diff --unified=0 -- PKGBUILD \ + | grep -E '^[+-]' \ + | grep -vE '^(\+\+\+|---)' \ + | grep -vE '^[+-](pkgver=|pkgrel=|sha256sums=)' || true) + + { + echo "### AUR PKGBUILD diff" + echo '```diff' + git diff -- PKGBUILD .SRCINFO || true + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + if [[ -n "$UNEXPECTED" ]]; then + echo "::error::PKGBUILD changed beyond pkgver/pkgrel/sha256sums:" + echo "$UNEXPECTED" + echo "::error::Refusing to publish. Review the upstream PKGBUILD by hand." + exit 1 + fi + echo "Diff limited to the expected bump." + + # La clé n'apparaît sur le disque qu'ici, une fois tout le contenu validé. + - name: Setup SSH for AUR + if: steps.aur_secret.outputs.configured == 'true' + env: + AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + AUR_KNOWN_HOSTS: ${{ vars.AUR_KNOWN_HOSTS }} + run: | + set -euo pipefail + if [[ -z "$AUR_KNOWN_HOSTS" ]]; then + echo "::error::AUR_KNOWN_HOSTS variable is required for secure AUR SSH" + exit 1 + fi + mkdir -p ~/.ssh + echo "$AUR_SSH_PRIVATE_KEY" > ~/.ssh/aur_key + chmod 600 ~/.ssh/aur_key + printf '%s\n' "$AUR_KNOWN_HOSTS" > ~/.ssh/aur_known_hosts + cat >> ~/.ssh/config <<'SSHCONF' + Host aur.archlinux.org + HostName aur.archlinux.org + User aur + IdentityFile ~/.ssh/aur_key + StrictHostKeyChecking yes + UserKnownHostsFile ~/.ssh/aur_known_hosts + SSHCONF + - name: Commit and push if: steps.aur_secret.outputs.configured == 'true' working-directory: aur-repo env: VERSION: ${{ steps.meta.outputs.version }} + PACKAGE: ${{ vars.AUR_PACKAGE_NAME }} run: | set -euo pipefail + git remote set-url --push origin "ssh://aur@aur.archlinux.org/${PACKAGE}.git" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add PKGBUILD .SRCINFO From 5f13d8d78ed0ff20a6108cd9bab9dc5cf161f26b Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 7 Aug 2026 11:10:17 +0200 Subject: [PATCH 4/5] feat(ci): add a dry_run input to the AUR publish workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permet d'exercer tout le pipeline sur un vrai runner — install de makepkg, clone, audit, bump, .SRCINFO, contrôle de diff — en s'arrêtant avant le push. La clé de déploiement n'est alors jamais écrite sur le disque. Nécessaire pour valider ce workflow avant sa première publication réelle : il n'a jamais dépassé son if: en dix releases. --- .github/workflows/aur-publish.yml | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml index f378dc03b..533056a26 100644 --- a/.github/workflows/aur-publish.yml +++ b/.github/workflows/aur-publish.yml @@ -9,6 +9,11 @@ on: description: "Release tag to publish (e.g. v1.5.0)" required: true type: string + dry_run: + description: "Exécuter tout le pipeline mais s'arrêter avant le push AUR" + required: false + default: false + type: boolean permissions: contents: read @@ -289,9 +294,23 @@ jobs: fi echo "Diff limited to the expected bump." + - name: Dry run — stop before touching the AUR + if: steps.aur_secret.outputs.configured == 'true' && inputs.dry_run + working-directory: aur-repo + run: | + set -euo pipefail + echo "Mode dry_run : tout a été validé, rien ne sera poussé." + echo "Ce qui aurait été committé :" + git --no-pager diff --stat -- PKGBUILD .SRCINFO + { + echo "### Dry run — aucun push" + echo "Le pipeline est allé jusqu'au bout des validations." + echo "La clé de déploiement n'a pas été écrite sur le disque." + } >> "$GITHUB_STEP_SUMMARY" + # La clé n'apparaît sur le disque qu'ici, une fois tout le contenu validé. - name: Setup SSH for AUR - if: steps.aur_secret.outputs.configured == 'true' + if: steps.aur_secret.outputs.configured == 'true' && !inputs.dry_run env: AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} AUR_KNOWN_HOSTS: ${{ vars.AUR_KNOWN_HOSTS }} @@ -315,7 +334,7 @@ jobs: SSHCONF - name: Commit and push - if: steps.aur_secret.outputs.configured == 'true' + if: steps.aur_secret.outputs.configured == 'true' && !inputs.dry_run working-directory: aur-repo env: VERSION: ${{ steps.meta.outputs.version }} From cecc90ad928c54beabf9f305801f17d21f0ead29 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 7 Aug 2026 11:52:20 +0200 Subject: [PATCH 5/5] fix(ci): close three gaps found in CodeRabbit review of the AUR audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sha256sums pouvait devenir plus court que source=(). Une entrée locale et légitime (.install, .desktop) ne produit aucune URL ; on écrivait alors un tableau désaligné que makepkg génère sans broncher et que le contrôle de diff laisse passer, pour finir en échec de vérification chez chaque utilisateur. On compte les entrées et on refuse un formatage non reconnu. - Le test de préfixe de l'allowlist ne contraignait pas l'origine réellement contactée : curl normalise les segments .. avant la requête, donc .../getopenscreen/openscreen/../../attacker/repo/x passait et allait chercher le dépôt d'un tiers. Reproduit — l'URL effective devient bien github.com/attacker/repo. Segments .., pourcent, arobase et antislash refusés, et l'hôte d'arrivée est vérifié après redirection. - La liste de motifs du préambule ignorait la substitution de processus. 23 assertions passent, dont 15 négatives. --- .github/workflows/aur-publish.yml | 39 ++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml index 533056a26..8ee64a792 100644 --- a/.github/workflows/aur-publish.yml +++ b/.github/workflows/aur-publish.yml @@ -132,6 +132,7 @@ jobs: done <<'PATTERNS' \$\( ` + [<>]\( (^|[;&|[:space:]])(eval|source|curl|wget|bash|sh|python3?)[[:space:]] PATTERNS @@ -204,6 +205,23 @@ jobs: exit 1 fi + # sha256sums doit rester aligné sur source=(), élément par élément. Une + # entrée locale et légitime (un .install, un .desktop) ne produit aucune + # URL : on écrirait alors un tableau plus court, que makepkg accepte de + # générer et que le contrôle de diff laisse passer — pour finir en échec + # de vérification chez chaque utilisateur. On compte les entrées, et on + # refuse tout formatage qu'on ne sait pas compter plutôt que de deviner. + ENTRIES=$(grep -oE '"[^"]*"' <<<"$SRC_BLOCK" | wc -l) + RESIDUE=$(sed -e 's/"[^"]*"//g' <<<"$SRC_BLOCK" | tr -d '[:space:]') + if [[ "$RESIDUE" != "source=()" ]]; then + echo "::error::source=() holds unquoted entries; refusing to guess its shape" + exit 1 + fi + if [[ "${#URLS[@]}" -ne "$ENTRIES" ]]; then + echo "::error::source=() holds ${ENTRIES} entries but ${#URLS[@]} are URLs; sha256sums would not align" + exit 1 + fi + SUMS=() for url in "${URLS[@]}"; do # Allowlist : une source pointant ailleurs que sur notre dépôt signifie @@ -213,8 +231,27 @@ jobs: echo "::error::Expected everything under ${ALLOWED_SOURCE_PREFIX}" exit 1 fi + # Le test de préfixe seul ne suffit pas : curl normalise les segments + # `..` AVANT d'émettre la requête, donc + # .../getopenscreen/openscreen/../../attacker/repo/x passe le préfixe + # et va chercher le dépôt d'un tiers. Vérifié : l'URL effective + # devient bien https://github.com/attacker/repo/x. Le pourcent est + # refusé au passage, %2e%2e n'étant normalisé que côté serveur. + if [[ "$url" == *".."* || "$url" == *"%"* || "$url" == *"@"* || "$url" == *'\'* ]]; then + echo "::error::Source URL contains path traversal, encoding or userinfo: $url" + exit 1 + fi echo "Fetching $url" - curl -fsSL --retry 3 -o /tmp/src.bin "$url" + # -L reste nécessaire (les assets de release redirigent vers le CDN), + # donc on contrôle l'hôte d'arrivée plutôt que d'interdire le saut. + EFFECTIVE=$(curl -fsSL --retry 3 --proto '=https' \ + -w '%{url_effective}' -o /tmp/src.bin "$url") + EFF_HOST=${EFFECTIVE#https://} + EFF_HOST=${EFF_HOST%%/*} + if [[ "$EFF_HOST" != "github.com" && "$EFF_HOST" != *".githubusercontent.com" ]]; then + echo "::error::Download redirected off GitHub: $EFFECTIVE" + exit 1 + fi SUMS+=("$(sha256sum /tmp/src.bin | awk '{print $1}')") done