Seguridad en PHP
Seguridad en PHP: cómo prevenir vulnerabilidades PHP en su código, sus dependencias de Composer y su build
Proteger PHP exige trabajar en tres capas. En el código que escribe: valide la entrada, use consultas parametrizadas y el escapado del framework, y no deserialice nunca datos que no sean de confianza. En los paquetes de Composer de los que depende: ejecute composer audit, haga commit de composer.lock y bloquee las versiones inseguras. En el CI que los instala: registre lo que realmente se instaló y limite a dónde puede conectarse el build. La mayoría de las guías de seguridad en PHP se quedan en la primera capa. Esta cubre las tres.
Actualizado el
- Su código Inyección, XSS, deserialización, manejo de archivos, sesiones Valores por defecto del framework, revisión de código, PHPStan, Psalm, Semgrep
- Sus dependencias de Composer Vulnerabilidades conocidas, paquetes abandonados y maliciosos, desviaciones del lockfile, código en tiempo de instalación composer audit, políticas de Composer, Dependabot o Renovate
- Su build Lo que el CI instaló y ejecutó de verdad, de dónde lo descargó y a qué pudo acceder Actions fijadas, tokens con privilegios mínimos, política de egress, SBOM en tiempo de build
En esta página: PHP2Shell, las vulnerabilidades PHP que siguen importando, configuración y versiones con soporte, análisis estático, seguridad de las dependencias de Composer, ataques a la cadena de suministro de PHP, el build de CI, SBOM y herramientas por capa.
PHP2Shell: la vulnerabilidad de PHP que hay que parchear ya
El 21 de octubre de 2026 el proyecto PHP publicó PHP 8.5.12, 8.4.27, 8.3.36 y 8.2.35 para corregir dos vulnerabilidades críticas en el propio PHP que pueden permitir la ejecución remota de código sin autenticación. Ambas tienen una puntuación de 9,2 en CVSS v4.0 y, juntas, se conocen como PHP2Shell:
- CVE-2026-109618: la gestión de falta de memoria puede interrumpir PHP cuando los datos de la petición solo están inicializados en parte, y la limpieza de la petición puede tratar después esos datos no válidos como válidos.
- CVE-2026-109619: el estado que pertenece a una petición puede sobrevivir después de liberarse su memoria, y la siguiente petición que atienda el mismo worker puede utilizarlo.
El aviso de PHP indica que toda instalación que atiende peticiones web está en riesgo, incluidas PHP-FPM, Apache con mod_php, php-cgi, FrankenPHP y LiteSpeed, y considera mod_php el caso de mayor riesgo. PHP 8.1 y versiones anteriores no reciben ninguna versión nueva del proyecto PHP. Dada la gravedad, el equipo de seguridad de PHP publicó backports mínimos del código fuente para PHP 7.4, 8.0 y 8.1, para quien compila PHP desde el código fuente o no tiene un proveedor LTS, y algunas distribuciones y proveedores LTS ofrecen sus propias correcciones adaptadas. Qué hacer:
- Actualice a una versión corregida. En 8.1 o anterior, pase a una rama con soporte, instale el paquete con el backport de su proveedor o aplique el backport oficial del código fuente si compila PHP usted mismo.
-
Reconstruya cada imagen de contenedor que incluya PHP y vuelva a desplegarla. Una etiqueta de imagen antigua sigue
ejecutando el PHP antiguo hasta que se sustituye, así que compruebe lo que está desplegado, no solo lo que hay en el
repositorio:
docker run --rm IMAGE php -v. -
Si hoy no puede actualizar, aplique las mitigaciones provisionales del aviso de seguridad de PHP, como
session.upload_progress.enabled=0. Configúrelo enphp.ini, en el pool de FPM o en una configuración del servidor que se aplique antes de analizar la petición, y reinicie cada worker. No confíe para ello enini_set()ni en el.user.inide FPM: el aviso desaconseja ambos. PHP deja claro que estas mitigaciones reducen la exposición, pero no hacen segura una instalación.
Tenga en cuenta lo que composer audit no puede decirle aquí: PHP2Shell está en el runtime, no en un
paquete de Composer, así que nunca aparece en composer.lock. El PHP que ejecuta procede de su imagen base
o de los paquetes de su sistema operativo, que es la tercera capa de esta guía. Para la tabla de versiones, la
cronología y un script que revisa cada etiqueta de imagen, consulte
PHP2Shell: qué parchear y cómo encontrar cada PHP vulnerable.
Las vulnerabilidades PHP que siguen importando
Las clases de vulnerabilidades de las aplicaciones PHP apenas han cambiado en una década. Lo que ha cambiado es que Laravel, Symfony y el PHP moderno cierran la mayoría por defecto, de modo que los fallos reales se concentran ahora donde el código esquiva esos valores por defecto. La inyección es A05 en el OWASP Top 10:2025, y Software Supply Chain Failures entra como novedad en A03, el tema de la segunda mitad de esta guía.
| Vulnerabilidad | Cómo aparece en PHP | La corrección | Laravel y Symfony |
|---|---|---|---|
| Inyección SQL | Datos de la petición concatenados en SQL, incluidas las vías de escape a consultas en bruto | Sentencias preparadas de PDO con parámetros vinculados | Eloquent y Doctrine vinculan los parámetros; whereRaw(), DB::raw() y el DQL concatenado no |
| Cross-site scripting (XSS) | Entrada del usuario impresa en HTML sin escapar | Escapar en la salida con htmlspecialchars() o con el motor de plantillas |
Blade {{ }} y Twig escapan por defecto; {!! !!} y |raw no |
| Cross-site request forgery (CSRF) | Un formulario o endpoint AJAX que cambia el estado acepta cualquier petición que lleve la cookie de sesión | Un token por sesión comprobado en cada POST, PUT, PATCH y DELETE, más cookies SameSite |
Laravel comprueba el token en las rutas web (@csrf en los formularios); Symfony Forms incluye y comprueba uno por defecto; las rutas excluidas de la comprobación quedan sin protección |
Inyección de código mediante eval() |
Datos de la petición, o una plantilla o fórmula que un usuario puede editar, que llegan a eval() |
No use eval(); asigne la entrada a un conjunto fijo de operaciones o use un parser para las expresiones que necesita |
Ningún framework protege sus propias llamadas a eval() |
| Inyección de objetos | unserialize() sobre cookies, datos de la petición o entradas de caché que un atacante puede escribir |
Use JSON para los datos externos; si tiene que deserializar, rechace los objetos | Ningún framework protege sus propias llamadas a unserialize() |
| Inclusión de archivos y subidas | Una ruta construida a partir de la entrada y pasada a include; subidas guardadas bajo la raíz web con el nombre de archivo del cliente |
Lista de permitidos para los archivos incluidos; guarde las subidas fuera de la raíz web con nombres generados | store() de Laravel genera los nombres de archivo; valide tipo y tamaño en el servidor |
| Type juggling | == al comparar tokens o hashes, donde "0e123" == "0e456" es verdadero |
===, hash_equals() para secretos, password_verify() para contraseñas |
Los helpers del framework comparan de forma segura; sus propias comparaciones dependen de usted |
| Sesiones y cookies débiles | ID de sesión legibles desde JavaScript, enviados por HTTP o mantenidos tras el inicio de sesión | Cookies Secure, HttpOnly y SameSite, modo estricto y un nuevo ID de sesión tras el inicio de sesión | Ambos regeneran el ID de sesión al iniciar sesión |
| Server-side request forgery (SSRF) | file_get_contents() o cURL sobre una URL que aporta el usuario, incluidos wrappers como file:// y phar:// |
Lista de permitidos de esquemas y hosts, bloqueo de direcciones internas y allow_url_fopen desactivado si no se usa |
Sin protección por defecto |
| Inyección de comandos | Entrada pasada a exec(), shell_exec(), system() o proc_open() |
Evite la shell; pase los argumentos como array o escape cada uno con escapeshellarg() |
Symfony Process y la facade Process de Laravel aceptan un array de argumentos |
| Asignación masiva | User::create($request->all()) permite que una petición establezca is_admin |
Enumere los campos rellenables o pase solo la entrada validada | Los modelos de Eloquent están protegidos por defecto; $guarded = [] desactiva esa protección |
Inyección SQL: vulnerable y corregido
// Vulnerable: the id goes straight into the SQL string
$rows = $pdo->query("SELECT * FROM orders WHERE id = " . $_GET['id']);
// Fixed: a prepared statement with a bound parameter
$stmt = $pdo->prepare('SELECT * FROM orders WHERE id = ?');
$stmt->execute([$_GET['id']]);
$rows = $stmt->fetchAll();
// Laravel: whereRaw() takes SQL as written, where() binds the value
Order::whereRaw("status = '" . $request->input('status') . "'")->get(); // vulnerable
Order::where('status', $request->input('status'))->get(); // fixed
XSS: vulnerable y corregido
<?php // Plain PHP: escape on output
echo '<p>' . $comment . '</p>'; // vulnerable
echo '<p>' . htmlspecialchars($comment, ENT_QUOTES, 'UTF-8') . '</p>'; // fixed ?>
{{-- Blade: {{ }} escapes, {!! !!} prints raw HTML --}}
<p>{!! $comment !!}</p> {{-- vulnerable --}}
<p>{{ $comment }}</p> {{-- fixed --}}
{# Twig: autoescape is on, |raw turns it off #}
<p>{{ comment|raw }}</p> {# vulnerable #}
<p>{{ comment }}</p> {# fixed #}
Inyección de objetos: vulnerable y corregido
// Vulnerable: an attacker-controlled cookie becomes PHP objects, and any
// class with a __wakeup() or __destruct() method in vendor/ is a gadget
$prefs = unserialize($_COOKIE['prefs']);
// Fixed: JSON carries data, not objects
$prefs = json_decode($_COOKIE['prefs'], true, 512, JSON_THROW_ON_ERROR);
// If you must read serialized data, refuse objects
$prefs = unserialize($data, ['allowed_classes' => false]);
La deserialización phar, la variante en la que una operación de archivo sobre una ruta phar://
deserializaba los metadatos del archivo, se acotó en PHP 8.0: los metadatos phar
ya no se deserializan al abrir un
phar, solo cuando el código llama a getMetadata(). Es un motivo más para dejar atrás PHP 7.
Inclusión de archivos: vulnerable y corregido
// Vulnerable: ?page=../admin/reset reaches any .php file on the disk
include __DIR__ . '/pages/' . $_GET['page'] . '.php';
// Fixed: only names on an allow-list can be included
$pages = ['home' => 'home.php', 'pricing' => 'pricing.php'];
$file = $pages[$_GET['page'] ?? 'home'] ?? 'home.php';
include __DIR__ . '/pages/' . $file;
Configuración y endurecimiento del runtime
Una aplicación segura puede filtrar información a través de su runtime. Estos ajustes de php.ini siguen
la OWASP
PHP Configuration Cheat Sheet:
| Ajuste | Valor en producción | Por qué |
|---|---|---|
display_errors |
Off (con log_errors = On) |
Las trazas muestran rutas, consultas y a veces secretos |
expose_php |
Off |
Deja de anunciar la versión exacta de PHP en las cabeceras de respuesta |
allow_url_include |
Off |
Impide el include de URL remotas; obsoleto desde PHP 7.4 |
allow_url_fopen |
Off si la aplicación no descarga URL con funciones de archivo |
Reduce el SSRF a través de file_get_contents() |
disable_functions |
Por ejemplo exec, shell_exec, system, passthru, proc_open, popen |
Quita la shell a un atacante que logra ejecutar código; incluya solo lo que su aplicación nunca llama |
open_basedir |
El directorio de la aplicación | Limita las rutas que pueden abrir las funciones de archivo de PHP; una barrera de protección, no un límite de aislamiento |
| Cookies de sesión | session.cookie_secure = 1, session.cookie_httponly = 1, session.cookie_samesite = Lax o Strict, session.use_strict_mode = 1 |
Mantiene los ID de sesión fuera de HTTP sin cifrar y lejos de JavaScript, y rechaza los ID que el servidor no emitió |
file_uploads |
Off si la aplicación no acepta subidas |
Elimina toda una vía de entrada |
Use una versión de PHP con soporte
Según php.net, PHP da a cada rama dos años de soporte activo y después dos años solo de correcciones de seguridad. PHP 8.1 llegó al final de su vida útil el 31 de diciembre de 2025, y PHP 8.2 lo hará el 31 de diciembre de 2026. Una rama al final de su vida útil no recibe versiones nuevas del proyecto PHP, que es la situación en la que PHP2Shell dejó a los usuarios de 8.1: su única corrección oficial fue un backport del código fuente.
- PHP 8.1 Fin de vida útil, terminó el 31 dic 2025
- PHP 8.2 Solo correcciones de seguridad, hasta el 31 dic 2026
- PHP 8.3 Solo correcciones de seguridad, hasta el 31 dic 2027
- PHP 8.4 Soporte activo, hasta el 31 dic 2026
- PHP 8.5 Soporte activo, hasta el 31 dic 2027
Ajustes de producción en Laravel y Symfony
-
Depuración desactivada.
APP_DEBUG=falseen Laravel,APP_ENV=prodyAPP_DEBUG=0en Symfony. Una página de depuración en producción muestra la configuración y los valores del entorno. -
Claves y secretos. Mantenga
APP_KEY(Laravel) yAPP_SECRET(Symfony) fuera del repositorio y rótelos si se filtran.APP_PREVIOUS_KEYSde Laravel le permite rotar la clave sin cerrar la sesión de todos los usuarios. -
Proxies de confianza. Detrás de un balanceador de carga, enumere los proxies en los que confía (el
middleware de proxies de confianza de Laravel,
framework.trusted_proxiesen Symfony), para que un cliente no pueda falsear su dirección IP ni HTTPS con cabecerasX-Forwarded-*.
Análisis estático para PHP (SAST)
El análisis estático lee su código sin ejecutarlo. Tres herramientas de código abierto cubren a la mayoría de los equipos PHP:
-
PHPStan encuentra errores de tipos y fallos con niveles de rigor crecientes. No es un escáner de
seguridad, pero muchos fallos de seguridad empiezan como una confusión de tipos o un
nullsin comprobar. -
Psalm añade análisis de
taint: sigue los datos desde fuentes como
$_GETy$_COOKIEhasta sumideros comoecho,includey consultas SQL, e informa de problemas comoTaintedSqlyTaintedHtml. El Psalm actual lo ejecuta junto con la comprobación de tipos;--taint-analysislo activa para una sola ejecución, y--report=results.sarifgenera SARIF para GitHub code scanning. - Semgrep admite PHP como lenguaje de disponibilidad general, con reglas de la comunidad para errores habituales de PHP y análisis de flujo de datos entre funciones en su versión comercial.
El SAST genera mucho ruido en PHP heredado por razones estructurales: includes dinámicos, variables variables, métodos
mágicos como __get() y __call(), y convenciones de framework que hacen pasar los datos por
contenedores y facades que el analizador no puede seguir. La herramienta o bien adivina, y da falsos positivos, o
bien se rinde, y pasa por alto flujos reales.
En una base de código antigua funciona mejor como un trinquete. Registre los hallazgos actuales como línea base
(vendor/bin/phpstan analyse --generate-baseline escribe phpstan-baseline.neon; Psalm tiene
--set-baseline), haga fallar el build solo con hallazgos nuevos y suba el nivel a medida que reduce la
línea base. Los equipos con bases de código PHP longevas nos cuentan que la corrección automática con IA funciona bien
para actualizar dependencias y es irregular con los hallazgos de código, así que revise las correcciones generadas
para hallazgos SAST con el mismo cuidado que cualquier otro cambio. SAST y SCA responden a preguntas distintas;
consulte SAST vs SCA.
Seguridad de las dependencias de Composer
Una aplicación Laravel o Symfony instala decenas de paquetes de Composer, la mayoría transitivos. Composer ha incorporado funciones de seguridad reales en las últimas versiones; esta sección refleja Composer 2.11, la versión actual.
composer audit
composer audit compara los paquetes instalados con los avisos de seguridad de la
API de Packagist.org, que reúne
avisos de fuentes como la GitHub Advisory Database y FriendsOfPHP/security-advisories. También informa de paquetes
abandonados y, desde Composer 2.10, de versiones de paquetes marcadas como malware. Consulte la
documentación de Composer para ver todas las
opciones.
composer audit # audit what is installed in vendor/
composer audit --locked # audit composer.lock, whatever is in vendor/
composer audit --no-dev # skip require-dev packages
composer audit --direct # direct dependencies only (Composer 2.11)
composer audit --format=json # table, plain, json or summary
composer audit --abandoned=report # ignore, report or fail on abandoned packages
composer audit --ignore-severity=low
Desde Composer 2.10 el código de salida es 0 cuando no hay problemas y 1 cuando algo no
supera la auditoría, así que sirve como control de CI sin necesidad de analizar la salida. Los paquetes abandonados
hacen fallar la auditoría por defecto.
Los hallazgos aceptados deben estar en composer.json, con un motivo, y no en un flag de CI que nadie
recuerda. Composer 2.10 sustituyó la mayor parte de la antigua configuración audit por un bloque
policy:
{
"config": {
"policy": {
"advisories": {
"ignore-id": {
"CVE-1234": "Not affected: we never call the vulnerable parser."
}
},
"abandoned": {
"audit": "report"
}
}
}
}
Las claves heredadas (audit.ignore, audit.abandoned, audit.block-insecure)
siguen funcionando como alternativa mientras falte la sección policy correspondiente, pero están
obsoletas. Migre todos los ajustes de avisos a la vez: en cuanto existe policy.advisories, se ignora
cualquier clave audit.* relacionada con avisos.
Bloquear versiones inseguras
Desde Composer 2.9 (noviembre de 2025), composer update y composer require rechazan las
versiones de paquetes que tienen un aviso de seguridad. Era el ajuste audit.block-insecure y ahora es
policy.advisories.block, activado por defecto. Composer 2.10 añadió el bloqueo de malware a partir del
feed de malware de Packagist, que también se aplica a composer install. Composer 2.11 añadió una política
opcional cooldown que retiene las versiones recién publicadas durante el periodo que usted elija, como
"7 days", porque muchas versiones maliciosas se detectan y retiran en horas o días.
En CI importan dos detalles. Primero, el bloqueo por avisos se aplica al resolver versiones: una versión que ya está en
composer.lock se sigue instalando con composer install después de publicarse un aviso, así
que sigue necesitando composer audit como control. Segundo, --no-blocking desactiva todas
las políticas para un comando, algo que conviene buscar en sus pipelines.
Antes de Composer 2.9, la forma habitual de bloquear versiones inseguras era
roave/security-advisories, un paquete que
declara un conflict con cada versión vulnerable conocida, a partir de la GitHub Advisory Database y de
FriendsOfPHP. Sigue funcionando y sigue siendo útil con versiones antiguas de Composer; en Composer 2.9 o posterior, el
bloqueo integrado cubre los mismos avisos sin el paquete adicional.
Disciplina con el lockfile
- Haga commit de
composer.locken las aplicaciones, para que todos los entornos instalen las mismas versiones. -
Use
composer installen CI y en los despliegues, nuncacomposer update, que resuelve versiones nuevas sobre la marcha. - Instale los builds de producción con
--no-dev, para que las herramientas de prueba nunca se entreguen. -
Ejecute
composer validate --strict: falla cuandocomposer.lockestá desactualizado respecto acomposer.json.
Un lockfile registra lo que Composer resolvió. Consulte lo que un lockfile no registra.
Código en tiempo de instalación: plugins, scripts y archivos autocargados
Composer ejecuta código de sus dependencias en más sitios de los que la mayoría de los equipos espera, lo que lo convierte en el equivalente PHP de los scripts de instalación de npm:
-
Los plugins se ejecutan dentro del propio Composer. Desde Composer 2.2, el ajuste
allow-pluginsdebe enumerar cada plugin autorizado a ejecutarse, y por defecto no autoriza ninguno. Trate cada entrada nueva ahí como una dependencia nueva con acceso a la shell. -
Los scripts solo se ejecutan desde su
composer.jsonraíz, no desde las dependencias. Pero a menudo arrancan su aplicación. -
Los archivos autocargados se ejecutan siempre que se carga el autoloader. En una aplicación Laravel
estándar,
composer installtermina conphp artisan package:discover, que carga el autoloader, de modo que losfilesautocargados de una dependencia se ejecutan dentro de su job de CI durante la instalación.
Si un job no necesita plugins ni scripts, --no-plugins --no-scripts evita que se ejecute código de las
dependencias durante la instalación; ejecute después de forma explícita los pasos que necesite.
Orígenes de repositorios y dependency confusion
Composer puede instalar desde Packagist.org, repositorios privados de Composer como Private Packagist, repositorios VCS
(URL de Git) y rutas locales. Mezclar orígenes privados y públicos es donde aparece la
dependency confusion: si el nombre de su paquete privado también existe en
Packagist.org con una versión superior, ¿cuál obtiene? En Composer 2,
todo repositorio es
canónico por defecto: cuando Composer encuentra un paquete en un repositorio, no lo busca más abajo en la lista, y
Packagist.org va siempre el último. Configurar "canonical": false en un repositorio elimina esa
protección, así que hágalo solo a propósito. Los repositorios VCS descargan lo que el host de Git sirva para una
etiqueta, así que fíjelos por commit en el lockfile y revise sus cambios.
Paquetes abandonados y sin mantenimiento
Los parques PHP heredados arrastran paquetes cuyos mantenedores se marcharon. Packagist marca un paquete como
abandonado cuando su mantenedor lo indica, a menudo con un sustituto, y composer audit falla por defecto
con él. Sin embargo, muchos paquetes sin mantenimiento nunca se abandonan formalmente. Mire la fecha de la última
versión de todo lo que maneje entrada, autenticación o criptografía, y planifique los sustitutos antes de que una
vulnerabilidad le obligue.
Actualizar con seguridad cuando hay pocas pruebas
El caso más difícil es una gran aplicación heredada con una dependencia vulnerable y pocas pruebas. Lo que funciona:
-
Actualice el paquete que trae el vulnerable. La mayoría de los hallazgos son transitivos; ejecute
composer why vendor/packagepara ver la cadena y actualice primero el paquete de nivel superior dentro de su restricción. -
Actualice una cosa cada vez.
composer update vendor/package --with-dependenciesmantiene el cambio lo bastante pequeño para revisarlo en el diff decomposer.lock. - Pruebe primero los flujos críticos. Inicio de sesión, pago, los principales endpoints de la API: unas pocas pruebas de extremo a extremo detectan la mayoría de las roturas por actualización.
- Añada pruebas de caracterización alrededor de lo que toca la actualización. Registre lo que hace el código hoy, para notar cuándo la actualización lo cambia.
- Despliegue por fases y vigile las tasas de error antes de que llegue a todos los clientes.
Lo que composer audit no ve
El lockfile es un plan, no un registro del build. composer audit lee vendor/ o
composer.lock, así que no ve:
- El propio PHP y sus extensiones, que proceden de su imagen base o de paquetes del sistema operativo (PHP2Shell es un ejemplo).
- Extensiones PECL y bibliotecas del sistema instaladas en la imagen Docker.
- Herramientas que su CI descarga con
curl, actions que ejecuta y binarios que descarga un plugin o un script. - Código malicioso que todavía no tiene aviso ni marca de malware.
Los mismos límites se aplican a npm audit y a pip-audit en sus ecosistemas.
Ataques a la cadena de suministro de PHP
Cuatro incidentes muestran dónde caen realmente los ataques a la cadena de suministro contra PHP y qué habría detectado cada uno.
Marzo de 2021: commits maliciosos en php-src
El 28 de marzo de 2021 se enviaron dos commits maliciosos al repositorio php-src con los nombres de dos desarrolladores del núcleo. El equipo de PHP anunció que dejaría de operar su propio servidor Git y que los repositorios de GitHub pasarían a ser los canónicos. Su seguimiento concluyó que los commits se habían enviado por HTTPS con autenticación por contraseña, posiblemente con credenciales de una base de datos de usuarios filtrada, y no a través de un servidor comprometido. Qué lo habría detectado: nada en su propio pipeline. El objetivo era el propio lenguaje, un recordatorio de que el runtime de PHP también es una dependencia.
Abril de 2021: inyección de comandos en Composer (CVE-2021-29472)
Un fallo en cómo Composer manejaba las URL de repositorios Mercurial permitía la inyección de comandos. Packagist informó de que Packagist.org y Private Packagist estaban expuestos en teoría mediante URL de paquetes manipuladas, de que no creía que se hubiera explotado, y lo corrigió en Composer 2.0.13 y 1.10.22. Qué lo habría detectado: mantener actualizado el propio Composer en los equipos de desarrollo y en las imágenes de CI. El gestor de paquetes forma parte de su cadena de suministro.
Mayo de 2023: robo de cuentas en Packagist
El 1 de mayo de 2023, un atacante entró en cuatro cuentas inactivas de Packagist.org con contraseñas filtradas en otros
sitios, que entre todas controlaban 14 paquetes, y apuntó los paquetes a repositorios bifurcados. Las bifurcaciones
llevaban una descripción modificada y ningún código malicioso.
Packagist los
restauró a la mañana siguiente. Qué lo habría detectado: un composer.lock en el
repositorio, ya que composer install usa los orígenes registrados en el lockfile, y revisar los diffs del
lockfile en busca de URL inesperadas, como recomienda el propio Packagist. Para los mantenedores: contraseñas únicas y
autenticación de dos factores.
2026: etiquetas secuestradas en laravel-lang e intercom-php
En 2026, atacantes con accesos robados publicaron versiones maliciosas de paquetes existentes. Packagist cita
intercom/intercom-php (30 de abril) y los paquetes laravel-lang (22 de mayo) en su
actualización
sobre la seguridad de la cadena de suministro. En el caso de laravel-lang, las etiquetas de versión apuntaban a
commits de una bifurcación maliciosa, y la carga útil, un archivo src/helpers.php
cargado
por el autoloader de Composer, descargaba un ladrón de credenciales dirigido a claves de nube, claves SSH y archivos
.env. Aikido contabilizó 233 versiones comprometidas. Qué lo habría detectado: una vez
marcadas, la política de malware de Composer 2.10 bloquea esas versiones incluso en composer install;
antes de eso, un periodo de cooldown deja fuera las versiones recién publicadas, una política de egress impide que la
carga útil llegue a su servidor y un registro de cada build muestra qué builds instalaron una versión maliciosa.
Desde entonces, Packagist ha anunciado que rechaza las versiones estables reetiquetadas, publica un registro de transparencia de los cambios de propiedad y de etiquetas, y marca el malware en Packagist.org desde marzo de 2026.
Proteger el build de PHP en CI
El build es donde se juntan su código, sus dependencias y sus secretos, así que es donde un paquete malicioso hace más daño. Un workflow de GitHub Actions para un build de producción:
name: build
on: [push, pull_request]
permissions:
contents: read # least privilege for GITHUB_TOKEN
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0
with:
php-version: "8.4"
tools: composer:v2
coverage: none
- run: composer validate --strict
- id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: composer-${{ hashFiles('composer.lock') }}
- run: composer install --no-dev --no-interaction --no-progress --optimize-autoloader
- run: composer audit --no-dev
-
Token con privilegios mínimos.
permissions: contents: readimpide que un paso comprometido envíe código o publique versiones conGITHUB_TOKEN. Consulte permisos del token de GitHub. - Actions fijadas. Un SHA de commit completo no se puede mover como una etiqueta; el comentario mantiene la versión legible para Dependabot o Renovate.
-
Guarde en caché las descargas, no
vendor/. Guardar en caché la caché de descargas de Composer mantienecomposer installejecutándose contra el lockfile en cada job. Unvendor/restaurado se da por bueno tal cual, y las cachés pueden envenenarse entre workflows; consulte caché de GitHub Actions. - Registre lo que se instaló. El lockfile dice lo que debería instalarse. Un SBOM registrado desde el build dice lo que se instaló, incluidos el runtime de PHP, los paquetes del sistema operativo y las descargas fuera de Composer. Consulte SBOM en tiempo de build.
- Limite a dónde puede conectarse el build. Un build con Composer necesita Packagist, GitHub y su repositorio privado. Una política de egress que solo permite esos destinos impide que un ladrón de credenciales envíe lo que ha encontrado.
Generar un SBOM para un proyecto PHP
La opción de código abierto habitual es el
plugin CycloneDX para Composer. Al
ser un plugin, necesita una entrada en allow-plugins:
composer require --dev cyclonedx/cyclonedx-php-composer
composer CycloneDX:make-sbom --output-format=JSON --output-file=sbom.json
Lee composer.json junto con los paquetes instalados o composer.lock, así que enumera con
precisión sus dependencias de Composer y nada más. El runtime de PHP, sus extensiones, los paquetes apt o apk de la
imagen base y las herramientas que descargó el job no aparecen. Un SBOM en tiempo de build también los registra,
porque se obtiene de lo que el job descargó y no de los archivos de Composer. Para los formatos y lo que debe contener
un SBOM, consulte qué es un SBOM.
Herramientas de seguridad PHP por capa
| Capa | Herramientas | Qué cubren | Qué no ven |
|---|---|---|---|
| Código | PHPStan, Psalm, Semgrep | Fallos y errores de tipos, flujos de datos contaminados, patrones inseguros conocidos | Dependencias vulnerables, el runtime, el build |
| Dependencias | composer audit, políticas de Composer, roave/security-advisories, Dependabot o Renovate |
Avisos publicados, paquetes abandonados y marcados, pull requests de actualización | Su propio código y todo lo que esté fuera de composer.lock |
| Imagen | Escáneres de contenedores | Paquetes del sistema operativo y del lenguaje en una imagen construida | Lo que el build descargó y no conservó, y las imágenes que nadie escaneó |
| SBOM | Plugin CycloneDX para Composer | Paquetes de Composer como SBOM CycloneDX | Todo lo que está fuera de Composer |
| Build | CRACI | Lo que cada job de GitHub Actions instaló realmente, desde Composer y cualquier otro origen, monitorizado a lo largo del tiempo | Su código PHP (sin SAST); sistemas de CI distintos de GitHub Actions |
Más sobre imágenes: escaneo de contenedores. Menos hallazgos y mejores en todas las capas: cómo reducir el ruido de vulnerabilidades.
Seguridad en PHP y CRACI
CRACI es un runner de GitHub Actions que registra lo que cada job instala realmente. Cambie runs-on a
craci y cada paquete de Composer que descarga el job queda registrado con su package URL
(pkg:composer/vendor/package), junto con los paquetes del gestor de paquetes del sistema operativo, las
imágenes de contenedor, los orígenes Git y otras descargas del mismo job, incluidos los paquetes restaurados desde
cachés de CI. Cada componente del SBOM estuvo en el build.
CRACI compara esos componentes con las vulnerabilidades conocidas y vuelve a comprobar los SBOM de lo que usted entregó a medida que se publican avisos nuevos, de modo que un CVE divulgado el año que viene le sigue llevando a los builds que lo contienen. Las policy gates pueden detener un build por sus hallazgos, incluido un build que contiene un CVE concreto, y la política de egress limita a dónde puede conectarse el job.
CRACI no escanea su código PHP: no hay SAST, por diseño. Mantenga PHPStan, Psalm o Semgrep para la capa de código. Vea cada paquete que instala su build de PHP.
Cuándo CRACI no es la respuesta
-
Despliega por FTP o en un hosting compartido sin CI. No hay build que registrar. Empiece con
composer audity uncomposer.locken el repositorio. - Su riesgo está sobre todo en su propio código, como en una aplicación heredada llena de SQL en bruto. SAST, revisión y pruebas van primero.
- Hace los builds fuera de GitHub Actions, en GitLab CI o Bitbucket Pipelines. CRACI admite GitHub Actions hoy; GitLab CI y Jenkins están en la hoja de ruta.
Seguridad en PHP: preguntas frecuentes
¿Cómo protejo el código PHP?
Trabaje en tres capas. En su código, valide la entrada, use sentencias preparadas, apóyese en el escapado de salida del framework y no deserialice nunca datos que no sean de confianza. En sus dependencias, haga commit de composer.lock, ejecute composer audit en CI y mantenga activado el bloqueo de versiones inseguras de Composer. En su build, fije las actions, limite el token, restrinja el acceso a la red y registre lo que instaló el job.
¿Cuáles son las vulnerabilidades PHP más comunes?
Inyección SQL, cross-site scripting, cross-site request forgery (CSRF), inyección de código mediante eval(), inyección de objetos mediante unserialize(), inclusión de archivos y subidas inseguras, type juggling con ==, ajustes de sesión débiles, SSRF mediante wrappers de URL, inyección de comandos y asignación masiva. Las dependencias de Composer vulnerables y las versiones de PHP sin parchear, como los fallos PHP2Shell, causan incidentes con la misma frecuencia.
¿Cuál es el mejor escáner de seguridad para PHP?
Ninguna herramienta cubre por sí sola la seguridad en PHP. Use un analizador estático como PHPStan, Psalm o Semgrep para su código, composer audit para sus dependencias de Composer, un escáner de contenedores para la imagen y un registro en tiempo de build de lo que el CI instaló realmente.
¿Cómo funciona composer audit y qué no ve?
Compara los paquetes instalados, o composer.lock con --locked, con los avisos de la API de Packagist.org, e informa de los paquetes abandonados y de los marcados como malware. Sale con 1 cuando algo no supera la auditoría. No ve el propio PHP, las extensiones PECL, los paquetes del sistema operativo de su imagen ni nada de lo que el CI descarga fuera de Composer.
¿Cómo bloqueo los paquetes de Composer inseguros en CI?
Use Composer 2.9 o posterior, que por defecto bloquea las versiones con avisos durante update y require (policy.advisories.block). Como una versión vulnerable fijada en composer.lock se sigue instalando, ejecute también composer audit después de composer install y haga fallar el job según su código de salida. No use nunca --no-blocking en CI.
¿Laravel es seguro por defecto?
En gran medida. Blade escapa la salida, Eloquent vincula los parámetros de las consultas, los modelos están protegidos contra la asignación masiva y el ID de sesión se regenera al iniciar sesión. Esas protecciones dejan de funcionar cuando el código usa {!! !!}, whereRaw() con entrada del usuario, $guarded = [] o APP_DEBUG=true en producción, o cuando se filtra APP_KEY.
¿Cómo actualizo las dependencias de una aplicación PHP heredada con pocas pruebas?
Actualice el paquete de nivel superior que trae el vulnerable, un paquete cada vez, con composer update vendor/package --with-dependencies. Pruebe los flujos críticos, añada pruebas de caracterización alrededor de lo que toca la actualización y despliegue por fases.
¿Cómo genero un SBOM para un proyecto PHP?
El plugin CycloneDX para Composer (cyclonedx/cyclonedx-php-composer) genera uno a partir de sus paquetes de Composer con composer CycloneDX:make-sbom. Solo enumera paquetes de Composer. Un SBOM en tiempo de build registra además el runtime de PHP, los paquetes del sistema operativo y otras descargas del build.
¿Qué es PHP2Shell?
PHP2Shell es el nombre de CVE-2026-109618 y CVE-2026-109619, dos vulnerabilidades críticas en el propio PHP que pueden permitir la ejecución remota de código sin autenticación. Están corregidas en PHP 8.5.12, 8.4.27, 8.3.36 y 8.2.35, publicadas el 21 de octubre de 2026. Consulte PHP2Shell.
Vea cada paquete que instala su build de PHP
Ejecute un workflow de GitHub Actions en CRACI y compare el SBOM registrado con su composer.lock.
Reservar una demo