diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1ad6b31 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + # Mantiene al día los SHA de las actions fijadas en los workflows. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + # Misma política que tools:update: no se adoptan releases de menos de 7 días. + cooldown: + default-days: 7 + + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 diff --git a/.github/workflows/code-report.yml b/.github/workflows/code-report.yml index 6022a24..937fb68 100644 --- a/.github/workflows/code-report.yml +++ b/.github/workflows/code-report.yml @@ -13,10 +13,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 20 cache: npm @@ -47,11 +47,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: getgitops/cli-github-action@v1 + - uses: getgitops/cli-github-action@9051a668ce26ef933b26b5b9a07cfca77f3f7f3c # v1.0.0 with: command: creport scan + version: 1.2.1 api-key: ${{ secrets.GITOPS_API_KEY }} service: gitops-cli project: gitops diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 152ac95..cea4101 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,10 +19,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 20 registry-url: https://registry.npmjs.org diff --git a/.github/workflows/update-tools.yml b/.github/workflows/update-tools.yml new file mode 100644 index 0000000..91a4d2e --- /dev/null +++ b/.github/workflows/update-tools.yml @@ -0,0 +1,77 @@ +name: Update scanners + +# Revisa cada semana si hay versiones nuevas de Trivy, Syft y Gitleaks con al +# menos 7 días y abre una PR con src/utils/tool-versions.ts regenerado. Las +# firmas y checksums se verifican igual que en local (npm run tools:update). + +on: + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: update-tools + cancel-in-progress: false + +jobs: + update: + runs-on: ubuntu-latest + env: + BRANCH: chore/update-scanners + steps: + - name: Check out repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install cosign + uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1 + with: + cosign-release: v3.1.3 + + - name: Update pinned versions + env: + GITHUB_TOKEN: ${{ github.token }} + run: npm run --silent tools:update -- --latest | tee "$RUNNER_TEMP/summary.txt" + + - name: Open pull request + env: + GH_TOKEN: ${{ github.token }} + run: | + if git diff --quiet; then + echo "Sin cambios." + exit 0 + fi + + { + echo "Versiones nuevas de los scanners (publicadas hace al menos 7 días, firmas y checksums verificados):" + echo + grep '^- ' "$RUNNER_TEMP/summary.txt" + echo + echo "Antes de mergear revisa las release notes. Después publica una nueva versión de la CLI." + } > "$RUNNER_TEMP/body.md" + + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git checkout -B "$BRANCH" + git commit -am 'chore: update pinned scanners' + git push --force origin "$BRANCH" + + if gh pr view "$BRANCH" --json state --jq .state 2>/dev/null | grep -q OPEN; then + gh pr edit "$BRANCH" --body-file "$RUNNER_TEMP/body.md" + else + gh pr create --base main --head "$BRANCH" \ + --title 'chore: update pinned scanners' \ + --body-file "$RUNNER_TEMP/body.md" + fi diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..cffe8cd --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +save-exact=true diff --git a/README.md b/README.md index 6f129f8..cf53e1e 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,9 @@ ref) automatically. The `--git-*` flags take precedence. `gops creport scan` downloads Trivy, Syft and Gitleaks on first use into `~/.gitops-cli/tools///`. Versions are pinned in `src/utils/tool-versions.ts`, together with the SHA-256 of every release -archive; a download whose checksum does not match is rejected. +archive and of the binary it contains; a download whose checksum does not match +is rejected. Installed binaries are verified again on every use, so a tampered +tools directory (for example, a poisoned CI cache) is detected and reinstalled. Run `gops tools install` to download them ahead of time (for example, when building a Docker image). `GOPS_TOOLS_DIR` changes the install directory. @@ -94,10 +96,23 @@ That file is generated, do not edit it by hand. To review and bump versions ```bash npm run tools:update # pinned vs. available versions npm run tools:update -- trivy=0.75.0 syft=1.54.0 # pin new versions +npm run tools:update -- --latest # pin the newest eligible version of each ``` +The `Update scanners` workflow runs `--latest` every Monday and opens a pull +request (`chore/update-scanners`) when there is a newer eligible version. + +Trivy still refreshes its vulnerability database on every scan (it is data, not +code), always from the official repositories: the CLI passes them as flags, so a +`trivy.yaml` in the scanned repository cannot redirect it. Update checks and +telemetry of the scanners are disabled. + Releases younger than 7 days are refused by default (`--allow-recent` overrides -it) to leave time for a compromised release to be detected. Review the upstream +it) to leave time for a compromised release to be detected. The script needs +[cosign](https://docs.sigstore.dev/cosign/system_config/installation/) in the +`PATH`: the `checksums.txt` of Trivy and Syft is only accepted if it is signed +by their release workflow. Gitleaks does not sign its releases, so it is only +checked against its `checksums.txt` and the GitHub asset digest. Review the upstream release notes, commit the regenerated file and publish a new CLI version. ## Releases diff --git a/package-lock.json b/package-lock.json index 2342414..913af11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,15 +8,15 @@ "name": "@getgitops/cli", "version": "1.2.1", "dependencies": { - "commander": "^12.0.0" + "commander": "12.1.0" }, "bin": { "gops": "dist/index.js" }, "devDependencies": { - "@types/node": "^20.0.0", - "tsx": "^4.7.0", - "typescript": "^5.0.0" + "@types/node": "20.19.43", + "tsx": "4.23.12", + "typescript": "5.9.3" }, "engines": { "node": ">=20" diff --git a/package.json b/package.json index 9c3f1ca..266b403 100644 --- a/package.json +++ b/package.json @@ -31,11 +31,11 @@ "access": "public" }, "dependencies": { - "commander": "^12.0.0" + "commander": "12.1.0" }, "devDependencies": { - "@types/node": "^20.0.0", - "tsx": "^4.7.0", - "typescript": "^5.0.0" + "@types/node": "20.19.43", + "tsx": "4.23.12", + "typescript": "5.9.3" } } diff --git a/scripts/update-tools.ts b/scripts/update-tools.ts index 0296819..dfc35fc 100644 --- a/scripts/update-tools.ts +++ b/scripts/update-tools.ts @@ -1,29 +1,50 @@ /** * Regenera src/utils/tool-versions.ts con las versiones fijadas de las - * herramientas externas (trivy, syft, gitleaks) y el SHA-256 de cada archivo. + * herramientas externas (trivy, syft, gitleaks), el SHA-256 de cada archivo y + * el SHA-256 del binario que contiene. * * Uso: * npm run tools:update # muestra fijadas vs. disponibles * npm run tools:update -- trivy=0.75.0 syft=1.54.0 # fija nuevas versiones * npm run tools:update -- trivy=0.75.0 --allow-recent # ignora la antigüedad mínima + * npm run tools:update -- --latest # fija la última versión elegible de cada una * * Por defecto se rechazan releases con menos de MIN_RELEASE_AGE_DAYS días para * dar margen a que se detecte una release comprometida antes de adoptarla. + * Requiere cosign en el PATH para verificar la firma del checksums.txt de las + * herramientas que lo firman (trivy y syft). * Define GITHUB_TOKEN para evitar el rate limit de la API de GitHub. */ -import { writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { sha256File } from '../src/utils/installs'; import type { PinnedTool, ToolName } from '../src/utils/tool-versions'; const MIN_RELEASE_AGE_DAYS = 7; const OUTPUT_FILE = join(__dirname, '..', 'src', 'utils', 'tool-versions.ts'); +const GITHUB_OIDC_ISSUER = 'https://token.actions.githubusercontent.com'; type Platform = 'darwin-arm64' | 'darwin-x64' | 'linux-arm64' | 'linux-x64'; +// Firma keyless (sigstore) del checksums.txt. `identity` es el workflow de +// GitHub Actions que firma la release; cualquier otra identidad se rechaza. +// Se usa el bundle si la release lo publica y, si no, el formato antiguo de +// certificado + firma (p. ej. syft < 1.54.0). +interface Signature { + identity: (version: string) => string; + bundle: (version: string) => string; + certificate?: (version: string) => string; + signature?: (version: string) => string; +} + interface ToolSpec { repo: string; archive: (version: string, platformSuffix: string) => string; checksums: (version: string) => string; + // null: la herramienta no publica firmas y solo se verifica contra checksums.txt. + signature: Signature | null; platforms: Record; } @@ -32,6 +53,11 @@ const SPECS: Record = { repo: 'aquasecurity/trivy', archive: (v, p) => `trivy_${v}_${p}.tar.gz`, checksums: (v) => `trivy_${v}_checksums.txt`, + signature: { + bundle: (v) => `trivy_${v}_checksums.txt.sigstore.json`, + identity: (v) => + `https://github.com/aquasecurity/trivy/.github/workflows/reusable-release.yaml@refs/tags/v${v}`, + }, platforms: { 'darwin-arm64': 'macOS-ARM64', 'darwin-x64': 'macOS-64bit', @@ -43,6 +69,12 @@ const SPECS: Record = { repo: 'anchore/syft', archive: (v, p) => `syft_${v}_${p}.tar.gz`, checksums: (v) => `syft_${v}_checksums.txt`, + signature: { + bundle: (v) => `syft_${v}_checksums.txt.sigstore.json`, + certificate: (v) => `syft_${v}_checksums.txt.pem`, + signature: (v) => `syft_${v}_checksums.txt.sig`, + identity: () => 'https://github.com/anchore/syft/.github/workflows/release.yaml@refs/heads/main', + }, platforms: { 'darwin-arm64': 'darwin_arm64', 'darwin-x64': 'darwin_amd64', @@ -54,6 +86,7 @@ const SPECS: Record = { repo: 'gitleaks/gitleaks', archive: (v, p) => `gitleaks_${v}_${p}.tar.gz`, checksums: (v) => `gitleaks_${v}_checksums.txt`, + signature: null, platforms: { 'darwin-arm64': 'darwin_arm64', 'darwin-x64': 'darwin_x64', @@ -65,12 +98,18 @@ const SPECS: Record = { const TOOL_NAMES = Object.keys(SPECS) as ToolName[]; +interface GithubAsset { + name: string; + digest?: string | null; + browser_download_url: string; +} + interface GithubRelease { tag_name: string; published_at: string; draft: boolean; prerelease: boolean; - assets: { name: string; digest?: string | null; browser_download_url: string }[]; + assets: GithubAsset[]; } async function github(path: string): Promise { @@ -85,6 +124,65 @@ async function github(path: string): Promise { return (await res.json()) as T; } +async function download(release: GithubRelease, name: string, dir: string): Promise { + const asset = release.assets.find((a) => a.name === name); + if (!asset) { + throw new Error(`${release.tag_name}: no se encontró ${name}`); + } + const res = await fetch(asset.browser_download_url); + if (!res.ok) { + throw new Error(`${release.tag_name}: no se pudo descargar ${name} (${res.status})`); + } + const path = join(dir, name); + writeFileSync(path, Buffer.from(await res.arrayBuffer())); + return path; +} + +async function verifySignature( + name: ToolName, + version: string, + release: GithubRelease, + checksumsPath: string, + dir: string, +): Promise { + const signature = SPECS[name].signature; + if (!signature) { + console.warn(`⚠ ${name} no publica firmas: solo se verifica contra checksums.txt y el digest de GitHub`); + return; + } + + const hasAsset = (file?: string) => !!file && release.assets.some((a) => a.name === file); + const certificate = signature.certificate?.(version); + const sig = signature.signature?.(version); + + const args = ['verify-blob']; + if (hasAsset(signature.bundle(version))) { + args.push('--bundle', await download(release, signature.bundle(version), dir)); + } else if (certificate && sig && hasAsset(certificate) && hasAsset(sig)) { + args.push( + '--certificate', await download(release, certificate, dir), + '--signature', await download(release, sig, dir), + ); + } else { + throw new Error(`${name} ${release.tag_name}: no se encontró la firma de checksums.txt`); + } + args.push( + '--certificate-identity', signature.identity(version), + '--certificate-oidc-issuer', GITHUB_OIDC_ISSUER, + checksumsPath, + ); + + try { + execFileSync('cosign', args, { stdio: 'pipe' }); + } catch (error) { + const err = error as NodeJS.ErrnoException & { stderr?: Buffer }; + if (err.code === 'ENOENT') { + throw new Error('cosign no está instalado (https://docs.sigstore.dev/cosign/system_config/installation/)'); + } + throw new Error(`${name} ${release.tag_name}: firma de checksums.txt no válida\n${err.stderr ?? ''}`); + } +} + function ageInDays(publishedAt: string): number { return (Date.now() - new Date(publishedAt).getTime()) / 86_400_000; } @@ -97,12 +195,45 @@ async function loadCurrent(): Promise>> { } } +function compareVersions(a: string, b: string): number { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const diff = (pa[i] ?? 0) - (pb[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; +} + +async function stableReleases(name: ToolName) { + const releases = await github(`/repos/${SPECS[name].repo}/releases?per_page=20`); + const stable = releases.filter((r) => !r.draft && !r.prerelease); + return { + latest: stable[0], + eligible: stable.find((r) => ageInDays(r.published_at) >= MIN_RELEASE_AGE_DAYS), + }; +} + +// Versiones más nuevas que la fijada con al menos MIN_RELEASE_AGE_DAYS días. +async function findUpdates(current: Partial>): Promise> { + const updates = new Map(); + for (const name of TOOL_NAMES) { + const { eligible } = await stableReleases(name); + const version = eligible?.tag_name.replace(/^v/, ''); + const pinned = current[name]?.version; + if (version && (!pinned || compareVersions(version, pinned) > 0)) { + updates.set(name, version); + console.log( + `- ${name} ${pinned ?? '-'} → ${version}: https://github.com/${SPECS[name].repo}/releases/tag/v${version}`, + ); + } + } + return updates; +} + async function showStatus(current: Partial>) { for (const name of TOOL_NAMES) { - const releases = await github(`/repos/${SPECS[name].repo}/releases?per_page=20`); - const stable = releases.filter((r) => !r.draft && !r.prerelease); - const latest = stable[0]; - const eligible = stable.find((r) => ageInDays(r.published_at) >= MIN_RELEASE_AGE_DAYS); + const { latest, eligible } = await stableReleases(name); const fmt = (r?: GithubRelease) => r ? `${r.tag_name.replace(/^v/, '')} (${Math.floor(ageInDays(r.published_at))}d)` : '-'; @@ -130,49 +261,65 @@ async function resolveTool(name: ToolName, version: string, allowRecent: boolean ); } - const checksumsAsset = release.assets.find((a) => a.name === spec.checksums(version)); - if (!checksumsAsset) { - throw new Error(`${name} ${tag}: no se encontró ${spec.checksums(version)}`); - } - const checksumsRes = await fetch(checksumsAsset.browser_download_url); - if (!checksumsRes.ok) { - throw new Error(`${name} ${tag}: no se pudo descargar ${checksumsAsset.name}`); - } - const checksums = new Map(); - for (const line of (await checksumsRes.text()).split('\n')) { - const [hash, file] = line.trim().split(/\s+/); - if (hash && file) checksums.set(file.replace(/^\*/, ''), hash.toLowerCase()); - } + const dir = mkdtempSync(join(tmpdir(), `gops-${name}-`)); + try { + const checksumsPath = await download(release, spec.checksums(version), dir); + await verifySignature(name, version, release, checksumsPath, dir); - const assets: PinnedTool['assets'] = {}; - for (const [platformKey, suffix] of Object.entries(spec.platforms)) { - const file = spec.archive(version, suffix); - const sha256 = checksums.get(file); - if (!sha256 || !/^[a-f0-9]{64}$/.test(sha256)) { - throw new Error(`${name} ${tag}: falta el checksum de ${file}`); + const checksums = new Map(); + for (const line of readFileSync(checksumsPath, 'utf8').split('\n')) { + const [hash, file] = line.trim().split(/\s+/); + if (hash && file) checksums.set(file.replace(/^\*/, ''), hash.toLowerCase()); } - // GitHub calcula su propio digest al subir el asset; debe coincidir con el checksums.txt. - const githubDigest = release.assets.find((a) => a.name === file)?.digest; - if (githubDigest && githubDigest !== `sha256:${sha256}`) { - throw new Error(`${name} ${tag}: el checksum de ${file} no coincide con el digest de GitHub`); + + const assets: PinnedTool['assets'] = {}; + for (const [platformKey, suffix] of Object.entries(spec.platforms)) { + const file = spec.archive(version, suffix); + const sha256 = checksums.get(file); + if (!sha256 || !/^[a-f0-9]{64}$/.test(sha256)) { + throw new Error(`${name} ${tag}: falta el checksum de ${file}`); + } + // GitHub calcula su propio digest al subir el asset; debe coincidir con el checksums.txt. + const githubDigest = release.assets.find((a) => a.name === file)?.digest; + if (githubDigest && githubDigest !== `sha256:${sha256}`) { + throw new Error(`${name} ${tag}: el checksum de ${file} no coincide con el digest de GitHub`); + } + + // Se descarga el archivo para fijar también el SHA-256 del binario que + // contiene; la CLI lo usa para verificar el binario ya instalado. + const archivePath = await download(release, file, dir); + if (sha256File(archivePath) !== sha256) { + throw new Error(`${name} ${tag}: el archivo descargado ${file} no coincide con checksums.txt`); + } + const extractDir = join(dir, platformKey); + mkdirSync(extractDir); + execFileSync('tar', ['-xzf', archivePath, '-C', extractDir, name]); + const binarySha256 = sha256File(join(extractDir, name)); + rmSync(archivePath); + + assets[platformKey] = { file, sha256, binarySha256 }; } - assets[platformKey] = { file, sha256 }; - } - console.log(`✔ ${name} ${version} (publicada hace ${Math.floor(age)} días)`); - return { repo: spec.repo, version, tag, assets }; + const signed = spec.signature ? 'firma verificada' : 'sin firma'; + console.log(`✔ ${name} ${version} (publicada hace ${Math.floor(age)} días, ${signed})`); + return { repo: spec.repo, version, tag, assets }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } } function render(tools: Record): string { return `// Generado por scripts/update-tools.ts (npm run tools:update). No editar a mano. -// Versiones fijadas de las herramientas externas que descarga la CLI y el -// SHA-256 de cada archivo. Cualquier binario que no coincida se rechaza. +// Versiones fijadas de las herramientas externas que descarga la CLI, el +// SHA-256 de cada archivo y el del binario que contiene. Cualquier archivo o +// binario que no coincida se rechaza. export type ToolName = ${TOOL_NAMES.map((n) => `'${n}'`).join(' | ')}; export interface ToolAsset { file: string; sha256: string; + binarySha256: string; } export interface PinnedTool { @@ -186,6 +333,10 @@ export const PINNED_TOOLS: Record = ${JSON.stringify(tools `; } +function isComplete(tool: PinnedTool): boolean { + return Object.values(tool.assets).every((asset) => /^[a-f0-9]{64}$/.test(asset.binarySha256 ?? '')); +} + async function main() { const args = process.argv.slice(2); const allowRecent = args.includes('--allow-recent'); @@ -200,7 +351,18 @@ async function main() { } const current = await loadCurrent(); - if (requested.size === 0) { + if (args.includes('--latest')) { + if (requested.size > 0) { + throw new Error('--latest no se puede combinar con versiones explícitas'); + } + for (const [name, version] of await findUpdates(current)) { + requested.set(name, version); + } + if (requested.size === 0) { + console.log('Todas las herramientas están en la última versión elegible.'); + return; + } + } else if (requested.size === 0) { await showStatus(current); return; } @@ -208,10 +370,14 @@ async function main() { const next = {} as Record; for (const name of TOOL_NAMES) { const version = requested.get(name); + const pinned = current[name]; if (version) { next[name] = await resolveTool(name, version, allowRecent); - } else if (current[name]) { - next[name] = current[name]!; + } else if (pinned && isComplete(pinned)) { + next[name] = pinned; + } else if (pinned) { + // Versión ya fijada pero generada sin algún campo nuevo: se recalcula. + next[name] = await resolveTool(name, pinned.version, true); } else { throw new Error(`${name} no tiene versión fijada; pásala como ${name}=`); } diff --git a/src/commands/creport/scan.ts b/src/commands/creport/scan.ts index 3a9e1ea..5cf82d2 100644 --- a/src/commands/creport/scan.ts +++ b/src/commands/creport/scan.ts @@ -10,6 +10,12 @@ import { ensureGitleaksInstalled, ensureSyftInstalled, ensureTrivyInstalled } fr const execFileAsync = promisify(execFile); +// La base de datos de vulnerabilidades de Trivy se sigue actualizando en cada +// escaneo (son datos, no código), pero siempre desde los repositorios oficiales: +// los flags tienen prioridad sobre un trivy.yaml del repo escaneado o TRIVY_*. +const TRIVY_DB_REPOSITORIES = 'mirror.gcr.io/aquasec/trivy-db:2,ghcr.io/aquasecurity/trivy-db:2'; +const TRIVY_JAVA_DB_REPOSITORIES = 'mirror.gcr.io/aquasec/trivy-java-db:1,ghcr.io/aquasecurity/trivy-java-db:1'; + export class ScanCommand extends Command { constructor(options: any) { @@ -159,6 +165,10 @@ export class ScanCommand extends Command { 'fs', '--format', 'json', '--quiet', + '--db-repository', TRIVY_DB_REPOSITORIES, + '--java-db-repository', TRIVY_JAVA_DB_REPOSITORIES, + '--skip-version-check', + '--disable-telemetry', path ], { @@ -188,7 +198,9 @@ export class ScanCommand extends Command { '--quiet' ], { - maxBuffer: 50 * 1024 * 1024 + maxBuffer: 50 * 1024 * 1024, + // La versión la fija la CLI; Syft no debe consultar si hay otra más nueva. + env: { ...process.env, SYFT_CHECK_FOR_APP_UPDATE: 'false' } } ); diff --git a/src/utils/installs.ts b/src/utils/installs.ts index 29cb560..58456b1 100644 --- a/src/utils/installs.ts +++ b/src/utils/installs.ts @@ -1,6 +1,6 @@ import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs'; +import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readSync, renameSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { homedir, platform, arch } from 'node:os'; import { PINNED_TOOLS, ToolName } from './tool-versions'; @@ -17,8 +17,20 @@ const DISPLAY_NAMES: Record = { gitleaks: 'Gitleaks', }; -function sha256File(path: string): string { - return createHash('sha256').update(readFileSync(path)).digest('hex'); +// Lee por bloques para no cargar en memoria binarios de cientos de MB. +export function sha256File(path: string): string { + const hash = createHash('sha256'); + const buffer = Buffer.alloc(1024 * 1024); + const fd = openSync(path, 'r'); + try { + let bytesRead: number; + while ((bytesRead = readSync(fd, buffer, 0, buffer.length, null)) > 0) { + hash.update(buffer.subarray(0, bytesRead)); + } + } finally { + closeSync(fd); + } + return hash.digest('hex'); } function ensureToolInstalled(name: ToolName): string { @@ -27,16 +39,24 @@ function ensureToolInstalled(name: ToolName): string { const installDir = join(TOOLS_DIR, name, tool.version); const binaryPath = join(installDir, name); - if (existsSync(binaryPath)) { - return binaryPath; - } - const platformKey = `${platform()}-${arch()}`; const asset = tool.assets[platformKey]; if (!asset) { throw new Error(`Plataforma no soportada para ${displayName}: ${platformKey}`); } + // El binario instalado también se verifica en cada uso: el directorio de + // herramientas puede venir de una caché de CI o haber sido modificado. + if (existsSync(binaryPath)) { + if (sha256File(binaryPath) === asset.binarySha256) { + return binaryPath; + } + console.warn( + `⚠️ El binario de ${displayName} en ${binaryPath} no coincide con el SHA-256 fijado. Se reinstala.`, + ); + rmSync(installDir, { recursive: true, force: true }); + } + console.log(`Instalando ${displayName} ${tool.version}...`); const tmpRoot = join(TOOLS_DIR, name, `.install-${process.pid}-${Date.now()}`); @@ -65,6 +85,14 @@ function ensureToolInstalled(name: ToolName): string { throw new Error(`No se encontró el binario de ${displayName} en ${asset.file}`); } + const binaryActual = sha256File(extractedBinary); + if (binaryActual !== asset.binarySha256) { + throw new Error( + `checksum SHA-256 del binario extraído no coincide (esperado ${asset.binarySha256}, ` + + `obtenido ${binaryActual}). Se aborta la instalación.`, + ); + } + chmodSync(extractedBinary, 0o755); mkdirSync(installDir, { recursive: true }); renameSync(extractedBinary, binaryPath); diff --git a/src/utils/tool-versions.ts b/src/utils/tool-versions.ts index f40dcce..3ed95bb 100644 --- a/src/utils/tool-versions.ts +++ b/src/utils/tool-versions.ts @@ -1,12 +1,14 @@ // Generado por scripts/update-tools.ts (npm run tools:update). No editar a mano. -// Versiones fijadas de las herramientas externas que descarga la CLI y el -// SHA-256 de cada archivo. Cualquier binario que no coincida se rechaza. +// Versiones fijadas de las herramientas externas que descarga la CLI, el +// SHA-256 de cada archivo y el del binario que contiene. Cualquier archivo o +// binario que no coincida se rechaza. export type ToolName = 'trivy' | 'syft' | 'gitleaks'; export interface ToolAsset { file: string; sha256: string; + binarySha256: string; } export interface PinnedTool { @@ -24,19 +26,23 @@ export const PINNED_TOOLS: Record = { "assets": { "darwin-arm64": { "file": "trivy_0.74.0_macOS-ARM64.tar.gz", - "sha256": "1caada5e0e2091909357c7525d3aa76f4b660b13821bc143b190c7483e31cc11" + "sha256": "1caada5e0e2091909357c7525d3aa76f4b660b13821bc143b190c7483e31cc11", + "binarySha256": "0ed07c205ca9ecc1065dc57b9f9f77adc79393bb469d9d1de9ec90c8c94ffc2f" }, "darwin-x64": { "file": "trivy_0.74.0_macOS-64bit.tar.gz", - "sha256": "472816f6888dda689d075c30254d4210b4d1035acf365aa72332f584c2f60485" + "sha256": "472816f6888dda689d075c30254d4210b4d1035acf365aa72332f584c2f60485", + "binarySha256": "43ae1fd02532315b44a9d0496ae06326d5a1f3496cae26499be624807813fa8c" }, "linux-arm64": { "file": "trivy_0.74.0_Linux-ARM64.tar.gz", - "sha256": "b94ce1976bbf3c15b514b605ee88be7c6d94a29be2302847ff01cb794d47aad5" + "sha256": "b94ce1976bbf3c15b514b605ee88be7c6d94a29be2302847ff01cb794d47aad5", + "binarySha256": "fed2c9ca7d27191ada34524b5eaf5216a845c6d6f3246143c3b475552ffe5358" }, "linux-x64": { "file": "trivy_0.74.0_Linux-64bit.tar.gz", - "sha256": "2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a" + "sha256": "2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a", + "binarySha256": "d89bcc6510a267f11b773398cbf1be5520ce39f9e8b6633178c4487f05b7d791" } } }, @@ -47,19 +53,23 @@ export const PINNED_TOOLS: Record = { "assets": { "darwin-arm64": { "file": "syft_1.52.0_darwin_arm64.tar.gz", - "sha256": "014d561b6d13059124155f74a6c5a9a99501f5e209313638dd884f39eb418ee6" + "sha256": "014d561b6d13059124155f74a6c5a9a99501f5e209313638dd884f39eb418ee6", + "binarySha256": "17e4215af72264e186a843269d0b797697d643f53841d9598f73614bbcbe9022" }, "darwin-x64": { "file": "syft_1.52.0_darwin_amd64.tar.gz", - "sha256": "56975f5d7ffa9846a1eaf64330647841b878097bc7e3730cb9325f93add96917" + "sha256": "56975f5d7ffa9846a1eaf64330647841b878097bc7e3730cb9325f93add96917", + "binarySha256": "abdbb5e8955ed65f67f058c10de9dc2fb36921236dab7d76a7d4618da3869e15" }, "linux-arm64": { "file": "syft_1.52.0_linux_arm64.tar.gz", - "sha256": "c46d5e4c28e12aa4c5becfaa343ef1c7f89045b6b895f2c21d471c62db09c706" + "sha256": "c46d5e4c28e12aa4c5becfaa343ef1c7f89045b6b895f2c21d471c62db09c706", + "binarySha256": "27563e0bee8b2f4c938e1d5a8c3391cf7f4b2481aec2eb74778d0d9ec4fffadc" }, "linux-x64": { "file": "syft_1.52.0_linux_amd64.tar.gz", - "sha256": "caeedb81fb0491615f1ebd1761e4145d41ee86dd2cc7bf80669f9f5ad9d6133d" + "sha256": "caeedb81fb0491615f1ebd1761e4145d41ee86dd2cc7bf80669f9f5ad9d6133d", + "binarySha256": "15a52d0122953081d16e6695f48055216a4de293bb005948663bb739a63880f9" } } }, @@ -70,19 +80,23 @@ export const PINNED_TOOLS: Record = { "assets": { "darwin-arm64": { "file": "gitleaks_8.30.1_darwin_arm64.tar.gz", - "sha256": "b40ab0ae55c505963e365f271a8d3846efbc170aa17f2607f13df610a9aeb6a5" + "sha256": "b40ab0ae55c505963e365f271a8d3846efbc170aa17f2607f13df610a9aeb6a5", + "binarySha256": "ba52fb1bfabbcde42f032afad3d6e0b19dff8ed105229a16e7caa338bbc0e84f" }, "darwin-x64": { "file": "gitleaks_8.30.1_darwin_x64.tar.gz", - "sha256": "dfe101a4db2255fc85120ac7f3d25e4342c3c20cf749f2c20a18081af1952709" + "sha256": "dfe101a4db2255fc85120ac7f3d25e4342c3c20cf749f2c20a18081af1952709", + "binarySha256": "cee01fea7173f1b779dff188e1c26ecbcb4027d394acc573b23aaf0be260e291" }, "linux-arm64": { "file": "gitleaks_8.30.1_linux_arm64.tar.gz", - "sha256": "e4a487ee7ccd7d3a7f7ec08657610aa3606637dab924210b3aee62570fb4b080" + "sha256": "e4a487ee7ccd7d3a7f7ec08657610aa3606637dab924210b3aee62570fb4b080", + "binarySha256": "00e91bbe655bd7c47753e8cfe61cb76ea1a5d7e7702fe161ee40102b46b3823b" }, "linux-x64": { "file": "gitleaks_8.30.1_linux_x64.tar.gz", - "sha256": "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" + "sha256": "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb", + "binarySha256": "88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509" } } }