diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cb6b63..e8e33d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - fix: remove the unnecessary `src` argument from the Rector command in `composer.json`. - feat: prepare Foxy `0.3` for PHP `8.3`, faster execution, safer fallbacks, updated tooling, and clearer docs. - fix: preserve plugin self-updates and clarify framework-agnostic Composer application support. -- feat!: require Bun `^1.4.0`, npm `^12.0.2`, pnpm `^11.23.0`, or Yarn `^4.18.0` and remove legacy manager support. +- feat!: require Bun `^1.4.0`, npm `>=10.9.8`, pnpm `^11.23.0`, or Yarn `^4.18.0` and remove legacy manager support. - fix: run manager commands in the configured root directory without changing the PHP working directory, and prevent manager probes and npm dependency cleanup when manager execution is disabled. +- feat!: add secure frontend audits with CVE reporting, CI formats, and strict npm, pnpm, Yarn, and Bun validation. ## 0.2.0 January 24, 2026 diff --git a/README.md b/README.md index 42e61c7..9879fdd 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ - PHP 8.3 or later. - Composer 2.10.2 or later. -- One supported frontend manager when manager execution is enabled: +- One supported frontend manager for automatic manager execution or explicit security audits: - Bun `^1.4.0`. - - npm `^12.0.2` with Node.js `^22.22.2 || ^24.15.0 || >=26.0.0`. + - npm `>=10.9.8` with a Node.js version supported by the selected npm release. - pnpm `^11.23.0` with Node.js `>=22.13.0`. - Yarn `^4.18.0` with Node.js `>=18.12.0`; use a Node.js release that still receives security updates. @@ -71,12 +71,27 @@ Selecting a manager explicitly is recommended for reproducible local and CI beha } ``` -Valid manager values are `bun`, `npm`, `pnpm`, and `yarn`. When manager execution is enabled and `manager` is omitted, -Foxy first looks for one recognized native lockfile and then checks available executables. Configure the manager -explicitly when the project contains lockfiles from more than one manager. +Valid manager values are `bun`, `npm`, `pnpm`, and `yarn`. When automatic manager execution is enabled and `manager` is +omitted, Foxy first looks for one recognized native lockfile and then checks available executables. Configure the +manager explicitly when the project contains lockfiles from more than one manager. -When `run-asset-manager` is `false`, Foxy does not require or probe a manager binary. Automatic selection uses the -single recognized lockfile when present, or npm as the manifest adapter when no lockfile exists. +During automatic Composer processing, `run-asset-manager=false` prevents Foxy from requiring or probing a manager +binary. Automatic selection uses the single recognized lockfile when present, or npm as the manifest adapter when no +lockfile exists. An explicit `composer foxy:audit` still validates and runs the selected manager. + +## Frontend security audit + +Audit the exact frontend dependency graph recorded by the selected manager's lockfile: + +```bash +composer foxy:audit +composer foxy:audit --format=summary --no-dev --audit-level=high +``` + +Foxy normalizes the current npm, pnpm, Yarn, and Bun audit reports and identifies the affected package, advisory, +severity, vulnerable range, and CVE identifiers when GitHub maps the advisory to a CVE. The command returns `0` when no +advisory meets the configured threshold, `1` when at least one does, and `2` when the audit cannot be completed +reliably. See the [usage guide](docs/usage.md#security-auditing) for formats and CI examples. ## Quick start diff --git a/UPGRADE.md b/UPGRADE.md index f1c5612..df049c5 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -10,8 +10,8 @@ Before updating, ensure the environment provides: - PHP 8.3 or later. - Composer 2.10.2 or later. -- One supported frontend manager when manager execution is enabled: Bun `^1.4.0`, npm `^12.0.2`, pnpm `^11.23.0`, or Yarn `^4.18.0`. -- For npm, Node.js `^22.22.2 || ^24.15.0 || >=26.0.0`. +- One supported frontend manager for automatic manager execution or explicit security audits: Bun `^1.4.0`, npm `>=10.9.8`, pnpm `^11.23.0`, or Yarn `^4.18.0`. +- For npm, use a Node.js version supported by the selected npm release. - For pnpm, Node.js `>=22.13.0`. - For Yarn, Node.js `>=18.12.0` on a release that still receives security updates. @@ -64,19 +64,18 @@ configure the manager and commit its native lockfile: Remove stale lockfiles from other managers before the first Composer operation with Foxy 0.3. -#### npm 12 migration +#### npm migration -Upgrade Node.js to a release accepted by npm 12 first, then install the latest npm 12 release and regenerate the -installation state: +Ensure npm is at least 10.9.8 and regenerate the installation state: ```bash -npm install --global npm@12 npm --version npm install ``` -The reported npm version must satisfy `^12.0.2`. Review npm 12 configuration changes against the -[npm 12 documentation](https://docs.npmjs.com/cli/v12/) and commit any `package-lock.json` changes. +The reported npm version must satisfy `>=10.9.8`. npm 10.9.8 requires Node.js `^18.17.0 || >=20.5.0`; later npm +releases may require a newer Node.js version. Review the [npm documentation](https://docs.npmjs.com/cli/) for the +selected release and commit any `package-lock.json` changes. #### pnpm 11 migration @@ -132,7 +131,11 @@ The [Yarn migration guide](https://yarnpkg.com/migration/guide) documents the co Custom `AssetManagerInterface` implementations must add `getVersionConstraint(): string` and return their hard supported version range as a Composer constraint. Remove implementations and calls of the obsolete -`isValidForUpdate()` method. +`isValidForUpdate()` method. Custom `AbstractAssetManager` subclasses must also implement +`getAuditCommand(bool $noDev): string`. Foxy currently normalizes only the report schemas and manager names of its four +built-in managers; arbitrary custom managers are not supported by `composer foxy:audit`. Direct `AssetManagerInterface` +implementations that do not implement Foxy's auditable manager contract remain usable for asset solving, but the audit +command reports that they cannot be audited reliably. `AbstractAssetManager` subclasses inherit the simplified update eligibility based on installation state and the `setUpdatable()` flag, concrete-version enforcement before every manager command, and version detection in the @@ -187,4 +190,6 @@ when necessary, revert the root manifest after a failed operation. Setting `enabled=false` now bypasses manager discovery, fallback snapshots, package merging, and manager execution. Setting `run-asset-manager=false` retains package merging but skips manager binary probing, version validation, -execution, and npm cleanup of existing `node_modules/@composer-asset/*` installations. +execution, and npm cleanup of existing `node_modules/@composer-asset/*` installations. The new explicit +`composer foxy:audit` command still validates and invokes the selected manager in this mode; it never installs, updates, +or repairs dependencies. diff --git a/composer.json b/composer.json index b0299a4..f0bbc1b 100644 --- a/composer.json +++ b/composer.json @@ -72,8 +72,14 @@ "scripts": { "check-dependencies": "./vendor/bin/composer-require-checker check", "ecs": "./vendor/bin/ecs --fix", - "mutation": "./vendor/bin/infection --threads=4 --min-msi=100 --min-covered-msi=100", - "mutation-static": "./vendor/bin/infection --threads=4 --min-msi=100 --min-covered-msi=100 --static-analysis-tool=phpstan --static-analysis-tool-options='--memory-limit=-1 --debug'", + "mutation": [ + "Composer\\Config::disableProcessTimeout", + "@php -d memory_limit=-1 ./vendor/bin/infection --threads=4 --with-uncovered --min-msi=100 --min-covered-msi=100" + ], + "mutation-static": [ + "Composer\\Config::disableProcessTimeout", + "@php -d memory_limit=-1 ./vendor/bin/infection --threads=4 --with-uncovered --min-msi=100 --min-covered-msi=100 --static-analysis-tool=phpstan --static-analysis-tool-options='--memory-limit=-1'" + ], "rector": "./vendor/bin/rector process", "static": "./vendor/bin/phpstan --memory-limit=-1", "sync-metadata": [ diff --git a/docs/config.md b/docs/config.md index 283efe8..1d481bd 100644 --- a/docs/config.md +++ b/docs/config.md @@ -55,7 +55,7 @@ manager-prefixed environment variable should contain the scalar value for the ac | `manager-install-options` | string or map | Empty | Appends options only to install commands. | | `manager-update-options` | string or map | Empty | Appends options only to update commands. | | `manager-timeout` | integer or map | No practical limit | Sets the manager process timeout in seconds. | -| `run-asset-manager` | boolean | `true` | Controls manager probing and execution while retaining package merging. | +| `run-asset-manager` | boolean | `true` | Controls automatic manager probing and install or update execution. | | `fallback-asset` | boolean | `true` | Restores `package.json` after asset processing fails. | | `fallback-composer` | boolean | `true` | Restores Composer lock and vendor state after asset solving fails. | | `composer-asset-dir` | string or `null` | `/php-forge/composer-asset/` | Sets the mock package directory. | @@ -106,14 +106,16 @@ When manager execution is enabled, Foxy validates the selected manager against i | Manager | Built-in constraint | | ------- | ------------------- | | Bun | `^1.4.0` | -| npm | `^12.0.2` | +| npm | `>=10.9.8` | | pnpm | `^11.23.0` | | Yarn | `^4.18.0` | The `manager-version` option adds another Composer constraint that is evaluated together with the built-in constraint. It can narrow the accepted versions for a project, but it cannot replace or widen Foxy's supported range. Foxy treats the reported value as one concrete release and validates it from `root-package-json-dir` before every -manager command. When `run-asset-manager` is `false`, Foxy neither probes, executes, nor validates the manager binary. +manager command. During automatic Composer processing, `run-asset-manager=false` prevents probing, execution, and +validation of the manager binary. An explicit `composer foxy:audit` remains an exception because it is a direct user +request. Narrow the npm constraint for one project: @@ -122,7 +124,7 @@ Narrow the npm constraint for one project: "config": { "foxy": { "manager": "npm", - "manager-version": "~12.0.2" + "manager-version": "~10.9.8" } } } @@ -135,7 +137,7 @@ Manager-prefixed options may also use a map when a shared configuration supports "config": { "foxy": { "manager-version": { - "npm": "~12.0.2", + "npm": "~10.9.8", "pnpm": "~11.23.0" } } @@ -143,8 +145,8 @@ Manager-prefixed options may also use a map when a shared configuration supports } ``` -For example, configuring npm with `>=11.0.0` does not enable npm 11 because the built-in `^12.0.2` constraint remains -in force. Remove `manager-version` to accept the complete built-in range for the selected manager. +For example, configuring npm with `<10.9.8` does not enable an older release because the built-in `>=10.9.8` +constraint remains in force. Remove `manager-version` to accept the complete built-in range for the selected manager. ## Manager executable and options @@ -178,6 +180,18 @@ Use the three manager option settings only when native manager configuration fil These values are appended to an external command. Treat project, global, and environment configuration as trusted input. Prefer native files such as `.npmrc`, `.yarnrc.yml`, `pnpm-workspace.yaml`, or `bunfig.toml` where appropriate. +They apply to install and update operations only. `foxy:audit` owns its machine-output, advisory-filter, and +dependency-scope flags and does not inherit these options. It does honor `manager-bin`, `manager-version`, +`manager-timeout`, and `root-package-json-dir`. + +Where the native manager supports an explicit override, Foxy neutralizes inherited settings that could exclude +dependencies or advisories. Bun 1.4 cannot reset every inherited dependency-scope setting without also discarding +registry configuration. Foxy therefore rejects a Bun audit when a loaded `.npmrc` or `bunfig.toml` excludes a dependency +type required by the requested audit. Remove the restrictive setting, or use `--no-dev` when the only restriction is the +development dependency graph. Audit preflight also requires UTF-8 configuration and canonical `[install]` table syntax; +inline or array install tables, escaped keys or omit values, and multiline values inside `[install]` are rejected rather +than interpreted heuristically. The preflight rejects restrictive declarations even when a later `include` or +higher-precedence file would override them. ## Manager timeout @@ -209,7 +223,8 @@ Disable manager binary probing and execution while continuing to update `package In this manifest-only mode, Foxy does not probe manager binaries, run install or update commands, or remove existing `node_modules/@composer-asset/*` installations during npm reconciliation. Generated Composer asset manifests and the -root `package.json` are still updated. +root `package.json` are still updated. An explicit `composer foxy:audit` remains available and validates and runs the +selected manager because it represents a direct user request. ## Fallbacks diff --git a/docs/faqs.md b/docs/faqs.md index a0ea006..7a03759 100644 --- a/docs/faqs.md +++ b/docs/faqs.md @@ -43,6 +43,18 @@ Multiple recognized lockfiles require explicit selection in either mode. Explicit selection and a committed native lockfile are recommended for CI. +## How can frontend dependencies be checked for known CVEs? + +Run `composer foxy:audit`. Foxy audits the dependency graph from the selected manager's native lockfile. The default +table, plain, and JSON formats identify the affected package, advisory, severity, vulnerable range, and available CVE +identifiers. Use `--format=summary` for compact counts in CI or `--format=json` for a versioned machine-readable +document. + +The native registry response commonly contains a GHSA but no CVE. Foxy resolves that relationship through GitHub's +public advisory API unless `--no-cve` or `--format=summary` is supplied. A lookup failure is shown as unavailable +without hiding the native security finding. See [Security auditing](usage.md#security-auditing) for options and exit +statuses. + ## Why is a dependency's package.json not detected? Check the following: diff --git a/docs/index.md b/docs/index.md index ddb3158..3ae6a59 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,13 +22,13 @@ package version only when the embedded package does not declare its own version. | PHP | 8.3 or later | | Composer | 2.10.2 or later | | Bun | `^1.4.0` | -| npm | `^12.0.2`; Node.js `^22.22.2 \|\| ^24.15.0 \|\| >=26.0.0` | +| npm | `>=10.9.8`; use a Node.js version supported by the selected npm release | | pnpm | `^11.23.0`; Node.js `>=22.13.0` | | Yarn | `^4.18.0`; Node.js `>=18.12.0` on a release that still receives security fixes | | Git | Required only for Git-based dependencies | -The frontend manager requirements apply only when manager execution is enabled. Manifest-only mode does not require a -manager binary. +The frontend manager requirements apply to automatic manager execution and explicit security audits. Manifest-only +Composer processing does not require a manager binary, but `composer foxy:audit` does. ## Installation @@ -47,8 +47,9 @@ Set `config.foxy.manager` to `bun`, `npm`, `pnpm`, or `yarn` when reproducible m manager execution is enabled and the option is omitted, Foxy looks for one recognized native lockfile and then for an available manager executable. Multiple recognized lockfiles require explicit selection. -When `run-asset-manager` is `false`, Foxy selects from a single lockfile or uses npm as the manifest adapter without -probing executables. +During automatic Composer processing, `run-asset-manager=false` makes Foxy select from a single lockfile or use npm as +the manifest adapter without probing executables. An explicit `composer foxy:audit` still probes and runs the selected +manager. Commit the selected manager's native lockfile and use the same explicit manager in local development and CI. diff --git a/docs/usage.md b/docs/usage.md index 2be6b84..5dea1f8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -34,6 +34,88 @@ The project may provide its own frontend dependencies in `package.json`: During Composer install and update operations, Foxy merges eligible Composer package assets into this file and runs the selected frontend manager. Existing non-Foxy dependencies are preserved. +## Security auditing + +Run an explicit frontend dependency audit from Composer: + +```bash +composer foxy:audit +``` + +The command requires the selected manager's native lockfile, validates the manager version, and asks the manager for a +machine-readable security report. It reads the lockfile and does not run an install, update, fix, or fallback. Foxy +supports npm audit report version 2 starting with npm 10.9.8 and the current report schemas emitted by pnpm 11, Yarn 4, +and Bun 1.4; legacy report formats are rejected instead of being interpreted heuristically. + +Foxy reports every advisory returned by the manager. `--audit-level` controls only the CI exit threshold: + +```bash +composer foxy:audit --audit-level=high +``` + +Valid levels are `low`, `moderate`, `high`, and `critical`; the default is `low`. Use `--no-dev` to audit only the +production dependency graph: + +```bash +composer foxy:audit --no-dev +``` + +For npm workspace roots, Foxy explicitly includes every workspace and the root package so ambient npm workspace +selection cannot silently narrow the audited lock graph. + +Foxy also overrides pnpm and Yarn settings that could silently filter the requested dependency graph or known +advisories. Bun 1.4 cannot safely reset every inherited scope setting while retaining project registry and +authentication configuration. A Bun audit therefore returns status `2` when a loaded `.npmrc` or `bunfig.toml` excludes +a dependency type that the requested audit must cover. Development-only restrictions are accepted with `--no-dev`; +optional and peer dependency restrictions are not. Bun configuration must be UTF-8 and use canonical `[install]` table +syntax with single-line values so the preflight can verify it without heuristics. Restrictive declarations are rejected +even when another setting would override them. + +### Audit output formats + +Select `table`, `plain`, `json`, or `summary` with `--format` or `-f`: + +```bash +composer foxy:audit --format=table +composer foxy:audit --format=plain +composer foxy:audit --format=json +composer foxy:audit --format=summary +``` + +The default table identifies the package, severity, advisory ID, CVE, vulnerable range, and advisory title. The JSON +document has `schema_version: 1` and is suitable for CI artifacts. Diagnostic and CVE lookup warnings are written to +standard error so JSON on standard output remains valid. + +Native npm registry audit data normally identifies advisories by a numeric ID and GHSA rather than a CVE. Unless +`--no-cve` or `--format=summary` is used, Foxy resolves each unique public GHSA once through the +[GitHub global security advisories API](https://docs.github.com/en/rest/security-advisories/global-advisories). A +failed optional lookup is reported as `unavailable` and does not discard the native finding or change its audit exit +status. + +Bun can skip packages from a registry that does not answer its audit request while still returning a successful native +status. Foxy therefore treats any diagnostic emitted by `bun audit --json` as an unreliable partial report and returns +status `2` instead of allowing CI to pass on incomplete data. + +### Audit exit status and CI + +The exit status is stable across all supported managers: + +| Status | Meaning | +| -----: | ------------------------------------------------------------------------------------------------------- | +| 0 | The audit succeeded and no advisory met `--audit-level`. | +| 1 | The audit succeeded and at least one advisory met `--audit-level`. | +| 2 | A configuration, manager, lockfile, execution, or report validation failure prevented a reliable audit. | + +Gate a production build on high and critical findings while retaining a compact log: + +```bash +composer foxy:audit --no-dev --audit-level=high --format=summary +``` + +The explicit command still runs when `run-asset-manager` is `false`; that setting controls automatic install and +update execution, not a user-requested audit. Foxy must be enabled, the selected binary must satisfy its supported +version constraint, and the matching native lockfile must exist. + ## Library setup A Composer library becomes eligible for Foxy when it contains the selected manager's package definition and uses one diff --git a/src/Asset/AbstractAssetManager.php b/src/Asset/AbstractAssetManager.php index 50fe552..30478b2 100644 --- a/src/Asset/AbstractAssetManager.php +++ b/src/Asset/AbstractAssetManager.php @@ -10,6 +10,7 @@ use Composer\Semver\VersionParser; use Composer\Util\{Filesystem, Platform, ProcessExecutor}; use Exception; +use Foxy\Audit\{AuditProcessResult, AuditableAssetManagerInterface}; use Foxy\Config\Config; use Foxy\Converter\{SemverConverter, VersionConverterInterface}; use Foxy\Exception\RuntimeException; @@ -19,17 +20,20 @@ use Throwable; use UnexpectedValueException; +use function array_key_exists; +use function getenv; use function is_dir; use function is_string; use function ltrim; use function preg_match; +use function putenv; use function rtrim; use function sprintf; use function trim; use const DIRECTORY_SEPARATOR; -abstract class AbstractAssetManager implements AssetManagerInterface +abstract class AbstractAssetManager implements AssetManagerInterface, AuditableAssetManagerInterface { final public const NODE_MODULES_PATH = './node_modules'; @@ -48,6 +52,11 @@ public function __construct( $this->versionConverter ??= new SemverConverter(); } + /** + * Get the command to audit the asset dependencies. + */ + abstract protected function getAuditCommand(bool $noDev): string; + /** * Get the command to install the asset dependencies. */ @@ -92,6 +101,41 @@ public function addDependencies(RootPackageInterface $rootPackage, array $depend } } + public function audit(bool $noDev): AuditProcessResult + { + if (!$this->hasLockFile()) { + throw new RuntimeException( + sprintf('The %s lock file "%s" was not found.', $this->getName(), $this->getLockFilePath()), + ); + } + + $this->validateAuditConfiguration($noDev); + $this->validate(); + + $timeout = ProcessExecutor::getTimeout(); + + /** @var int $managerTimeout */ + $managerTimeout = $this->config->get('manager-timeout', PHP_INT_MAX); + + ProcessExecutor::setTimeout($managerTimeout); + + $environment = $this->overrideEnvironment($this->getAuditEnvironment()); + + try { + $output = ''; + $result = $this->executor->execute( + $this->getAuditCommand($noDev), + $output, + $this->getManagerWorkingDirectory(), + ); + + return new AuditProcessResult($result, (string) $output, $this->executor->getErrorOutput()); + } finally { + $this->restoreEnvironment($environment); + ProcessExecutor::setTimeout($timeout); + } + } + public function getPackageJsonPath(): string { return rtrim($this->getRootPackageDir(), '/\\') . DIRECTORY_SEPARATOR . $this->getPackageName(); @@ -182,6 +226,8 @@ public function setUpdatable($updatable): static public function validate(): void { + $this->config->setResolvedManager($this->getName()); + $version = $this->getVersion(); if (null === $version) { @@ -245,22 +291,38 @@ protected function actionWhenComposerDependenciesAreAlreadyInstalled(array $name */ protected function buildCommand(string $defaultBin, string $action, array|string $command): string { - $bin = $this->config->get('manager-bin', $defaultBin); - - $bin = Platform::isWindows() ? str_replace('/', '\\', (string) $bin) : $bin; - $gOptions = trim((string) $this->config->get('manager-options', '')); - $options = trim((string) $this->config->get('manager-' . $action . '-options', '')); + $options = trim((string) $this->config->get("manager-{$action}-options", '')); return sprintf( '%s %s%s%s', - $bin, + $this->getManagerBinary($defaultBin), implode(' ', (array) $command), - $gOptions === '' ? '' : ' ' . $gOptions, - $options === '' ? '' : ' ' . $options, + $gOptions === '' ? '' : " {$gOptions}", + $options === '' ? '' : " {$options}", ); } + /** + * Build a manager command without inheriting install and update options. + * + * @param array|string $command The command. + */ + protected function buildUnconfiguredCommand(string $defaultBin, array|string $command): string + { + return sprintf('%s %s', $this->getManagerBinary($defaultBin), implode(' ', (array) $command)); + } + + /** + * Get environment overrides required for a complete audit. + * + * @return array + */ + protected function getAuditEnvironment(): array + { + return []; + } + protected function getLockFilePath(): string { return $this->getRootPackagePath($this->getLockPackageName()); @@ -330,6 +392,11 @@ protected function getVersion(): string|null return $this->version; } + /** + * Validate manager configuration that can change the audit scope. + */ + protected function validateAuditConfiguration(bool $noDev): void {} + /** * Execute a manager command without changing the PHP process working directory. */ @@ -348,6 +415,14 @@ private function executeManagerCommand(string $command, string|null $workingDire return $this->executor->execute($command, $outputHandler, $workingDirectory); } + private function getManagerBinary(string $defaultBin): string + { + /** @var string $bin */ + $bin = $this->config->get('manager-bin', $defaultBin); + + return Platform::isWindows() ? str_replace('/', '\\', $bin) : $bin; + } + private function getManagerWorkingDirectory(): string|null { $rootPackageDir = $this->config->get('root-package-json-dir'); @@ -374,6 +449,39 @@ private function isAbsolutePath(string $path): bool return (bool) preg_match('/^[A-Za-z]:[\\\\\/]/', $path); } + /** + * @param array $environment + * + * @return array + */ + private function overrideEnvironment(array $environment): array + { + $state = []; + + foreach ($environment as $name => $value) { + $state[$name] = [ + 'process' => getenv($name), + 'envExists' => array_key_exists($name, $_ENV), + 'env' => $_ENV[$name] ?? null, + 'serverExists' => array_key_exists($name, $_SERVER), + 'server' => $_SERVER[$name] ?? null, + ]; + + putenv("{$name}={$value}"); + + $_ENV[$name] = $value; + $_SERVER[$name] = $value; + } + + return $state; + } + /** * Restore the asset manifest without hiding the manager failure. */ @@ -395,4 +503,32 @@ private function restoreAfterFailure(Throwable $exception): void ); } } + + /** + * @param array $state + */ + private function restoreEnvironment(array $state): void + { + foreach ($state as $name => $values) { + putenv(false === $values['process'] ? $name : "{$name}=" . $values['process']); + + if ($values['envExists']) { + $_ENV[$name] = $values['env']; + } else { + unset($_ENV[$name]); + } + + if ($values['serverExists']) { + $_SERVER[$name] = $values['server']; + } else { + unset($_SERVER[$name]); + } + } + } } diff --git a/src/Asset/BunManager.php b/src/Asset/BunManager.php index 09ec437..5208de8 100644 --- a/src/Asset/BunManager.php +++ b/src/Asset/BunManager.php @@ -4,7 +4,37 @@ namespace Foxy\Asset; +use Composer\Pcre\Preg; use Composer\Util\Platform; +use Foxy\Exception\RuntimeException; +use Stringable; + +use function array_diff_ukey; +use function array_intersect_key; +use function array_intersect_ukey; +use function array_key_exists; +use function array_pop; +use function file_exists; +use function getenv; +use function in_array; +use function is_file; +use function is_readable; +use function is_scalar; +use function ltrim; +use function preg_match; +use function rtrim; +use function sprintf; +use function str_contains; +use function str_ends_with; +use function str_starts_with; +use function strcasecmp; +use function strlen; +use function strpos; +use function strtolower; +use function substr; +use function trim; + +use const DIRECTORY_SEPARATOR; final class BunManager extends AbstractAssetManager { @@ -28,6 +58,19 @@ public function isInstalled(): bool return parent::isInstalled() && $this->hasLockFile(); } + protected function getAuditCommand(bool $noDev): string + { + $command = ['audit', '--json']; + + if ($noDev) { + $command[] = '--prod'; + } + + $binary = Platform::isWindows() ? 'bun.exe' : 'bun'; + + return $this->buildUnconfiguredCommand($binary, $command); + } + protected function getInstallCommand(): string { $command = Platform::isWindows() ? 'bun.exe' : 'bun'; @@ -46,6 +89,448 @@ protected function getVersionCommand(): string { $command = Platform::isWindows() ? 'bun.exe' : 'bun'; - return $this->buildCommand($command, 'version', '--version'); + return $this->buildUnconfiguredCommand($command, '--version'); + } + + protected function validateAuditConfiguration(bool $noDev): void + { + foreach ($this->getNpmrcPaths() as $path) { + $contents = $this->readAuditConfiguration($path); + + if (null !== $contents) { + $this->validateNpmrcAuditScope($contents, $path, $noDev); + } + } + + foreach ($this->getBunfigPaths() as $path) { + $contents = $this->readAuditConfiguration($path); + + if (null !== $contents) { + $this->validateBunfigAuditScope($contents, $path, $noDev); + } + } + } + + /** + * Read the environment using the same merge order as Symfony Process. + */ + private function getAuditEnvironmentValue(string $name): string|null + { + $environment = getenv(); + + if (Platform::isWindows()) { + $serverEnvironment = array_intersect_ukey($environment, $_SERVER, 'strcasecmp'); + + $environment = [] === $serverEnvironment ? $environment : $serverEnvironment; + + $environment = $_ENV + array_diff_ukey($environment, $_ENV, 'strcasecmp'); + } else { + $serverEnvironment = array_intersect_key($environment, $_SERVER); + + $environment = $_ENV + ([] === $serverEnvironment ? $environment : $serverEnvironment); + } + + if (array_key_exists($name, $environment)) { + return $this->normalizeAuditEnvironmentValue($environment[$name]); + } + + if (Platform::isWindows()) { + foreach ($environment as $key => $value) { + if (0 === strcasecmp((string) $key, $name)) { + return $this->normalizeAuditEnvironmentValue($value); + } + } + } + + return null; + } + + /** + * @return list + */ + private function getBunfigPaths(): array + { + $xdgConfigHome = $this->getAuditEnvironmentValue('XDG_CONFIG_HOME'); + $home = $this->getHomeDirectory(); + + $paths = []; + + if (null !== $xdgConfigHome) { + $paths[] = $this->getConfigurationPath($xdgConfigHome, '.bunfig.toml'); + } elseif (null !== $home) { + $paths[] = $this->getConfigurationPath($home, '.bunfig.toml'); + } + + $paths[] = $this->getRootPackagePath('bunfig.toml'); + + return $paths; + } + + private function getConfigurationPath(string $directory, string $file): string + { + return rtrim($directory, '/\\') . DIRECTORY_SEPARATOR . $file; + } + + private function getHomeDirectory(): string|null + { + $home = $this->getAuditEnvironmentValue('HOME') ?? $this->getAuditEnvironmentValue('USERPROFILE'); + + if (null !== $home) { + return $home; + } + + $homeDrive = $this->getAuditEnvironmentValue('HOMEDRIVE'); + $homePath = $this->getAuditEnvironmentValue('HOMEPATH'); + + return null !== $homeDrive && null !== $homePath ? $homeDrive . $homePath : null; + } + + /** + * @return list + */ + private function getNpmrcPaths(): array + { + $xdgConfigHome = $this->getAuditEnvironmentValue('XDG_CONFIG_HOME'); + $home = $this->getHomeDirectory(); + + $paths = []; + + if (null !== $xdgConfigHome) { + $xdgPath = $this->getConfigurationPath($xdgConfigHome, '.npmrc'); + + if (file_exists($xdgPath)) { + $paths[] = $xdgPath; + } elseif (null !== $home) { + $paths[] = $this->getConfigurationPath($home, '.npmrc'); + } + } elseif (null !== $home) { + $paths[] = $this->getConfigurationPath($home, '.npmrc'); + } + + $paths[] = $this->getRootPackagePath('.npmrc'); + + return $paths; + } + + private function isSingleLineTomlContainer(string $value): bool + { + $closingCharacters = []; + $quote = null; + + for ($position = 0, $length = strlen($value); $position < $length; ++$position) { + $character = $value[$position]; + + if ('\\' === $quote) { + $quote = '"'; + + continue; + } + + if (null !== $quote) { + if ('"' === $quote && '\\' === $character) { + $quote = '\\'; + } elseif ($quote === $character) { + $quote = null; + } + + continue; + } + + if ('"' === $character || '\'' === $character) { + $quote = $character; + + continue; + } + + if ('[' === $character) { + $closingCharacters[] = ']'; + + continue; + } + + if ('{' === $character) { + $closingCharacters[] = '}'; + + continue; + } + + if (']' === $character || '}' === $character) { + if ([] === $closingCharacters || $character !== array_pop($closingCharacters)) { + return false; + } + } + } + + return [] === $closingCharacters && null === $quote; + } + + private function normalizeAuditEnvironmentValue(mixed $value): string|null + { + if (!is_scalar($value) && !$value instanceof Stringable) { + return null; + } + + $value = (string) $value; + + return '' === $value ? null : $value; + } + + private function readAuditConfiguration(string $path): string|null + { + if (!file_exists($path)) { + return null; + } + + if (!is_file($path) || !is_readable($path)) { + throw new RuntimeException( + sprintf('The Bun audit configuration "%s" cannot be read.', $path), + ); + } + + $contents = file_get_contents($path); + + if (false === $contents) { + throw new RuntimeException( + sprintf('The Bun audit configuration "%s" cannot be read.', $path), + ); + } + + return $contents; + } + + private function rejectRestrictedAuditScope(string $path, string $setting): never + { + throw new RuntimeException( + sprintf( + 'The Bun audit cannot guarantee the requested dependency scope because "%s" declares "%s".', + $path, + $setting, + ), + ); + } + + private function rejectUnverifiableAuditConfiguration(string $path, string $reason): never + { + throw new RuntimeException( + sprintf('The Bun audit cannot verify dependency scope in "%s": %s.', $path, $reason), + ); + } + + private function stripLeadingUtf8Bom(string $contents): string + { + return Preg::replace('/\A(?:\xEF\xBB\xBF)++/', '', $contents); + } + + private function stripTomlComment(string $line): string + { + $quote = null; + + for ($position = 0, $length = strlen($line); $position < $length; ++$position) { + $character = $line[$position]; + + if ('\\' === $quote) { + $quote = '"'; + + continue; + } + + if (null !== $quote) { + if ('"' === $quote && '\\' === $character) { + $quote = '\\'; + } elseif ($quote === $character) { + $quote = null; + } + + continue; + } + + if ('"' === $character || '\'' === $character) { + $quote = $character; + + continue; + } + + if ('#' === $character) { + return substr($line, 0, $position); + } + } + + return $line; + } + + private function validateBunfigAuditScope(string $contents, string $path, bool $noDev): void + { + if (1 !== preg_match('//u', $contents)) { + $this->rejectUnverifiableAuditConfiguration($path, 'the configuration must be UTF-8'); + } + + $contents = $this->stripLeadingUtf8Bom($contents); + + $inInstallSection = false; + + $lines = Preg::split('/\R/u', $contents); + + foreach ($lines as $line) { + $line = trim($this->stripTomlComment($line)); + + if ('' === $line) { + continue; + } + + $assignmentPosition = strpos($line, '='); + $keyExpression = false === $assignmentPosition ? $line : substr($line, 0, $assignmentPosition); + + if (str_contains($keyExpression, '\\')) { + $this->rejectUnverifiableAuditConfiguration( + $path, + 'escape sequences in TOML keys are not supported; use canonical keys', + ); + } + + if ($inInstallSection && false !== $assignmentPosition) { + $value = ltrim(substr($line, $assignmentPosition + 1)); + + if (str_contains($value, '"""') || str_contains($value, "'''")) { + $this->rejectUnverifiableAuditConfiguration( + $path, + 'multiline strings in [install] are not supported', + ); + } + + if ( + '' !== $value + && ('[' === $value[0] || '{' === $value[0]) + && !$this->isSingleLineTomlContainer($value) + ) { + $this->rejectUnverifiableAuditConfiguration( + $path, + 'multiline container values in [install] are not supported', + ); + } + } + + if ('[' === $line[0]) { + $arrayTable = str_starts_with($line, '[['); + $tableName = trim(substr($line, $arrayTable ? 2 : 1, $arrayTable ? -2 : -1)); + + $inInstallSection = str_ends_with($line, ']') + && in_array($tableName, ['install', '"install"', "'install'"], true); + + if ($arrayTable && $inInstallSection) { + $this->rejectUnverifiableAuditConfiguration( + $path, + 'array install tables are not supported; use an [install] table', + ); + } + + continue; + } + + if ( + $inInstallSection + && 1 === preg_match( + '/^(?:"|\')?(production|dev|optional|peer)(?:"|\')?\s*=\s*(true|false)\b/', + $line, + $matches, + ) + ) { + $this->validateBunScopeSetting($matches[1], 'true' === $matches[2], $path, $noDev); + } + + if ( + 1 === preg_match( + '/^(?:"install"|\'install\'|install)\s*\.\s*(?:"|\')?' + . '(production|dev|optional|peer)(?:"|\')?\s*=\s*(true|false)\b/', + $line, + $matches, + ) + ) { + $this->validateBunScopeSetting($matches[1], 'true' === $matches[2], $path, $noDev); + } + + if ( + 1 === preg_match( + '/^(?:"install"|\'install\'|install)\s*=\s*\{/', + $line, + ) + ) { + $this->rejectUnverifiableAuditConfiguration( + $path, + 'inline install tables are not supported; use an [install] table', + ); + } + } + } + + private function validateBunScopeSetting(string $name, bool $value, string $path, bool $noDev): void + { + $includesRequiredScope = 'production' === $name ? !$value : $value; + $appliesToAudit = !$noDev || 'optional' === $name || 'peer' === $name; + + if ($appliesToAudit && !$includesRequiredScope) { + $this->rejectRestrictedAuditScope($path, 'install.' . $name . '=' . ($value ? 'true' : 'false')); + } + } + + private function validateNpmrcAuditScope(string $contents, string $path, bool $noDev): void + { + if (1 !== preg_match('//u', $contents)) { + $this->rejectUnverifiableAuditConfiguration($path, 'the configuration must be UTF-8'); + } + + $contents = $this->stripLeadingUtf8Bom($contents); + + $lines = Preg::split('/\R/u', $contents); + + foreach ($lines as $line) { + $line = ltrim($line); + + $assignmentPosition = strpos($line, '='); + $keyExpression = false === $assignmentPosition ? $line : substr($line, 0, $assignmentPosition); + + if ( + '' !== $keyExpression + && ('"' === $keyExpression[0] || '\'' === $keyExpression[0]) + && str_contains($keyExpression, '\\') + ) { + $this->rejectUnverifiableAuditConfiguration( + $path, + 'escape sequences in npmrc keys are not supported; use canonical keys', + ); + } + + if (false === $assignmentPosition) { + continue; + } + + $key = strtolower(trim($keyExpression, " \t'\"")); + + if ('omit' !== $key && 'omit[]' !== $key) { + continue; + } + + $value = trim(substr($line, $assignmentPosition + 1)); + + if (str_contains($value, '${')) { + $this->rejectRestrictedAuditScope($path, 'dynamic omit=' . $value); + } + + if (str_contains($value, '\\')) { + $this->rejectUnverifiableAuditConfiguration( + $path, + 'escape sequences in npmrc omit values are not supported; use canonical values', + ); + } + + $values = Preg::split('/[\s,;]+/u', $value); + + foreach ($values as $omitted) { + $omitted = strtolower(trim($omitted, " \t\n\r\0\x0B[]'\"")); + + if ('optional' === $omitted || 'peer' === $omitted || ('dev' === $omitted && !$noDev)) { + $this->rejectRestrictedAuditScope($path, "omit={$omitted}"); + } + } + } } } diff --git a/src/Asset/NpmManager.php b/src/Asset/NpmManager.php index 553bd12..81c4027 100644 --- a/src/Asset/NpmManager.php +++ b/src/Asset/NpmManager.php @@ -4,6 +4,16 @@ namespace Foxy\Asset; +use Composer\Util\ProcessExecutor; +use Foxy\Exception\RuntimeException; +use Foxy\Json\JsonFile; + +use function is_array; +use function is_string; +use function str_contains; +use function str_replace; +use function trim; + final class NpmManager extends AbstractAssetManager { public function getLockPackageName(): string @@ -18,7 +28,7 @@ public function getName(): string public function getVersionConstraint(): string { - return '^12.0.2'; + return '>=10.9.8'; } protected function actionWhenComposerDependenciesAreAlreadyInstalled(array $names): void @@ -28,6 +38,37 @@ protected function actionWhenComposerDependenciesAreAlreadyInstalled(array $name } } + protected function getAuditCommand(bool $noDev): string + { + $command = [ + 'audit', + '--json', + '--package-lock-only', + '--package-lock=true', + '--audit-level=info', + '--prefix=.', + ]; + + $workspaceSelectors = $this->getWorkspaceSelectors(); + + if ([] !== $workspaceSelectors) { + $command = [ + ...$command, + '--workspaces=true', + ...$workspaceSelectors, + '--include-workspace-root=true', + ]; + } + + if ($noDev) { + $command = [...$command, '--omit=dev', '--include=optional', '--include=peer']; + } else { + $command = [...$command, '--include=dev', '--include=optional', '--include=peer']; + } + + return $this->buildUnconfiguredCommand('npm', $command); + } + protected function getInstallCommand(): string { return $this->buildCommand('npm', 'install', 'install'); @@ -40,6 +81,104 @@ protected function getUpdateCommand(): string protected function getVersionCommand(): string { - return $this->buildCommand('npm', 'version', '--version'); + return $this->buildUnconfiguredCommand('npm', '--version'); + } + + /** + * @return list + */ + private function getWorkspacePatterns(mixed $workspaces): array + { + if (null === $workspaces) { + return []; + } + + if (!is_array($workspaces)) { + throw $this->workspaceEnumerationFailure(); + } + + if (array_is_list($workspaces)) { + $patterns = $workspaces; + } else { + $patterns = $workspaces['packages'] ?? null; + + if (!is_array($patterns) || !array_is_list($patterns)) { + throw $this->workspaceEnumerationFailure(); + } + } + + $validatedPatterns = []; + + foreach ($patterns as $pattern) { + if (!is_string($pattern) || '' === trim($pattern)) { + throw $this->workspaceEnumerationFailure(); + } + + $validatedPatterns[] = $pattern; + } + + return $validatedPatterns; + } + + /** + * @return list + */ + private function getWorkspaceSelectors(): array + { + $packageJson = new JsonFile($this->getPackageJsonPath(), null, $this->io); + + $workspacePatterns = $packageJson->exists() + ? $this->getWorkspacePatterns($packageJson->read()['workspaces'] ?? null) + : []; + + $lockFile = new JsonFile($this->getLockFilePath(), null, $this->io); + + $packages = $lockFile->read()['packages'] ?? null; + + $rootPackage = is_array($packages) ? ($packages[''] ?? null) : null; + $lockedWorkspacePatterns = is_array($rootPackage) + ? $this->getWorkspacePatterns($rootPackage['workspaces'] ?? null) + : []; + + if ([] === $workspacePatterns && [] === $lockedWorkspacePatterns) { + return []; + } + + if ($workspacePatterns !== $lockedWorkspacePatterns) { + throw $this->workspaceEnumerationFailure(); + } + + $selectors = []; + + foreach ($packages as $path => $package) { + $path = (string) $path; + + if ('' === $path || $this->isNodeModulesPath($path)) { + continue; + } + + $selectors[] = ProcessExecutor::escape('--workspace=' . $path); + } + + if ([] === $selectors) { + throw $this->workspaceEnumerationFailure(); + } + + return $selectors; + } + + private function isNodeModulesPath(string $path): bool + { + $path = '/' . str_replace('\\', '/', $path) . '/'; + + return str_contains($path, '/node_modules/'); + } + + private function workspaceEnumerationFailure(): RuntimeException + { + return new RuntimeException( + 'The npm workspace graph could not be enumerated from package-lock.json. ' + . 'Regenerate the lock file with a supported npm version.', + ); } } diff --git a/src/Asset/PnpmManager.php b/src/Asset/PnpmManager.php index ced2932..064992c 100644 --- a/src/Asset/PnpmManager.php +++ b/src/Asset/PnpmManager.php @@ -4,6 +4,8 @@ namespace Foxy\Asset; +use Composer\Util\ProcessExecutor; + final class PnpmManager extends AbstractAssetManager { public function getLockPackageName(): string @@ -26,6 +28,33 @@ public function isInstalled(): bool return parent::isInstalled() && file_exists($this->getLockFilePath()); } + protected function getAuditCommand(bool $noDev): string + { + $command = [ + 'audit', + '--json', + '--audit-level=info', + '--lockfile-dir=.', + '--ignore-pnpmfile', + '--only=null', + ]; + + if ($noDev) { + $command = [...$command, '--prod', '--optional=true']; + } else { + $command = [...$command, '--prod=false', '--dev=false', '--optional=true']; + } + + $command = [ + ...$command, + '--ignore-registry-errors=false', + '--ignore-unfixable=false', + ProcessExecutor::escape('--config.auditConfig={ignoreGhsas:[]}'), + ]; + + return $this->buildUnconfiguredCommand('pnpm', $command); + } + protected function getInstallCommand(): string { return $this->buildCommand('pnpm', 'install', 'install'); @@ -38,6 +67,6 @@ protected function getUpdateCommand(): string protected function getVersionCommand(): string { - return $this->buildCommand('pnpm', 'version', '--version'); + return $this->buildUnconfiguredCommand('pnpm', '--version'); } } diff --git a/src/Asset/YarnManager.php b/src/Asset/YarnManager.php index 4345181..8bbdea8 100644 --- a/src/Asset/YarnManager.php +++ b/src/Asset/YarnManager.php @@ -6,6 +6,11 @@ final class YarnManager extends AbstractAssetManager { + private const array AUDIT_ENVIRONMENT = [ + 'YARN_NPM_AUDIT_EXCLUDE_PACKAGES' => '__FOXY_AUDIT_NO_MATCH__', + 'YARN_NPM_AUDIT_IGNORE_ADVISORIES' => '__FOXY_AUDIT_NO_MATCH__', + ]; + public function getLockPackageName(): string { return 'yarn.lock'; @@ -31,6 +36,22 @@ public function isInstalled(): bool ); } + protected function getAuditCommand(bool $noDev): string + { + $command = ['npm', 'audit', '--all', '--recursive', '--json', '--no-deprecations', '--severity', 'info']; + + if ($noDev) { + $command = [...$command, '--environment', 'production']; + } + + return $this->buildUnconfiguredCommand('yarn', $command); + } + + protected function getAuditEnvironment(): array + { + return self::AUDIT_ENVIRONMENT; + } + protected function getInstallCommand(): string { return $this->buildCommand('yarn', 'install', 'install'); @@ -43,6 +64,6 @@ protected function getUpdateCommand(): string protected function getVersionCommand(): string { - return $this->buildCommand('yarn', 'version', '--version'); + return $this->buildUnconfiguredCommand('yarn', '--version'); } } diff --git a/src/Audit/AuditFinding.php b/src/Audit/AuditFinding.php new file mode 100644 index 0000000..990994c --- /dev/null +++ b/src/Audit/AuditFinding.php @@ -0,0 +1,47 @@ + $cves + * @param list $affectedVersions + * @param list $dependencyPaths + */ + public function __construct( + public string $package, + public Severity $severity, + public string $advisoryId, + public string|null $sourceId, + public string $title, + public string $vulnerableVersions, + public string|null $url = null, + public array $cves = [], + public CveStatus $cveStatus = CveStatus::NOT_REQUESTED, + public array $affectedVersions = [], + public array $dependencyPaths = [], + ) {} + + /** + * @param list $cves + */ + public function withCveResolution(array $cves, CveStatus $status): self + { + return new self( + $this->package, + $this->severity, + $this->advisoryId, + $this->sourceId, + $this->title, + $this->vulnerableVersions, + $this->url, + $cves, + $status, + $this->affectedVersions, + $this->dependencyPaths, + ); + } +} diff --git a/src/Audit/AuditFormat.php b/src/Audit/AuditFormat.php new file mode 100644 index 0000000..302022c --- /dev/null +++ b/src/Audit/AuditFormat.php @@ -0,0 +1,13 @@ + $this->writeTable($report, $output), + AuditFormat::PLAIN => $this->writePlain($report, $output), + AuditFormat::JSON => $this->writeJson($report, $minimumSeverity, $output), + AuditFormat::SUMMARY => $this->writeSummary($report, $output), + }; + } + + private function formatCves(AuditFinding $finding): string + { + return match ($finding->cveStatus) { + CveStatus::RESOLVED => implode(', ', $finding->cves), + CveStatus::NONE_ASSIGNED => 'None assigned', + CveStatus::UNAVAILABLE => 'Unavailable', + CveStatus::NOT_REQUESTED => 'Not requested', + }; + } + + /** + * @throws JsonException + */ + private function writeJson(AuditReport $report, Severity $minimumSeverity, OutputInterface $output): void + { + $advisories = array_map( + static fn(AuditFinding $finding): array => [ + 'package' => $finding->package, + 'severity' => $finding->severity->value, + 'advisory_id' => $finding->advisoryId, + 'source_id' => $finding->sourceId, + 'cves' => $finding->cves, + 'cve_status' => $finding->cveStatus->value, + 'title' => $finding->title, + 'url' => $finding->url, + 'vulnerable_versions' => $finding->vulnerableVersions, + 'affected_versions' => $finding->affectedVersions, + 'dependency_paths' => $finding->dependencyPaths, + ], + $report->findings, + ); + $document = [ + 'schema_version' => 1, + 'manager' => $report->manager, + 'audit_level' => $minimumSeverity->value, + 'affected' => $report->hasFindingAtLeast($minimumSeverity), + 'advisories' => $advisories, + 'summary' => [ + 'total' => count($report->findings), + 'packages' => $report->countPackages(), + 'severity' => $report->countSeverities(), + ], + ]; + + $output->writeln( + json_encode($document, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), + OutputInterface::OUTPUT_RAW, + ); + } + + private function writePlain(AuditReport $report, OutputInterface $output): void + { + if ([] === $report->findings) { + $output->writeln('No known frontend vulnerabilities found.', OutputInterface::OUTPUT_RAW); + + return; + } + + foreach ($report->findings as $finding) { + $fields = [ + $finding->severity->value, + $finding->package, + $finding->advisoryId, + $this->formatCves($finding), + $finding->vulnerableVersions, + $finding->title, + ]; + + if (null !== $finding->url) { + $fields[] = $finding->url; + } + + $output->writeln(implode(' | ', $fields), OutputInterface::OUTPUT_RAW); + } + + $this->writeSummary($report, $output); + } + + private function writeSummary(AuditReport $report, OutputInterface $output): void + { + $total = count($report->findings); + + if (0 === $total) { + $output->writeln('No known frontend vulnerabilities found.', OutputInterface::OUTPUT_RAW); + + return; + } + + $severity = []; + + foreach ($report->countSeverities() as $name => $count) { + if ($count > 0) { + $severity[] = sprintf('%s: %d', $name, $count); + } + } + + $packages = $report->countPackages(); + + $output->writeln( + sprintf( + '%d %s affecting %d %s (%s).', + $total, + 1 === $total ? 'advisory' : 'advisories', + $packages, + 1 === $packages ? 'package' : 'packages', + implode(', ', $severity), + ), + OutputInterface::OUTPUT_RAW, + ); + } + + private function writeTable(AuditReport $report, OutputInterface $output): void + { + if ([] === $report->findings) { + $output->writeln('No known frontend vulnerabilities found.'); + + return; + } + + $rows = []; + + foreach ($report->findings as $finding) { + $advisory = $finding->advisoryId; + + if (null !== $finding->url) { + $advisory .= "\n{$finding->url}"; + } + + $rows[] = array_map( + OutputFormatter::escape(...), + [ + $finding->severity->value, + $finding->package, + $advisory, + $this->formatCves($finding), + $finding->vulnerableVersions, + $finding->title, + ], + ); + } + + (new Table($output)) + ->setHeaders(['Severity', 'Package', 'Advisory', 'CVE', 'Vulnerable versions', 'Title']) + ->setRows($rows) + ->render(); + + $this->writeSummary($report, $output); + } +} diff --git a/src/Audit/AuditParserFactory.php b/src/Audit/AuditParserFactory.php new file mode 100644 index 0000000..476aab2 --- /dev/null +++ b/src/Audit/AuditParserFactory.php @@ -0,0 +1,26 @@ + new NpmAuditParser(), + 'pnpm' => new PnpmAuditParser(), + 'yarn' => new YarnAuditParser(), + 'bun' => new BunAuditParser(), + default => throw new RuntimeException( + sprintf('The asset manager "%s" does not provide a supported audit report.', $manager), + ), + }; + } +} diff --git a/src/Audit/AuditParserInterface.php b/src/Audit/AuditParserInterface.php new file mode 100644 index 0000000..5d4b26b --- /dev/null +++ b/src/Audit/AuditParserInterface.php @@ -0,0 +1,13 @@ + + */ + public function parse(string $output): array; +} diff --git a/src/Audit/AuditProcessResult.php b/src/Audit/AuditProcessResult.php new file mode 100644 index 0000000..510d9ed --- /dev/null +++ b/src/Audit/AuditProcessResult.php @@ -0,0 +1,10 @@ + $findings + */ + public function __construct(public string $manager, public array $findings, public string $diagnostics = '') {} + + public function countPackages(): int + { + return count( + array_unique( + array_map( + static fn(AuditFinding $finding): string => $finding->package, + $this->findings, + ), + ), + ); + } + + /** + * @return array + */ + public function countSeverities(): array + { + $counts = [ + Severity::CRITICAL->value => 0, + Severity::HIGH->value => 0, + Severity::MODERATE->value => 0, + Severity::LOW->value => 0, + Severity::INFO->value => 0, + ]; + + foreach ($this->findings as $finding) { + ++$counts[$finding->severity->value]; + } + + return $counts; + } + + public function hasFindingAtLeast(Severity $minimumSeverity): bool + { + foreach ($this->findings as $finding) { + if ($finding->severity->isAtLeast($minimumSeverity)) { + return true; + } + } + + return false; + } + + /** + * @param list $findings + */ + public function withFindings(array $findings): self + { + return new self($this->manager, $findings, $this->diagnostics); + } +} diff --git a/src/Audit/AuditRequest.php b/src/Audit/AuditRequest.php new file mode 100644 index 0000000..12c41af --- /dev/null +++ b/src/Audit/AuditRequest.php @@ -0,0 +1,10 @@ +manager->getName(); + $result = $this->manager->audit($request->noDev); + + $diagnostics = trim($result->errorOutput); + + if (!in_array($result->exitCode, [0, 1], true)) { + throw $this->executionFailure($manager, $result); + } + + if ('bun' === $manager && '' !== $diagnostics) { + throw new RuntimeException( + "The bun audit command produced diagnostics and may have returned a partial report. {$diagnostics}", + ); + } + + try { + $findings = AuditParserFactory::create($manager)->parse($result->output); + } catch (Throwable $exception) { + $message = $exception->getMessage(); + + if ($diagnostics !== '') { + $message .= " Manager error: {$diagnostics}"; + } + + throw new RuntimeException($message, previous: $exception); + } + + $findings = $this->normalize($findings); + + $report = new AuditReport($manager, $findings, $diagnostics); + + if (1 === $result->exitCode && [] === $findings) { + throw $this->executionFailure($manager, $result); + } + + if (0 === $result->exitCode && $report->hasFindingAtLeast(Severity::LOW)) { + throw new RuntimeException( + sprintf('The %s audit report contains vulnerabilities but the manager returned a successful status.', $manager), + ); + } + + return $report; + } + + private function executionFailure(string $manager, AuditProcessResult $result): RuntimeException + { + $message = sprintf('The %s audit command failed with status code %d.', $manager, $result->exitCode); + $diagnostics = trim($result->errorOutput); + + if ($diagnostics !== '') { + $message .= " {$diagnostics}"; + } + + return new RuntimeException($message, $result->exitCode); + } + + /** + * @param list $left + * @param list $right + * + * @return list + */ + private function mergeStrings(array $left, array $right): array + { + $values = [...$left, ...$right]; + + $values = array_unique($values); + sort($values); + + return $values; + } + + /** + * @param list $findings + * + * @return list + */ + private function normalize(array $findings): array + { + $normalized = []; + + foreach ($findings as $finding) { + $key = "{$finding->package}\0{$finding->advisoryId}\0{$finding->vulnerableVersions}"; + $existing = $normalized[$key] ?? null; + + if (!$existing instanceof AuditFinding) { + $normalized[$key] = $finding; + + continue; + } + + $selected = $finding->severity->isAtLeast($existing->severity) ? $finding : $existing; + $cves = $this->mergeStrings($existing->cves, $finding->cves); + + $normalized[$key] = new AuditFinding( + $selected->package, + $selected->severity, + $selected->advisoryId, + $selected->sourceId, + $selected->title, + $selected->vulnerableVersions, + $selected->url, + $cves, + [] === $cves ? $selected->cveStatus : CveStatus::RESOLVED, + $this->mergeStrings($existing->affectedVersions, $finding->affectedVersions), + $this->mergeStrings($existing->dependencyPaths, $finding->dependencyPaths), + ); + } + + usort( + $normalized, + static fn(AuditFinding $left, AuditFinding $right): int => [ + -$left->severity->weight(), + $left->package, + $left->advisoryId, + ] <=> [ + -$right->severity->weight(), + $right->package, + $right->advisoryId, + ], + ); + + return $normalized; + } +} diff --git a/src/Audit/AuditRunnerInterface.php b/src/Audit/AuditRunnerInterface.php new file mode 100644 index 0000000..91fa512 --- /dev/null +++ b/src/Audit/AuditRunnerInterface.php @@ -0,0 +1,10 @@ + $cache */ + $cache = []; + $findings = []; + + foreach ($report->findings as $finding) { + if ([] !== $finding->cves) { + $findings[] = $finding->withCveResolution($finding->cves, CveStatus::RESOLVED); + + continue; + } + + $ghsaId = $this->getGhsaId($finding->advisoryId); + + if (null === $ghsaId) { + $findings[] = $finding->withCveResolution([], CveStatus::UNAVAILABLE); + + continue; + } + + if (!isset($cache[$ghsaId])) { + try { + $cache[$ghsaId] = $this->resolver->resolve($ghsaId); + } catch (Throwable $exception) { + $warningHandler(sprintf('Unable to resolve CVE identifiers for %s: %s', $ghsaId, $exception->getMessage())); + $cache[$ghsaId] = new CveResolution([], CveStatus::UNAVAILABLE); + } + } + + $resolution = $cache[$ghsaId]; + $findings[] = $finding->withCveResolution($resolution->cves, $resolution->status); + } + + return $report->withFindings($findings); + } + + private function getGhsaId(string $advisoryId): string|null + { + if (1 !== preg_match('/^GHSA-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/i', $advisoryId)) { + return null; + } + + return $advisoryId; + } +} diff --git a/src/Audit/CveResolution.php b/src/Audit/CveResolution.php new file mode 100644 index 0000000..0269843 --- /dev/null +++ b/src/Audit/CveResolution.php @@ -0,0 +1,13 @@ + $cves + */ + public function __construct(public array $cves, public CveStatus $status) {} +} diff --git a/src/Audit/CveResolverInterface.php b/src/Audit/CveResolverInterface.php new file mode 100644 index 0000000..1bb4ec5 --- /dev/null +++ b/src/Audit/CveResolverInterface.php @@ -0,0 +1,10 @@ +normalizeGhsaId($ghsaId); + + $response = $this->httpDownloader->get( + sprintf(self::API_URL, $ghsaId), + [ + 'http' => [ + 'header' => [ + 'Accept: application/vnd.github+json', + 'X-GitHub-Api-Version: 2022-11-28', + ], + ], + ], + ); + $data = $response->decodeJson(); + + if (!is_array($data)) { + throw new RuntimeException( + sprintf('GitHub returned an invalid advisory document for %s.', $ghsaId), + ); + } + + $responseGhsaId = $data['ghsa_id'] ?? null; + + if (!is_string($responseGhsaId) || $this->normalizeGhsaId($responseGhsaId) !== $ghsaId) { + throw new RuntimeException( + sprintf('GitHub returned a mismatched advisory document for %s.', $ghsaId), + ); + } + + $cves = []; + $identifiers = $data['identifiers'] ?? []; + + if (!is_array($identifiers) || !array_is_list($identifiers)) { + throw new RuntimeException( + sprintf('GitHub returned invalid identifiers for %s.', $ghsaId), + ); + } + + foreach ($identifiers as $identifier) { + if (!is_array($identifier) || 'CVE' !== ($identifier['type'] ?? null)) { + continue; + } + + $value = $identifier['value'] ?? null; + + if (is_string($value) && $this->isCve($value)) { + $cves[] = strtoupper(trim($value)); + } + } + + $cveId = $data['cve_id'] ?? null; + + if (is_string($cveId) && $this->isCve($cveId)) { + $cves[] = strtoupper(trim($cveId)); + } + + $cves = array_unique($cves); + sort($cves); + + return new CveResolution( + $cves, + [] === $cves ? CveStatus::NONE_ASSIGNED : CveStatus::RESOLVED, + ); + } + + private function isCve(string $value): bool + { + return 1 === preg_match('/^CVE-\d{4}-\d{4,}$/i', trim($value)); + } + + private function normalizeGhsaId(string $ghsaId): string + { + $ghsaId = trim($ghsaId); + + if (1 !== preg_match('/^GHSA-([A-Z0-9]{4})-([A-Z0-9]{4})-([A-Z0-9]{4})$/i', $ghsaId, $matches)) { + throw new RuntimeException( + sprintf('The advisory identifier "%s" is not a valid GHSA identifier.', $ghsaId), + ); + } + + return 'GHSA-' . strtolower("{$matches[1]}-{$matches[2]}-{$matches[3]}"); + } +} diff --git a/src/Audit/Parser/AbstractAuditParser.php b/src/Audit/Parser/AbstractAuditParser.php new file mode 100644 index 0000000..f713e03 --- /dev/null +++ b/src/Audit/Parser/AbstractAuditParser.php @@ -0,0 +1,252 @@ + self::MAX_OUTPUT_BYTES) { + throw $this->malformed('the report exceeds the 16 MiB safety limit'); + } + } + + /** + * @return array + */ + final protected function decodeObject(string $output): array + { + $this->assertOutputSize($output); + $output = trim($output); + + if ($output === '' || $output[0] !== '{') { + throw $this->malformed('expected a JSON object'); + } + + try { + $data = json_decode($output, flags: JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw $this->malformed('invalid JSON', $exception); + } + + return get_object_vars($data); + } + + final protected function getAdvisoryId(string $sourceId, string|null $candidate, string|null $url): string + { + return $this->getGhsaId($candidate) ?? $this->getGhsaId($url) ?? $sourceId; + } + + final protected function getBoolean(array $data, string $key, string $context): bool + { + $value = $data[$key] ?? null; + + if (!is_bool($value)) { + throw $this->malformed(sprintf('%s.%s must be a boolean', $context, $key)); + } + + return $value; + } + + /** + * @return list + */ + final protected function getCves(mixed $value, string $context): array + { + if (null === $value) { + return []; + } + + $cves = $this->getStringList($value, $context); + + foreach ($cves as $cve) { + if (1 !== preg_match('/^CVE-\d{4}-\d{4,}$/i', $cve)) { + throw $this->malformed(sprintf('%s contains an invalid CVE identifier', $context)); + } + } + + return $this->uniqueStrings(array_map(strtoupper(...), $cves)); + } + + final protected function getGhsaId(string|null $value): string|null + { + if ( + null === $value + || 1 !== preg_match( + '{^(?:https://github\.com/advisories/)?GHSA-([A-Z0-9]{4})-([A-Z0-9]{4})-([A-Z0-9]{4})/?$}i', + $value, + $matches, + ) + ) { + return null; + } + + return 'GHSA-' . strtolower($matches[1] . '-' . $matches[2] . '-' . $matches[3]); + } + + final protected function getNonNegativeInteger(array $data, string $key, string $context): int + { + $value = $data[$key] ?? null; + + if (!is_int($value) || $value < 0) { + throw $this->malformed(sprintf('%s.%s must be a non-negative integer', $context, $key)); + } + + return $value; + } + + /** + * @return array + */ + final protected function getObject(mixed $value, string $context): array + { + if (!$value instanceof stdClass) { + throw $this->malformed(sprintf('%s must be an object', $context)); + } + + return get_object_vars($value); + } + + final protected function getOptionalString(array $data, string $key, string $context): string|null + { + if (!isset($data[$key])) { + return null; + } + + $value = $data[$key]; + + if (!is_string($value)) { + throw $this->malformed(sprintf('%s.%s must be a string', $context, $key)); + } + + return trim($value) === '' ? null : $this->sanitizeString($value); + } + + final protected function getSeverity(mixed $value, string $context): Severity + { + if (!is_string($value) || null === $severity = Severity::tryFrom(strtolower($value))) { + throw $this->malformed(sprintf('%s has an unsupported severity', $context)); + } + + return $severity; + } + + final protected function getSeverityCount(array $metadata, string $context, bool $requireTotal): int + { + $counts = $this->getObject($metadata['vulnerabilities'] ?? null, $context . '.vulnerabilities'); + $total = 0; + + foreach (['info', 'low', 'moderate', 'high', 'critical'] as $severity) { + $total += $this->getNonNegativeInteger($counts, $severity, $context . '.vulnerabilities'); + } + + if ($requireTotal && $this->getNonNegativeInteger($counts, 'total', $context . '.vulnerabilities') !== $total) { + throw $this->malformed(sprintf('%s.vulnerabilities.total must equal the severity counts', $context)); + } + + return $total; + } + + final protected function getSourceId(mixed $value, string $context): string + { + if (!is_int($value) && !is_string($value)) { + throw $this->malformed(sprintf('%s must be a string or integer', $context)); + } + + $sourceId = $this->sanitizeString((string) $value); + + if ($sourceId === '') { + throw $this->malformed(sprintf('%s must not be empty', $context)); + } + + return $sourceId; + } + + final protected function getString(array $data, string $key, string $context, bool $allowEmpty = false): string + { + $value = $data[$key] ?? null; + + if (!is_string($value) || (!$allowEmpty && trim($value) === '')) { + throw $this->malformed(sprintf('%s.%s must be a string', $context, $key)); + } + + return $this->sanitizeString($value); + } + + /** + * @return list + */ + final protected function getStringList(mixed $value, string $context): array + { + if (!is_array($value)) { + throw $this->malformed(sprintf('%s must be a list', $context)); + } + + foreach ($value as $index => $item) { + if (!is_string($item)) { + throw $this->malformed(sprintf('%s must contain only strings', $context)); + } + + $value[$index] = $this->sanitizeString($item); + } + + /** @var list $value */ + return $this->uniqueStrings($value); + } + + final protected function malformed(string $reason, \Throwable|null $previous = null): RuntimeException + { + return new RuntimeException( + sprintf('The %s audit output is malformed: %s.', $this->getManagerName(), $reason), + previous: $previous, + ); + } + + final protected function sanitizeString(string $value): string + { + return trim((string) preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $value)); + } + + /** + * @param list $values + * + * @return list + */ + final protected function uniqueStrings(array $values): array + { + $values = array_unique($values); + sort($values); + + return $values; + } +} diff --git a/src/Audit/Parser/BunAuditParser.php b/src/Audit/Parser/BunAuditParser.php new file mode 100644 index 0000000..00c6451 --- /dev/null +++ b/src/Audit/Parser/BunAuditParser.php @@ -0,0 +1,51 @@ +decodeObject($output); + + $findings = []; + + foreach ($packages as $package => $advisories) { + if ($package === '' || !is_array($advisories)) { + throw $this->malformed('each package must contain a list of advisories'); + } + + $package = (string) $package; + + foreach ($advisories as $index => $advisory) { + $context = sprintf('%s.%d', $package, $index); + $advisory = $this->getObject($advisory, $context); + $sourceId = $this->getSourceId($advisory['id'] ?? null, $context . '.id'); + $url = $this->getOptionalString($advisory, 'url', $context); + + $findings[] = new AuditFinding( + $this->sanitizeString($package), + $this->getSeverity($advisory['severity'] ?? null, $context), + $this->getAdvisoryId($sourceId, null, $url), + $sourceId, + $this->getOptionalString($advisory, 'title', $context) ?? 'Vulnerability found', + $this->getString($advisory, 'vulnerable_versions', $context), + $url, + ); + } + } + + return $findings; + } + + protected function getManagerName(): string + { + return 'bun'; + } +} diff --git a/src/Audit/Parser/NpmAuditParser.php b/src/Audit/Parser/NpmAuditParser.php new file mode 100644 index 0000000..67b205d --- /dev/null +++ b/src/Audit/Parser/NpmAuditParser.php @@ -0,0 +1,157 @@ +decodeObject($output); + + $this->validateReportHeader($data); + + $metadata = $this->getObject($data['metadata'] ?? null, 'metadata'); + $vulnerabilities = $this->getObject($data['vulnerabilities'] ?? null, 'vulnerabilities'); + + $findings = []; + + foreach ($vulnerabilities as $package => $vulnerability) { + foreach ($this->parseVulnerability($package, $vulnerability) as $finding) { + $findings[] = $finding; + } + } + + $this->validateMetadata($metadata, count($vulnerabilities)); + + return $findings; + } + + protected function getManagerName(): string + { + return 'npm'; + } + + /** + * @param list $via + * @param list $paths + * + * @return list + */ + private function parseViaAdvisories(array $via, string $package, array $paths, string $context): array + { + $findings = []; + + foreach ($via as $index => $advisory) { + if (is_string($advisory)) { + continue; + } + + if (!$advisory instanceof stdClass) { + throw $this->malformed(sprintf('%s.via.%d must be a string or object', $context, $index)); + } + + $advisoryContext = sprintf('%s.via.%d', $context, $index); + + $advisory = $this->getObject($advisory, $advisoryContext); + $sourceId = $this->getSourceId($advisory['source'] ?? null, "{$advisoryContext}.source"); + $url = $this->getOptionalString($advisory, 'url', $advisoryContext); + + $findings[] = new AuditFinding( + $this->sanitizeString($package), + $this->getSeverity($advisory['severity'] ?? null, $advisoryContext), + $this->getAdvisoryId($sourceId, null, $url), + $sourceId, + $this->getOptionalString($advisory, 'title', $advisoryContext) ?? 'Vulnerability found', + $this->getString($advisory, 'range', $advisoryContext), + $url, + dependencyPaths: $paths, + ); + } + + return $findings; + } + + /** + * @return list + */ + private function parseVulnerability(int|string $package, mixed $vulnerability): array + { + if ('' === $package) { + throw $this->malformed('each vulnerability must be keyed by a package name'); + } + + $package = (string) $package; + + $context = sprintf('vulnerabilities.%s', $package); + + $vulnerability = $this->getObject($vulnerability, $context); + $name = $this->getString($vulnerability, 'name', $context); + + if ($package !== $name) { + throw $this->malformed(sprintf('%s.name must match its vulnerability key', $context)); + } + + $this->getSeverity($vulnerability['severity'] ?? null, $context); + $this->getBoolean($vulnerability, 'isDirect', $context); + + $via = $vulnerability['via'] ?? null; + + if (!is_array($via) || [] === $via) { + throw $this->malformed(sprintf('%s.via must be a non-empty list', $context)); + } + + $this->getStringList($vulnerability['effects'] ?? null, $context . '.effects'); + $this->getString($vulnerability, 'range', $context); + $paths = $this->getStringList($vulnerability['nodes'] ?? null, "{$context}.nodes"); + + $fixAvailable = $vulnerability['fixAvailable'] ?? null; + + if (!is_bool($fixAvailable) && !$fixAvailable instanceof stdClass) { + throw $this->malformed(sprintf('%s.fixAvailable must be a boolean or object', $context)); + } + + return $this->parseViaAdvisories($via, $package, $paths, $context); + } + + /** + * @param array $metadata + */ + private function validateMetadata(array $metadata, int $vulnerabilityCount): void + { + $severityTotal = $this->getSeverityCount($metadata, 'metadata', true); + $dependencies = $this->getObject($metadata['dependencies'] ?? null, 'metadata.dependencies'); + + foreach (['prod', 'dev', 'optional', 'peer', 'peerOptional', 'total'] as $dependencyType) { + $this->getNonNegativeInteger($dependencies, $dependencyType, 'metadata.dependencies'); + } + + if ($severityTotal !== $vulnerabilityCount) { + throw $this->malformed('metadata vulnerability counts must equal the vulnerability entries'); + } + } + + /** + * @param array $data + */ + private function validateReportHeader(array $data): void + { + if (isset($data['error'])) { + throw $this->malformed('the manager returned an error document'); + } + + if (2 !== ($data['auditReportVersion'] ?? null)) { + throw $this->malformed('auditReportVersion must be 2'); + } + } +} diff --git a/src/Audit/Parser/PnpmAuditParser.php b/src/Audit/Parser/PnpmAuditParser.php new file mode 100644 index 0000000..41bdfdf --- /dev/null +++ b/src/Audit/Parser/PnpmAuditParser.php @@ -0,0 +1,150 @@ +decodeObject($output); + + if (isset($data['error'])) { + throw $this->malformed('the manager returned an error document'); + } + + $advisories = $this->getObject($data['advisories'] ?? null, 'advisories'); + $metadata = $this->getObject($data['metadata'] ?? null, 'metadata'); + + $findings = []; + + foreach ($advisories as $key => $advisory) { + $context = sprintf('advisories.%s', $key); + + $advisory = $this->getObject($advisory, $context); + + ['sourceId' => $sourceId, 'url' => $url, 'ghsaId' => $ghsaId] = $this->parseAdvisoryHeader( + $advisory, + $key, + $context, + ); + + [$versions, $paths] = $this->parseFindings($advisory, $context); + + $cves = $this->getCves($advisory['cves'] ?? null, $context . '.cves'); + + $findings[] = new AuditFinding( + $this->getString($advisory, 'module_name', $context), + $this->getSeverity($advisory['severity'] ?? null, $context), + $this->getAdvisoryId($sourceId, $ghsaId, $url), + $sourceId, + $this->getString($advisory, 'title', $context, true), + $this->getString($advisory, 'vulnerable_versions', $context), + $url, + $cves, + [] === $cves ? CveStatus::NOT_REQUESTED : CveStatus::RESOLVED, + affectedVersions: $this->uniqueStrings($versions), + dependencyPaths: $this->uniqueStrings($paths), + ); + } + + $this->validateMetadata($metadata, count($advisories)); + + return $findings; + } + + protected function getManagerName(): string + { + return 'pnpm'; + } + + /** + * @param array $advisory + * + * @return array{sourceId: string, url: string|null, ghsaId: string|null} + */ + private function parseAdvisoryHeader(array $advisory, int|string $key, string $context): array + { + $id = $advisory['id'] ?? null; + + if (!is_int($id) || $id < 0) { + throw $this->malformed(sprintf('%s.id must be a non-negative integer', $context)); + } + + $sourceId = (string) $id; + + if ((string) $key !== $sourceId) { + throw $this->malformed(sprintf('%s.id must match its advisory key', $context)); + } + + $url = $this->getString($advisory, 'url', $context, true); + + $url = '' === $url ? null : $url; + + $ghsaId = $this->getString($advisory, 'github_advisory_id', $context, true); + + $ghsaId = '' === $ghsaId ? null : $ghsaId; + + $this->getString($advisory, 'cwe', $context, true); + + return ['sourceId' => $sourceId, 'url' => $url, 'ghsaId' => $ghsaId]; + } + + /** + * @param array $advisory + * + * @return array{list, list} + */ + private function parseFindings(array $advisory, string $context): array + { + $findingsData = $advisory['findings'] ?? null; + + if (!is_array($findingsData) || [] === $findingsData) { + throw $this->malformed(sprintf('%s.findings must be a non-empty list', $context)); + } + + $versions = []; + $paths = []; + + foreach ($findingsData as $index => $finding) { + $findingContext = sprintf('%s.findings.%d', $context, $index); + + $finding = $this->getObject($finding, $findingContext); + $versions[] = $this->getString($finding, 'version', $findingContext); + $paths = [ + ...$paths, + ...$this->getStringList($finding['paths'] ?? null, $findingContext . '.paths'), + ]; + + $this->getBoolean($finding, 'dev', $findingContext); + $this->getBoolean($finding, 'optional', $findingContext); + $this->getBoolean($finding, 'bundled', $findingContext); + } + + return [$versions, $paths]; + } + + /** + * @param array $metadata + */ + private function validateMetadata(array $metadata, int $advisoryCount): void + { + $severityTotal = $this->getSeverityCount($metadata, 'metadata', false); + + foreach (['dependencies', 'devDependencies', 'optionalDependencies', 'totalDependencies'] as $dependencyType) { + $this->getNonNegativeInteger($metadata, $dependencyType, 'metadata'); + } + + if ($severityTotal !== $advisoryCount) { + throw $this->malformed('metadata vulnerability counts must equal the advisory entries'); + } + } +} diff --git a/src/Audit/Parser/YarnAuditParser.php b/src/Audit/Parser/YarnAuditParser.php new file mode 100644 index 0000000..633f419 --- /dev/null +++ b/src/Audit/Parser/YarnAuditParser.php @@ -0,0 +1,71 @@ +assertOutputSize($output); + $output = trim($output); + + if ($output === '') { + return []; + } + + $findings = []; + + foreach (explode("\n", $output) as $lineNumber => $line) { + try { + $data = json_decode($line, false, flags: JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw $this->malformed(sprintf('line %d contains invalid JSON', $lineNumber + 1), $exception); + } + + if (!$data instanceof stdClass) { + throw $this->malformed(sprintf('line %d is not an audit finding', $lineNumber + 1)); + } + + $data = get_object_vars($data); + $context = sprintf('line %d.children', $lineNumber + 1); + + $children = $this->getObject($data['children'] ?? null, $context); + $package = $this->getString($data, 'value', sprintf('line %d', $lineNumber + 1)); + $sourceId = $this->getSourceId($children['ID'] ?? null, $context . '.ID'); + $url = $this->getOptionalString($children, 'URL', $context); + + $findings[] = new AuditFinding( + $package, + $this->getSeverity($children['Severity'] ?? null, $context), + $this->getAdvisoryId($sourceId, null, $url), + $sourceId, + $this->getString($children, 'Issue', $context), + $this->getString($children, 'Vulnerable Versions', $context), + $url, + affectedVersions: $this->getStringList($children['Tree Versions'] ?? null, $context . '.Tree Versions'), + dependencyPaths: $this->getStringList($children['Dependents'] ?? null, $context . '.Dependents'), + ); + } + + return $findings; + } + + protected function getManagerName(): string + { + return 'yarn'; + } +} diff --git a/src/Audit/Severity.php b/src/Audit/Severity.php new file mode 100644 index 0000000..2830fc6 --- /dev/null +++ b/src/Audit/Severity.php @@ -0,0 +1,30 @@ +weight() >= $threshold->weight(); + } + + public function weight(): int + { + return match ($this) { + self::INFO => 0, + self::LOW => 1, + self::MODERATE => 2, + self::HIGH => 3, + self::CRITICAL => 4, + }; + } +} diff --git a/src/Command/AuditCommand.php b/src/Command/AuditCommand.php new file mode 100644 index 0000000..be44727 --- /dev/null +++ b/src/Command/AuditCommand.php @@ -0,0 +1,148 @@ +setName('foxy:audit') + ->setDescription('Checks frontend dependencies for known security vulnerabilities') + ->setDefinition([ + new InputOption('no-dev', null, InputOption::VALUE_NONE, 'Excludes development dependencies.'), + new InputOption( + 'format', + 'f', + InputOption::VALUE_REQUIRED, + 'Output format: table, plain, json, or summary.', + AuditFormat::TABLE->value, + ), + new InputOption( + 'audit-level', + null, + InputOption::VALUE_REQUIRED, + 'Minimum severity that makes the command fail: low, moderate, high, or critical.', + Severity::LOW->value, + ), + new InputOption( + 'no-cve', + null, + InputOption::VALUE_NONE, + 'Skips GitHub advisory lookups for CVE identifiers.', + ), + ]) + ->setHelp( + <<foxy:audit command audits the selected frontend manager's lock file. + + It reports all known advisories and uses --audit-level only to determine the exit status. + Exit status 0 means no advisory met the threshold, 1 means at least one advisory met it, and 2 means the audit could not be completed reliably. + HELP, + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $formatValue = $input->getOption('format'); + $severityValue = $input->getOption('audit-level'); + + $format = is_string($formatValue) ? AuditFormat::tryFrom($formatValue) : null; + $minimumSeverity = is_string($severityValue) ? Severity::tryFrom($severityValue) : null; + + if (null === $format) { + $this->getIO()->writeError('The audit output format must be table, plain, json, or summary.'); + + return self::STATUS_FAILED; + } + + if (null === $minimumSeverity || Severity::INFO === $minimumSeverity) { + $this->getIO()->writeError( + 'The audit level must be low, moderate, high, or critical.', + ); + + return self::STATUS_FAILED; + } + + try { + $report = $this->runner->audit( + new AuditRequest($minimumSeverity, true === $input->getOption('no-dev')), + ); + + if ($report->diagnostics !== '') { + $this->getIO()->writeError(OutputFormatter::escape($this->sanitize($report->diagnostics))); + } + + if (true !== $input->getOption('no-cve') && AuditFormat::SUMMARY !== $format && [] !== $report->findings) { + $resolver = $this->cveResolver ?? new GitHubAdvisoryCveResolver( + $this->requireComposer()->getLoop()->getHttpDownloader(), + ); + $report = (new CveEnricher($resolver))->enrich( + $report, + function (string $warning): void { + $this->getIO()->writeError( + '' . OutputFormatter::escape($this->sanitize($warning)) . '', + ); + }, + ); + } + + $this->formatter->write($report, $minimumSeverity, $format, $output); + + return $report->hasFindingAtLeast($minimumSeverity) + ? self::STATUS_VULNERABLE + : self::STATUS_OK; + } catch (Throwable $exception) { + $message = OutputFormatter::escape($this->sanitize($exception->getMessage())); + + $this->getIO()->writeError('Foxy audit failed: ' . $message . ''); + + return self::STATUS_FAILED; + } + } + + private function sanitize(string $value): string + { + $sanitized = preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $value); + + if (null === $sanitized) { + $sanitized = preg_replace('/[\x00-\x1F\x7F]+/', ' ', $value) ?? $value; + } + + return trim($sanitized); + } +} diff --git a/src/Command/FoxyCommandProvider.php b/src/Command/FoxyCommandProvider.php new file mode 100644 index 0000000..92916a2 --- /dev/null +++ b/src/Command/FoxyCommandProvider.php @@ -0,0 +1,50 @@ + $arguments + */ + public function __construct(array $arguments) + { + $composer = $arguments['composer'] ?? null; + $io = $arguments['io'] ?? null; + $plugin = $arguments['plugin'] ?? null; + + if (!$composer instanceof Composer || !$io instanceof IOInterface || !$plugin instanceof Foxy) { + throw new RuntimeException('Composer provided invalid Foxy command capability arguments.'); + } + + $this->composer = $composer; + $this->io = $io; + $this->plugin = $plugin; + } + + /** + * @return list + */ + public function getCommands(): array + { + $command = new AuditCommand($this->plugin); + + $command->setComposer($this->composer); + $command->setIO($this->io); + + return [$command]; + } +} diff --git a/src/Converter/SemverUtil.php b/src/Converter/SemverUtil.php index dda4c77..9325aa9 100644 --- a/src/Converter/SemverUtil.php +++ b/src/Converter/SemverUtil.php @@ -54,7 +54,7 @@ public static function convertVersionMetadata(string $version): string } } - return static::cleanWildcard($version); + return self::cleanWildcard($version); } /** @@ -75,18 +75,6 @@ public static function createPattern(string $pattern): string return "/^({$numVer}|{$numVer2}|{$numVer3}){$pattern}/"; } - /** - * Clean the wildcard in version. - * - * @param string $version The version. - * - * @return string The cleaned version. - */ - protected static function cleanWildcard(string $version): string - { - return Preg::replace('/(?:\.x){2,}/', '.x', $version); - } - /** * Clean the raw version. * @@ -114,6 +102,18 @@ private static function cleanVersion(string $version, array $matches): array return [$type, $version, $end]; } + /** + * Clean the wildcard in version. + * + * @param string $version The version. + * + * @return string The cleaned version. + */ + private static function cleanWildcard(string $version): string + { + return Preg::replace('/(?:\.x){2,}/', '.x', $version); + } + /** * Convert the minor version of date. * diff --git a/src/Foxy.php b/src/Foxy.php index 21c4a67..5508879 100644 --- a/src/Foxy.php +++ b/src/Foxy.php @@ -9,11 +9,14 @@ use Composer\EventDispatcher\EventSubscriberInterface; use Composer\Installer\{PackageEvent, PackageEvents}; use Composer\IO\IOInterface; -use Composer\Plugin\PluginInterface; +use Composer\Plugin\Capability\CommandProvider as ComposerCommandProvider; +use Composer\Plugin\{Capable, PluginInterface}; use Composer\Script\{Event, ScriptEvents}; use Composer\Util\{Filesystem, ProcessExecutor}; use Foxy\Asset\{AbstractAssetManager, AssetManagerFinder, AssetManagerInterface}; use Foxy\Asset\{BunManager, NpmManager, PnpmManager, YarnManager}; +use Foxy\Audit\{AuditReport, AuditRequest, AuditRunner, AuditRunnerInterface, AuditableAssetManagerInterface}; +use Foxy\Command\FoxyCommandProvider; use Foxy\Config\{Config, ConfigBuilder}; use Foxy\Exception\RuntimeException; use Foxy\Fallback\{AssetFallback, ComposerFallback}; @@ -23,7 +26,7 @@ use function str_contains; -final class Foxy implements PluginInterface, EventSubscriberInterface +final class Foxy implements PluginInterface, EventSubscriberInterface, Capable, AuditRunnerInterface { final public const string REQUIRED_COMPOSER_VERSION = '^2.10.2'; @@ -98,11 +101,29 @@ public function activate(Composer $composer, IOInterface $io): void $this->assetManager->setFallback($this->assetFallback); } + public function audit(AuditRequest $request): AuditReport + { + if (!isset($this->config) || !$this->isEnabled()) { + throw new RuntimeException('Foxy is disabled; frontend dependencies cannot be audited.'); + } + + if (!isset($this->assetManager) || !$this->assetManager instanceof AuditableAssetManagerInterface) { + throw new RuntimeException('The selected asset manager does not support security audits.'); + } + + return (new AuditRunner($this->assetManager))->audit($request); + } + public function deactivate(Composer $composer, IOInterface $io): void { // Do nothing } + public function getCapabilities(): array + { + return [ComposerCommandProvider::class => FoxyCommandProvider::class]; + } + public static function getSubscribedEvents(): array { return [ diff --git a/tests/Asset/AbstractAssetManagerTest.php b/tests/Asset/AbstractAssetManagerTest.php index 13a531d..f2c676d 100644 --- a/tests/Asset/AbstractAssetManagerTest.php +++ b/tests/Asset/AbstractAssetManagerTest.php @@ -24,6 +24,8 @@ use function defined; use function file_put_contents; use function getcwd; +use function getenv; +use function putenv; use function str_replace; use const DIRECTORY_SEPARATOR; @@ -105,6 +107,179 @@ public function testAddDependenciesRestoresFallbackAfterFailure(): void $this->createManager()->addDependencies($this->rootPackage, []); } + public function testAuditCapturesStandardAndErrorOutputWithoutStreaming(): void + { + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'inspectable.lock', '{}'); + + $position = 0; + $executor = $this->createMock(ProcessExecutor::class); + $executor + ->expects(self::exactly(2)) + ->method('execute') + ->willReturnCallback( + static function (mixed $command, mixed &$output = null, mixed $cwd = null) use (&$position): int { + self::assertNull($cwd); + + if (0 === $position++) { + self::assertSame('inspectable --version', $command); + $output = '42.0.0'; + + return 0; + } + + self::assertSame('inspectable audit --prod', $command); + $output = 'standard output'; + + return 1; + }, + ); + $executor + ->expects(self::once()) + ->method('getErrorOutput') + ->willReturn('error output'); + + $this->io->expects(self::never())->method('writeRaw'); + $this->io->expects(self::never())->method('writeErrorRaw'); + + $manager = new InspectableAssetManager($this->io, $this->config, $executor, $this->fs, $this->fallback); + $result = $manager->audit(true); + + self::assertSame(1, $result->exitCode); + self::assertSame('standard output', $result->output); + self::assertSame('error output', $result->errorOutput); + } + + public function testAuditInvokesManagerConfigurationValidation(): void + { + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'inspectable.lock', '{}'); + $this->executor->addExpectedValues(0, '42.0.0'); + $this->executor->addExpectedValues(0, '{}'); + + $manager = $this->createManager(); + + $manager->audit(true); + + self::assertTrue($manager->getAuditValidationForTest()); + } + + public function testAuditOverridesAndRestoresManagerEnvironment(): void + { + $name = 'FOXY_ABSTRACT_ASSET_MANAGER_AUDIT'; + $processValue = getenv($name); + $environment = $_ENV; + $server = $_SERVER; + + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'inspectable.lock', '{}'); + putenv("{$name}=process-before-audit"); + $_ENV[$name] = 'env-before-audit'; + $_SERVER[$name] = 'server-before-audit'; + + $position = 0; + $executor = $this->createMock(ProcessExecutor::class); + $executor + ->expects(self::exactly(2)) + ->method('execute') + ->willReturnCallback( + static function (mixed $command, mixed &$output = null) use (&$position, $name): int { + if (0 === $position++) { + $output = '42.0.0'; + + return 0; + } + + self::assertSame('audit-value', getenv($name)); + self::assertSame('audit-value', $_ENV[$name]); + self::assertSame('audit-value', $_SERVER[$name]); + $output = '{}'; + + return 0; + }, + ); + + try { + $manager = new InspectableAssetManager( + $this->io, + $this->config, + $executor, + $this->fs, + $this->fallback, + ); + $manager->setAuditEnvironmentForTest([$name => 'audit-value']); + + $manager->audit(false); + + self::assertSame('process-before-audit', getenv($name)); + self::assertSame('env-before-audit', $_ENV[$name]); + self::assertSame('server-before-audit', $_SERVER[$name]); + } finally { + putenv(false === $processValue ? $name : "{$name}={$processValue}"); + $_ENV = $environment; + $_SERVER = $server; + } + } + + public function testAuditRestoresTimeoutWhenExecutorThrows(): void + { + $originalTimeout = ProcessExecutor::getTimeout(); + $expectedTimeout = 42; + $managerTimeout = 900; + $observedTimeout = null; + + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'inspectable.lock', '{}'); + $this->config = new Config( + [ + 'manager-timeout' => $managerTimeout, + 'run-asset-manager' => false, + ], + ); + $position = 0; + $executor = $this->createMock(ProcessExecutor::class); + $executor + ->expects(self::exactly(2)) + ->method('execute') + ->willReturnCallback( + static function (mixed $command, mixed &$output = null) use ( + &$observedTimeout, + &$position, + ): int { + if (0 === $position++) { + $output = '42.0.0'; + + return 0; + } + + $observedTimeout = ProcessExecutor::getTimeout(); + + throw new \RuntimeException('Audit execution failed.'); + }, + ); + $this->fallback->expects(self::never())->method('restore'); + + try { + ProcessExecutor::setTimeout($expectedTimeout); + + $manager = new InspectableAssetManager( + $this->io, + $this->config, + $executor, + $this->fs, + $this->fallback, + ); + + try { + $manager->audit(false); + self::fail('Expected the audit process to fail.'); + } catch (\RuntimeException $exception) { + self::assertSame('Audit execution failed.', $exception->getMessage()); + } + + self::assertSame($managerTimeout, $observedTimeout); + self::assertSame($expectedTimeout, ProcessExecutor::getTimeout()); + } finally { + ProcessExecutor::setTimeout($originalTimeout); + } + } + #[RunInSeparateProcess] #[PreserveGlobalState(false)] public function testBuildCommandNormalizesWindowsBinaryPath(): void diff --git a/tests/Asset/AssetManager.php b/tests/Asset/AssetManager.php index 845253a..e1930a0 100644 --- a/tests/Asset/AssetManager.php +++ b/tests/Asset/AssetManager.php @@ -9,6 +9,7 @@ use Composer\Package\RootPackageInterface; use Composer\Util\{Filesystem, ProcessExecutor}; use Foxy\Asset\{AbstractAssetManager, AssetManagerInterface, AssetPackageInterface}; +use Foxy\Audit\AuditableAssetManagerInterface; use Foxy\Config\Config; use Foxy\Exception\RuntimeException; use Foxy\Fallback\FallbackInterface; @@ -41,6 +42,8 @@ abstract protected function getManager(): AssetManagerInterface; abstract protected function getUnsupportedVersion(): string; + abstract protected function getValidAuditCommand(bool $noDev): string; + abstract protected function getValidInstallCommand(): string; abstract protected function getValidLockPackageName(): string; @@ -55,6 +58,14 @@ abstract protected function getValidVersionCommand(): string; abstract protected function getValidVersionConstraint(): string; + public static function getAuditCommandData(): array + { + return [ + 'all dependencies' => [false], + 'production dependencies' => [true], + ]; + } + public static function getEnabledRunAssetManagerData(): array { return [ @@ -238,6 +249,113 @@ public function testAddDependenciesUsesRootPackageJsonDir(): void ); } + #[DataProvider('getAuditCommandData')] + public function testAuditBuildsExactCommandWithoutInstallOptions(bool $noDev): void + { + $this->config = new Config( + [ + 'manager-options' => ' --install-only ', + 'run-asset-manager' => false, + ], + ); + $this->manager = $this->getManager(); + + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . $this->manager->getLockPackageName(), '{}'); + + $this->executor->addExpectedValues(0, $this->getValidVersion()); + $this->executor->addExpectedValues(1, 'AUDIT OUTPUT'); + + self::assertInstanceOf(AuditableAssetManagerInterface::class, $this->manager); + + $result = $this->manager->audit($noDev); + + self::assertSame(1, $result->exitCode); + self::assertSame('AUDIT OUTPUT', $result->output); + self::assertSame('', $result->errorOutput); + self::assertSame($this->getValidVersionCommand(), $this->executor->getExecutedCommand(0)); + self::assertSame($this->getValidAuditCommand($noDev), $this->executor->getExecutedCommand(1)); + self::assertNull($this->executor->getExecutedCommand(2)); + } + + public function testAuditRejectsMissingLockFileBeforeManagerAuditCommand(): void + { + $this->config = new Config(['run-asset-manager' => false]); + $this->manager = $this->getManager(); + + self::assertInstanceOf(AuditableAssetManagerInterface::class, $this->manager); + + try { + $this->manager->audit(false); + self::fail('Expected the audit to reject a missing lock file.'); + } catch (RuntimeException $exception) { + self::assertSame( + sprintf( + 'The %s lock file "%s" was not found.', + $this->manager->getName(), + $this->cwd . DIRECTORY_SEPARATOR . $this->manager->getLockPackageName(), + ), + $exception->getMessage(), + ); + } + + self::assertNull($this->executor->getExecutedCommand(0)); + } + + public function testAuditResolvesManagerSpecificBinaryConfiguration(): void + { + $managerName = $this->manager->getName(); + $customBinary = 'custom-manager'; + $this->config = new Config( + [ + 'manager-bin' => [$managerName => $customBinary], + 'run-asset-manager' => false, + ], + ); + $this->manager = $this->getManager(); + + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . $this->manager->getLockPackageName(), '{}'); + + $this->executor->addExpectedValues(0, $this->getValidVersion()); + $this->executor->addExpectedValues(0, '{}'); + + self::assertInstanceOf(AuditableAssetManagerInterface::class, $this->manager); + + $this->manager->audit(false); + + [, $auditArguments] = explode(' ', $this->getValidAuditCommand(false), 2); + + self::assertSame($customBinary . ' --version', $this->executor->getExecutedCommand(0)); + self::assertSame($customBinary . ' ' . $auditArguments, $this->executor->getExecutedCommand(1)); + } + + public function testAuditUsesConfiguredRootDirectoryWithoutChangingProcessDirectory(): void + { + $configuredRootPackageDir = 'root-package'; + $rootPackageDir = $this->cwd . DIRECTORY_SEPARATOR . $configuredRootPackageDir; + $this->sfs->mkdir($rootPackageDir); + $originalCwd = getcwd(); + $this->config = new Config( + [ + 'root-package-json-dir' => $configuredRootPackageDir, + 'run-asset-manager' => false, + ], + ); + $this->manager = $this->getManager(); + + file_put_contents($rootPackageDir . DIRECTORY_SEPARATOR . $this->manager->getLockPackageName(), '{}'); + + $this->executor->addExpectedValues(0, $this->getValidVersion()); + $this->executor->addExpectedValues(0, '{}'); + + self::assertInstanceOf(AuditableAssetManagerInterface::class, $this->manager); + + $this->manager->audit(false); + + self::assertSame($rootPackageDir, $this->executor->getExecutedWorkingDirectory(0)); + self::assertSame($rootPackageDir, $this->executor->getExecutedWorkingDirectory(1)); + self::assertSame($originalCwd, getcwd()); + } + public function testGetLockPackageName(): void { self::assertSame( diff --git a/tests/Asset/BunAssetManagerTest.php b/tests/Asset/BunAssetManagerTest.php index 6119ba3..b8f9e2a 100644 --- a/tests/Asset/BunAssetManagerTest.php +++ b/tests/Asset/BunAssetManagerTest.php @@ -7,14 +7,480 @@ use Composer\Util\Platform; use Foxy\Asset\BunManager; use Foxy\Config\Config; +use Foxy\Exception\RuntimeException; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\{PreserveGlobalState, RunInSeparateProcess}; +use Xepozz\InternalMocker\MockerState; +use function array_key_exists; use function define; use function defined; use function file_put_contents; +use function getenv; +use function putenv; +use function sprintf; + +use const DIRECTORY_SEPARATOR; final class BunAssetManagerTest extends AssetManager { + /** + * @var array + */ + private array $environmentState = []; + + public static function benignAuditConfigurations(): array + { + return [ + 'escaped non-scope npmrc value' => [ + '.npmrc', + '"cache" = "C:\\npm-cache"' . "\nomit=dev\n", + true, + ], + 'unrelated Bun install settings and table' => [ + 'bunfig.toml', + "[install]\nnotoptional = false\nnotinstall = { optional = false }\n" + . "[other]\noptional = false\n", + false, + ], + 'TOML comment containing an unmatched container' => [ + 'bunfig.toml', + "[install]\nnote = [\"\"] # [\noptional = true\n", + false, + ], + 'escaped TOML string before an unmatched comment container' => [ + 'bunfig.toml', + "[install]\nnote = [\"escaped \\\" quote\"] # [\noptional = true\n", + false, + ], + 'TOML string containing a comment character' => [ + 'bunfig.toml', + "[install]\nnote = [\"value#suffix\"]\noptional = true\n", + false, + ], + ]; + } + + public static function restrictiveAuditConfigurations(): array + { + return [ + 'npmrc development omission for a full audit' => ['.npmrc', 'omit=dev', false, 'omit=dev'], + 'npmrc optional omission for a production audit' => ['.npmrc', 'omit[]=optional', true, 'omit=optional'], + 'dynamic npmrc omission' => ['.npmrc', 'omit=${AUDIT_OMIT}', true, 'dynamic omit=${AUDIT_OMIT}'], + 'BOM-prefixed npmrc omission' => ['.npmrc', "\xEF\xBB\xBFomit=peer", true, 'omit=peer'], + 'double-quoted npmrc omission key' => ['.npmrc', '"omit"=optional', true, 'omit=optional'], + 'single-quoted npmrc omission key' => ['.npmrc', "'omit'=peer", true, 'omit=peer'], + 'quoted npmrc omission array key' => ['.npmrc', '"omit[]"=optional', true, 'omit=optional'], + 'multiline uppercase npmrc omission' => [ + '.npmrc', + "\n # comment\n ; comment\nstrict-ssl\nregistry=https://registry.npmjs.org/\n" + . " OMIT = [\"OPTIONAL\"]\n", + true, + 'omit=optional', + ], + 'padded dynamic npmrc omission' => [ + '.npmrc', + 'omit = ${AUDIT_OMIT} ', + true, + 'dynamic omit=${AUDIT_OMIT}', + ], + 'bunfig production mode for a full audit' => [ + 'bunfig.toml', + "[install]\nproduction = true\n", + false, + 'install.production=true', + ], + 'bunfig development exclusion for a full audit' => [ + 'bunfig.toml', + "[install]\ndev = false\n", + false, + 'install.dev=false', + ], + 'dotted bunfig optional exclusion' => [ + 'bunfig.toml', + 'install.optional = false', + true, + 'install.optional=false', + ], + 'bunfig peer exclusion' => [ + 'bunfig.toml', + "[install]\npeer = false\n", + true, + 'install.peer=false', + ], + 'BOM-prefixed bunfig optional exclusion' => [ + 'bunfig.toml', + "\xEF\xBB\xBF[install]\noptional = false\n", + true, + 'install.optional=false', + ], + 'comment after bunfig install table' => [ + 'bunfig.toml', + "[install] # comment\noptional = false\n", + true, + 'install.optional=false', + ], + 'quoted bunfig install table' => [ + 'bunfig.toml', + "[\"install\"]\noptional = false\n", + true, + 'install.optional=false', + ], + 'double-quoted bunfig install table with comment' => [ + 'bunfig.toml', + "[\"install\"] # comment\noptional = false\n", + true, + 'install.optional=false', + ], + 'single-quoted bunfig install table with comment' => [ + 'bunfig.toml', + "['install'] # comment\noptional = false\n", + true, + 'install.optional=false', + ], + 'spaced bunfig install table' => [ + 'bunfig.toml', + "[ install ]\noptional = false\n", + true, + 'install.optional=false', + ], + 'blank line before bunfig restriction' => [ + 'bunfig.toml', + "\n[install]\noptional = false\n", + true, + 'install.optional=false', + ], + 'double-BOM-prefixed bunfig optional exclusion' => [ + 'bunfig.toml', + "\xEF\xBB\xBF\xEF\xBB\xBF[install]\noptional = false\n", + true, + 'install.optional=false', + ], + ]; + } + + #[DataProvider('benignAuditConfigurations')] + public function testAuditAcceptsBenignConfiguration(string $file, string $contents, bool $noDev): void + { + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'bun.lock', '{}'); + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . $file, $contents); + $this->executor->addExpectedValues(0, $this->getValidVersion()); + $this->executor->addExpectedValues(0, '{}'); + + $this->getManager()->audit($noDev); + + self::assertSame($this->getValidAuditCommand($noDev), $this->executor->getExecutedCommand(1)); + } + + public function testAuditAcceptsBenignHomeAndXdgConfigurations(): void + { + $xdgConfigHome = $this->cwd . DIRECTORY_SEPARATOR . 'xdg-config'; + $this->sfs->mkdir($xdgConfigHome); + putenv('XDG_CONFIG_HOME=' . $xdgConfigHome . DIRECTORY_SEPARATOR); + $_ENV['XDG_CONFIG_HOME'] = $xdgConfigHome . DIRECTORY_SEPARATOR; + $_SERVER['XDG_CONFIG_HOME'] = $xdgConfigHome . DIRECTORY_SEPARATOR; + + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'bun.lock', '{}'); + file_put_contents( + $this->cwd . DIRECTORY_SEPARATOR . '.npmrc', + "\n # comment\n ; comment\nstrict-ssl\nregistry=https://registry.npmjs.org/\nomit=\n", + ); + file_put_contents( + $xdgConfigHome . DIRECTORY_SEPARATOR . '.bunfig.toml', + "[install]\nnote = [{ value = \"safe\" }]\nproduction = false\ndev = true\n" + . "[other]\ninstall.optional = true\n", + ); + $this->executor->addExpectedValues(0, $this->getValidVersion()); + $this->executor->addExpectedValues(0, '{}'); + + $this->getManager()->audit(false); + + self::assertSame($this->getValidAuditCommand(false), $this->executor->getExecutedCommand(1)); + } + + #[DataProvider('restrictiveAuditConfigurations')] + public function testAuditFailsClosedForRestrictiveConfiguration( + string $file, + string $contents, + bool $noDev, + string $setting, + ): void { + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'bun.lock', '{}'); + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . $file, $contents); + $manager = $this->getManager(); + + try { + $manager->audit($noDev); + self::fail('Expected a restrictive Bun configuration to be rejected.'); + } catch (RuntimeException $exception) { + self::assertSame( + sprintf( + 'The Bun audit cannot guarantee the requested dependency scope because "%s" declares "%s".', + $this->cwd . DIRECTORY_SEPARATOR . $file, + $setting, + ), + $exception->getMessage(), + ); + } + + self::assertNull($this->executor->getExecutedCommand(0)); + } + + #[DataProvider('unverifiableAuditConfigurations')] + public function testAuditFailsClosedForUnverifiableConfiguration( + string $file, + string $contents, + string $reason, + ): void { + $path = $this->cwd . DIRECTORY_SEPARATOR . $file; + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'bun.lock', '{}'); + file_put_contents($path, $contents); + + try { + $this->getManager()->audit(false); + self::fail('Expected an unverifiable Bun configuration to be rejected.'); + } catch (RuntimeException $exception) { + self::assertSame( + sprintf('The Bun audit cannot verify dependency scope in "%s": %s.', $path, $reason), + $exception->getMessage(), + ); + } + + self::assertNull($this->executor->getExecutedCommand(0)); + } + + public function testAuditIgnoresAnEnvironmentVariableThatTheManagerProcessWillDrop(): void + { + $home = $this->cwd . DIRECTORY_SEPARATOR . 'home'; + $droppedXdgConfig = $this->cwd . DIRECTORY_SEPARATOR . 'dropped-xdg-config'; + $this->sfs->mkdir([$home, $droppedXdgConfig]); + putenv('HOME=' . $home); + $_ENV['HOME'] = $home; + $_SERVER['HOME'] = $home; + putenv('XDG_CONFIG_HOME=' . $droppedXdgConfig); + unset($_ENV['XDG_CONFIG_HOME'], $_SERVER['XDG_CONFIG_HOME']); + file_put_contents($droppedXdgConfig . DIRECTORY_SEPARATOR . '.npmrc', 'omit=optional'); + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'bun.lock', '{}'); + $this->executor->addExpectedValues(0, $this->getValidVersion()); + $this->executor->addExpectedValues(0, '{}'); + + $this->getManager()->audit(true); + + self::assertSame($this->getValidAuditCommand(true), $this->executor->getExecutedCommand(1)); + } + + public function testAuditIgnoresAServerOnlyEnvironmentVariable(): void + { + $serverOnlyXdgConfig = $this->cwd . DIRECTORY_SEPARATOR . 'server-only-xdg-config'; + $this->sfs->mkdir($serverOnlyXdgConfig); + putenv('XDG_CONFIG_HOME'); + unset($_ENV['XDG_CONFIG_HOME']); + $_SERVER['XDG_CONFIG_HOME'] = $serverOnlyXdgConfig; + file_put_contents($serverOnlyXdgConfig . DIRECTORY_SEPARATOR . '.npmrc', 'omit=optional'); + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'bun.lock', '{}'); + $this->executor->addExpectedValues(0, $this->getValidVersion()); + $this->executor->addExpectedValues(0, '{}'); + + $this->getManager()->audit(true); + + self::assertSame($this->getValidAuditCommand(true), $this->executor->getExecutedCommand(1)); + } + + public function testAuditIgnoresNonScalarEnvironmentValues(): void + { + $home = $this->cwd . DIRECTORY_SEPARATOR . 'home'; + $this->sfs->mkdir($home); + putenv('HOME=' . $home); + $_ENV['HOME'] = $home; + $_SERVER['HOME'] = $home; + $_ENV['XDG_CONFIG_HOME'] = []; + file_put_contents($home . DIRECTORY_SEPARATOR . '.bunfig.toml', "[install]\noptional = false\n"); + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'bun.lock', '{}'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($home . DIRECTORY_SEPARATOR . '.bunfig.toml'); + + $this->getManager()->audit(true); + } + + public function testAuditNoDevAllowsDevelopmentOnlyRestrictions(): void + { + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'bun.lock', '{}'); + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . '.npmrc', 'omit=dev'); + file_put_contents( + $this->cwd . DIRECTORY_SEPARATOR . 'bunfig.toml', + "[install]\nproduction = true\ndev = false\noptional = true\npeer = true\n", + ); + $this->executor->addExpectedValues(0, $this->getValidVersion()); + $this->executor->addExpectedValues(0, '{}'); + + $this->getManager()->audit(true); + + self::assertSame($this->getValidAuditCommand(true), $this->executor->getExecutedCommand(1)); + } + + public function testAuditNormalizesConfigurationDirectorySeparators(): void + { + $xdgConfigHome = $this->cwd . DIRECTORY_SEPARATOR . 'xdg-config'; + $this->sfs->mkdir($xdgConfigHome); + putenv('XDG_CONFIG_HOME=' . $xdgConfigHome . DIRECTORY_SEPARATOR); + $_ENV['XDG_CONFIG_HOME'] = $xdgConfigHome . DIRECTORY_SEPARATOR; + $_SERVER['XDG_CONFIG_HOME'] = $xdgConfigHome . DIRECTORY_SEPARATOR; + file_put_contents($xdgConfigHome . DIRECTORY_SEPARATOR . '.npmrc', 'omit=optional'); + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'bun.lock', '{}'); + + try { + $this->getManager()->audit(true); + self::fail('Expected the normalized XDG npmrc configuration to be rejected.'); + } catch (RuntimeException $exception) { + self::assertStringContainsString( + '"' . $xdgConfigHome . DIRECTORY_SEPARATOR . '.npmrc"', + $exception->getMessage(), + ); + } + + self::assertNull($this->executor->getExecutedCommand(0)); + } + + public function testAuditPrefersHomeOverUserProfile(): void + { + $home = $this->cwd . DIRECTORY_SEPARATOR . 'home'; + $userProfile = $this->cwd . DIRECTORY_SEPARATOR . 'user-profile'; + $this->sfs->mkdir([$home, $userProfile]); + + putenv('HOME=' . $home); + putenv('USERPROFILE=' . $userProfile); + $_ENV['HOME'] = $home; + $_ENV['USERPROFILE'] = $userProfile; + $_SERVER['HOME'] = $home; + $_SERVER['USERPROFILE'] = $userProfile; + + file_put_contents($home . DIRECTORY_SEPARATOR . '.bunfig.toml', "[install]\noptional = false\n"); + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'bun.lock', '{}'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($home . DIRECTORY_SEPARATOR . '.bunfig.toml'); + + $this->getManager()->audit(true); + } + + public function testAuditPreservesQuotedTomlCharactersDuringPreflight(): void + { + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'bun.lock', '{}'); + file_put_contents( + $this->cwd . DIRECTORY_SEPARATOR . 'bunfig.toml', + "[install]\nnote = [\"]\", '}#', \"escaped \\\"#\"] # comment\noptional = true\n", + ); + $this->executor->addExpectedValues(0, $this->getValidVersion()); + $this->executor->addExpectedValues(0, '{}'); + + $this->getManager()->audit(false); + + self::assertSame($this->getValidAuditCommand(false), $this->executor->getExecutedCommand(1)); + } + + public function testAuditRejectsConfigurationDirectory(): void + { + $path = $this->cwd . DIRECTORY_SEPARATOR . '.npmrc'; + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'bun.lock', '{}'); + $this->sfs->mkdir($path); + + try { + $this->getManager()->audit(false); + self::fail('Expected a Bun configuration directory to be rejected.'); + } catch (RuntimeException $exception) { + self::assertSame( + sprintf('The Bun audit configuration "%s" cannot be read.', $path), + $exception->getMessage(), + ); + } + + self::assertNull($this->executor->getExecutedCommand(0)); + } + + public function testAuditRejectsConfigurationReadFailure(): void + { + $path = $this->cwd . DIRECTORY_SEPARATOR . '.npmrc'; + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'bun.lock', '{}'); + file_put_contents($path, 'registry=https://registry.npmjs.org/'); + MockerState::addCondition( + 'Foxy\\Asset', + 'file_get_contents', + [$path, false, null, 0, null], + false, + ); + + try { + $this->getManager()->audit(false); + self::fail('Expected a Bun configuration read failure to be rejected.'); + } catch (RuntimeException $exception) { + self::assertSame( + sprintf('The Bun audit configuration "%s" cannot be read.', $path), + $exception->getMessage(), + ); + } + + self::assertNull($this->executor->getExecutedCommand(0)); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testAuditUsesProcessEnvironmentWhenServerIntersectionIsEmpty(): void + { + $xdgConfigHome = $this->cwd . DIRECTORY_SEPARATOR . 'xdg-config'; + $this->sfs->mkdir($xdgConfigHome); + putenv('XDG_CONFIG_HOME=' . $xdgConfigHome); + $_ENV = []; + $_SERVER = []; + file_put_contents($xdgConfigHome . DIRECTORY_SEPARATOR . '.npmrc', 'omit=optional'); + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'bun.lock', '{}'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($xdgConfigHome . DIRECTORY_SEPARATOR . '.npmrc'); + + $this->getManager()->audit(true); + } + + public function testAuditUsesTheSameEnvironmentPrecedenceAsTheManagerProcess(): void + { + $environmentConfig = $this->cwd . DIRECTORY_SEPARATOR . 'environment-config'; + $processConfig = $this->cwd . DIRECTORY_SEPARATOR . 'process-config'; + $this->sfs->mkdir([$environmentConfig, $processConfig]); + putenv('XDG_CONFIG_HOME=' . $processConfig); + $_ENV['XDG_CONFIG_HOME'] = $environmentConfig; + $_SERVER['XDG_CONFIG_HOME'] = $processConfig; + file_put_contents($environmentConfig . DIRECTORY_SEPARATOR . '.npmrc', 'omit=optional'); + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'bun.lock', '{}'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($environmentConfig . DIRECTORY_SEPARATOR . '.npmrc'); + + $this->getManager()->audit(true); + } + + public function testAuditValidatesBothXdgAndRootNpmrcConfigurations(): void + { + $xdgConfigHome = $this->cwd . DIRECTORY_SEPARATOR . 'xdg-config'; + $this->sfs->mkdir($xdgConfigHome); + putenv('XDG_CONFIG_HOME=' . $xdgConfigHome); + $_ENV['XDG_CONFIG_HOME'] = $xdgConfigHome; + $_SERVER['XDG_CONFIG_HOME'] = $xdgConfigHome; + file_put_contents($xdgConfigHome . DIRECTORY_SEPARATOR . '.npmrc', 'registry=https://registry.npmjs.org/'); + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . '.npmrc', 'omit=optional'); + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'bun.lock', '{}'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($this->cwd . DIRECTORY_SEPARATOR . '.npmrc'); + + $this->getManager()->audit(true); + } + public function testIgnoresLegacyBinaryLockFile(): void { file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'bun.lockb', 'legacy'); @@ -34,6 +500,138 @@ public function testIgnoresLegacyBinaryLockFileInConfiguredRootDirectory(): void self::assertFalse($this->manager->hasLockFile()); } + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testWindowsAuditDropsProcessOnlyEnvironmentVariable(): void + { + if (!defined('PHP_WINDOWS_VERSION_BUILD')) { + define('PHP_WINDOWS_VERSION_BUILD', 1); + } + + $droppedXdgConfig = $this->cwd . DIRECTORY_SEPARATOR . 'dropped-xdg-config'; + $this->sfs->mkdir($droppedXdgConfig); + putenv('XDG_CONFIG_HOME=' . $droppedXdgConfig); + unset($_ENV['XDG_CONFIG_HOME'], $_SERVER['XDG_CONFIG_HOME']); + file_put_contents($droppedXdgConfig . DIRECTORY_SEPARATOR . '.npmrc', 'omit=optional'); + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'bun.lock', '{}'); + $this->executor->addExpectedValues(0, $this->getValidVersion()); + $this->executor->addExpectedValues(0, '{}'); + + $this->getManager()->audit(true); + + self::assertSame($this->getValidAuditCommand(true), $this->executor->getExecutedCommand(1)); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testWindowsAuditIgnoresAServerOnlyEnvironmentVariable(): void + { + if (!defined('PHP_WINDOWS_VERSION_BUILD')) { + define('PHP_WINDOWS_VERSION_BUILD', 1); + } + + $serverOnlyXdgConfig = $this->cwd . DIRECTORY_SEPARATOR . 'server-only-xdg-config'; + $this->sfs->mkdir($serverOnlyXdgConfig); + putenv('XDG_CONFIG_HOME'); + unset($_ENV['XDG_CONFIG_HOME']); + $_SERVER['XDG_CONFIG_HOME'] = $serverOnlyXdgConfig; + file_put_contents($serverOnlyXdgConfig . DIRECTORY_SEPARATOR . '.npmrc', 'omit=optional'); + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'bun.lock', '{}'); + $this->executor->addExpectedValues(0, $this->getValidVersion()); + $this->executor->addExpectedValues(0, '{}'); + + $this->getManager()->audit(true); + + self::assertSame($this->getValidAuditCommand(true), $this->executor->getExecutedCommand(1)); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testWindowsAuditUsesCaseInsensitiveEnvironmentPrecedence(): void + { + if (!defined('PHP_WINDOWS_VERSION_BUILD')) { + define('PHP_WINDOWS_VERSION_BUILD', 1); + } + + $environmentConfig = $this->cwd . DIRECTORY_SEPARATOR . 'environment-config'; + $processConfig = $this->cwd . DIRECTORY_SEPARATOR . 'process-config'; + $this->sfs->mkdir([$environmentConfig, $processConfig]); + putenv('XDG_CONFIG_HOME=' . $processConfig); + unset($_ENV['XDG_CONFIG_HOME']); + $_ENV['xdg_config_home'] = new class ($environmentConfig) implements \Stringable { + public function __construct(private readonly string $value) {} + + public function __toString(): string + { + return $this->value; + } + }; + $_SERVER['XDG_CONFIG_HOME'] = $processConfig; + file_put_contents($environmentConfig . DIRECTORY_SEPARATOR . '.npmrc', 'omit=optional'); + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'bun.lock', '{}'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($environmentConfig . DIRECTORY_SEPARATOR . '.npmrc'); + + $this->getManager()->audit(true); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testWindowsAuditUsesProcessEnvironmentAndHomeDriveFallback(): void + { + if (!defined('PHP_WINDOWS_VERSION_BUILD')) { + define('PHP_WINDOWS_VERSION_BUILD', 1); + } + + $xdgConfigHome = $this->cwd . DIRECTORY_SEPARATOR . 'xdg-config'; + $driveOnlyHome = $this->cwd . DIRECTORY_SEPARATOR . 'drive-only-home'; + $homePath = DIRECTORY_SEPARATOR . 'windows-home'; + $home = $this->cwd . $homePath; + $this->sfs->mkdir([$xdgConfigHome, $driveOnlyHome, $home]); + + foreach (['HOME', 'USERPROFILE', 'XDG_CONFIG_HOME'] as $name) { + putenv($name); + } + + $_ENV = []; + $_SERVER = []; + putenv('xdg_config_home=' . $xdgConfigHome); + + file_put_contents($driveOnlyHome . DIRECTORY_SEPARATOR . '.npmrc', 'omit=optional'); + file_put_contents($home . DIRECTORY_SEPARATOR . '.npmrc', 'omit=optional'); + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'bun.lock', '{}'); + + putenv('HOMEPATH=' . $home); + $this->executor->addExpectedValues(0, $this->getValidVersion()); + $this->executor->addExpectedValues(0, '{}'); + + $this->getManager()->audit(true); + + putenv('HOMEPATH'); + putenv('HOMEDRIVE=' . $driveOnlyHome); + $this->executor->addExpectedValues(0, $this->getValidVersion()); + $this->executor->addExpectedValues(0, '{}'); + + $this->getManager()->audit(true); + + putenv('HOMEDRIVE=' . $this->cwd); + putenv('HOMEPATH=' . $homePath); + + $path = $home . DIRECTORY_SEPARATOR . '.npmrc'; + + try { + $this->getManager()->audit(true); + self::fail('Expected the Windows home npmrc configuration to be rejected.'); + } catch (RuntimeException $exception) { + self::assertStringContainsString($path, $exception->getMessage()); + } + + self::assertSame($this->getValidAuditCommand(true), $this->executor->getExecutedCommand(1)); + self::assertSame($this->getValidAuditCommand(true), $this->executor->getExecutedCommand(3)); + self::assertNull($this->executor->getExecutedCommand(4)); + } + #[RunInSeparateProcess] #[PreserveGlobalState(false)] public function testWindowsCommandsUseExecutableNameAndNormalizedCustomPath(): void @@ -56,6 +654,132 @@ public function testWindowsCommandsUseExecutableNameAndNormalizedCustomPath(): v self::assertSame('C:\\tools\\bun.exe install', $this->executor->getLastCommand()); } + public static function unverifiableAuditConfigurations(): array + { + return [ + 'escaped table key' => [ + 'bunfig.toml', + "[\"in\\u0073tall\"]\noptional = false\n", + 'escape sequences in TOML keys are not supported; use canonical keys', + ], + 'escaped dependency key' => [ + 'bunfig.toml', + "[install]\n\"option\\u0061l\" = false\n", + 'escape sequences in TOML keys are not supported; use canonical keys', + ], + 'unquoted escaped dependency key' => [ + 'bunfig.toml', + "[install]\n\\optional = false\n", + 'escape sequences in TOML keys are not supported; use canonical keys', + ], + 'hex-escaped table and dependency keys' => [ + 'bunfig.toml', + "[\"in\\x73tall\"]\n\"option\\x61l\" = false\n", + 'escape sequences in TOML keys are not supported; use canonical keys', + ], + 'single-line inline install table' => [ + 'bunfig.toml', + 'install = { optional = false }', + 'inline install tables are not supported; use an [install] table', + ], + 'multiline inline install table' => [ + 'bunfig.toml', + "install = {\n optional = false,\n}\n", + 'inline install tables are not supported; use an [install] table', + ], + 'array install table' => [ + 'bunfig.toml', + "[[install]]\noptional = false\n", + 'array install tables are not supported; use an [install] table', + ], + 'multiline basic string in install table' => [ + 'bunfig.toml', + "[install]\nnote = \"\"\"\n[not-a-table]\n\"\"\"\noptional = false\n", + 'multiline strings in [install] are not supported', + ], + 'multiline literal string in install table' => [ + 'bunfig.toml', + "[install]\nnote = '''\n[not-a-table]\n'''\noptional = false\n", + 'multiline strings in [install] are not supported', + ], + 'multiline array in install table' => [ + 'bunfig.toml', + "[install]\nnote = [\n [1],\n]\noptional = false\n", + 'multiline container values in [install] are not supported', + ], + 'multiline array without assignment whitespace' => [ + 'bunfig.toml', + "[install]\nnote=[\n 1,\n]\n", + 'multiline container values in [install] are not supported', + ], + 'multiline inline table without assignment whitespace' => [ + 'bunfig.toml', + "[install]\nnote={\n value = true,\n}\n", + 'multiline container values in [install] are not supported', + ], + 'mismatched container closer' => [ + 'bunfig.toml', + "[install]\nnote = [}\n", + 'multiline container values in [install] are not supported', + ], + 'unexpected container closer' => [ + 'bunfig.toml', + "[install]\nnote = []]\n", + 'multiline container values in [install] are not supported', + ], + 'unterminated string in an install container' => [ + 'bunfig.toml', + "[install]\nnote = [\"unterminated]\noptional = false\n", + 'multiline container values in [install] are not supported', + ], + 'unterminated literal string in an install container' => [ + 'bunfig.toml', + "[install]\nnote = ['unterminated]\noptional = false\n", + 'multiline container values in [install] are not supported', + ], + 'UTF-16LE bunfig' => [ + 'bunfig.toml', + "\xFF\xFE[\0i\0n\0s\0t\0a\0l\0l\0]\0\n\0o\0p\0t\0i\0o\0n\0a\0l\0=\0f\0a\0l\0s\0e\0", + 'the configuration must be UTF-8', + ], + 'UTF-16LE npmrc' => [ + '.npmrc', + "\xFF\xFEo\0m\0i\0t\0=\0o\0p\0t\0i\0o\0n\0a\0l\0", + 'the configuration must be UTF-8', + ], + 'hex-escaped npmrc omit key' => [ + '.npmrc', + '"om\\x69t"=optional', + 'escape sequences in npmrc keys are not supported; use canonical keys', + ], + 'Unicode-escaped npmrc omit key' => [ + '.npmrc', + '"om\\u0069t"=optional', + 'escape sequences in npmrc keys are not supported; use canonical keys', + ], + 'single-quoted escaped npmrc omit key' => [ + '.npmrc', + "'om\\u0069t'=optional", + 'escape sequences in npmrc keys are not supported; use canonical keys', + ], + 'indented escaped npmrc omit key' => [ + '.npmrc', + ' "om\\u0069t"=optional', + 'escape sequences in npmrc keys are not supported; use canonical keys', + ], + 'hex-escaped npmrc omit value' => [ + '.npmrc', + 'omit="opti\\x6fnal"', + 'escape sequences in npmrc omit values are not supported; use canonical values', + ], + 'Unicode-escaped npmrc omit value' => [ + '.npmrc', + 'omit="opti\\u006fnal"', + 'escape sequences in npmrc omit values are not supported; use canonical values', + ], + ]; + } + protected function getManager(): BunManager { return new BunManager($this->io, $this->config, $this->executor, $this->fs, $this->fallback); @@ -66,6 +790,13 @@ protected function getUnsupportedVersion(): string return '1.3.9'; } + protected function getValidAuditCommand(bool $noDev): string + { + $binary = Platform::isWindows() ? 'bun.exe' : 'bun'; + + return $binary . ' audit --json' . ($noDev ? ' --prod' : ''); + } + protected function getValidInstallCommand(): string { return Platform::isWindows() ? 'bun.exe install' : 'bun install'; @@ -100,4 +831,48 @@ protected function getValidVersionConstraint(): string { return '^1.4.0'; } + + protected function setUp(): void + { + parent::setUp(); + + foreach (['HOME', 'USERPROFILE', 'XDG_CONFIG_HOME'] as $name) { + $this->environmentState[$name] = [ + 'process' => getenv($name), + 'envExists' => array_key_exists($name, $_ENV), + 'env' => $_ENV[$name] ?? null, + 'serverExists' => array_key_exists($name, $_SERVER), + 'server' => $_SERVER[$name] ?? null, + ]; + } + + putenv('HOME=' . $this->cwd); + $_ENV['HOME'] = $this->cwd; + $_SERVER['HOME'] = $this->cwd; + putenv('XDG_CONFIG_HOME'); + unset($_ENV['XDG_CONFIG_HOME'], $_SERVER['XDG_CONFIG_HOME']); + } + + protected function tearDown(): void + { + foreach ($this->environmentState as $name => $values) { + putenv(false === $values['process'] ? $name : $name . '=' . $values['process']); + + if ($values['envExists']) { + $_ENV[$name] = $values['env']; + } else { + unset($_ENV[$name]); + } + + if ($values['serverExists']) { + $_SERVER[$name] = $values['server']; + } else { + unset($_SERVER[$name]); + } + } + + $this->environmentState = []; + + parent::tearDown(); + } } diff --git a/tests/Asset/NpmAssetManagerTest.php b/tests/Asset/NpmAssetManagerTest.php index 0d0dc37..8003af9 100644 --- a/tests/Asset/NpmAssetManagerTest.php +++ b/tests/Asset/NpmAssetManagerTest.php @@ -8,13 +8,74 @@ use Composer\Util\ProcessExecutor; use Foxy\Asset\NpmManager; use Foxy\Config\Config; +use Foxy\Exception\RuntimeException; +use PHPUnit\Framework\Attributes\DataProvider; +use function array_map; use function file_put_contents; +use function implode; use const DIRECTORY_SEPARATOR; final class NpmAssetManagerTest extends AssetManager { + #[DataProvider('workspaceLocksThatCannotBeEnumerated')] + public function testAuditFailsClosedWhenWorkspaceGraphCannotBeEnumerated(string|null $manifest, string $lock): void + { + if (null !== $manifest) { + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'package.json', $manifest); + } + + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'package-lock.json', $lock); + $this->executor->addExpectedValues(0, $this->getValidVersion()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'The npm workspace graph could not be enumerated from package-lock.json. ' + . 'Regenerate the lock file with a supported npm version.', + ); + + $this->getManager()->audit(false); + } + + #[DataProvider('workspaceManifests')] + public function testAuditForcesTheCompleteWorkspaceGraph(string $manifest, string $lock, array $workspacePaths): void + { + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'package.json', $manifest); + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'package-lock.json', $lock); + $this->executor->addExpectedValues(0, $this->getValidVersion()); + $this->executor->addExpectedValues(0, '{}'); + + $this->getManager()->audit(false); + + $workspaceSelectors = implode( + ' ', + array_map( + static fn(string $path): string => ProcessExecutor::escape('--workspace=' . $path), + $workspacePaths, + ), + ); + + self::assertSame( + 'npm audit --json --package-lock-only --package-lock=true --audit-level=info --prefix=. --workspaces=true ' + . $workspaceSelectors + . ' --include-workspace-root=true --include=dev --include=optional --include=peer', + $this->executor->getExecutedCommand(1), + ); + } + + public function testAuditWithoutWorkspacesUsesTheRootPackageOnly(): void + { + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'package.json', '{}'); + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'package-lock.json', '{"packages":{"":{}}}'); + $this->executor->addExpectedValues(0, $this->getValidVersion()); + $this->executor->addExpectedValues(0, '{}'); + + $this->getManager()->audit(false); + + self::assertSame($this->getValidAuditCommand(false), $this->executor->getExecutedCommand(1)); + } + public function testExistingDependencyCleanupIsSkippedWhenManagerExecutionIsDisabled(): void { $rootPackageDir = $this->cwd . DIRECTORY_SEPARATOR . 'web'; @@ -87,7 +148,7 @@ public function testRunAppliesConfiguredTimeoutDuringExecution(): void ->willReturnCallback( static function (mixed $command, mixed &$output = null) use (&$observedTimeout): int { if ('npm --version' === $command) { - $output = '12.0.2'; + $output = '10.9.8'; return 0; } @@ -112,6 +173,83 @@ static function (mixed $command, mixed &$output = null) use (&$observedTimeout): } } + public static function workspaceLocksThatCannotBeEnumerated(): array + { + return [ + 'legacy lock without package map' => [ + '{"workspaces":["packages/*"]}', + '{"lockfileVersion":1}', + ], + 'lock without workspace entries' => [ + '{"workspaces":["packages/*"]}', + '{"packages":{"":{"workspaces":["packages/*"]}}}', + ], + 'stale workspace declaration' => [ + '{"workspaces":["packages/*"]}', + '{"packages":{"":{"workspaces":["other/*"]},"packages/a":{}}}', + ], + 'stale secondary workspace declaration' => [ + '{"workspaces":["packages/*","apps/*"]}', + '{"packages":{"":{"workspaces":["packages/*","services/*"]},"packages/a":{}}}', + ], + 'manifest without locked workspaces' => [ + '{}', + '{"packages":{"":{"workspaces":["packages/*"]},"packages/a":{}}}', + ], + 'missing manifest with locked workspaces' => [ + null, + '{"packages":{"":{"workspaces":["packages/*"]},"packages/a":{}}}', + ], + 'malformed manifest workspace declaration' => [ + '{"workspaces":"packages/*"}', + '{"packages":{"":{}}}', + ], + 'manifest workspace declaration with a non-string pattern' => [ + '{"workspaces":[null]}', + '{"packages":{"":{}}}', + ], + 'manifest workspace declaration with a blank pattern' => [ + '{"workspaces":[" "]}', + '{"packages":{"":{"workspaces":[" "]},"packages/a":{}}}', + ], + 'malformed locked workspace declaration' => [ + '{}', + '{"packages":{"":{"workspaces":{"packages":"packages/*"}}}}', + ], + ]; + } + + public static function workspaceManifests(): array + { + return [ + 'workspace list' => [ + '{"workspaces":["packages/*"]}', + '{"packages":{"":{"workspaces":["packages/*"]},"node_modules/a":{"link":true,"resolved":"packages/a"},"packages/a":{}}}', + ['packages/a'], + ], + 'workspace packages object' => [ + '{"workspaces":{"packages":["packages/*"]}}', + '{"packages":{"":{"workspaces":{"packages":["packages/*"]}},"node_modules/a":{"link":true,"resolved":"packages/a"},"packages/a":{}}}', + ['packages/a'], + ], + 'dot-leading workspace path' => [ + '{"workspaces":["visible",".hidden"]}', + '{"packages":{"":{"workspaces":["visible",".hidden"]},".hidden":{},"node_modules/hidden":{"link":true,"resolved":".hidden"},"node_modules/visible":{"link":true,"resolved":"visible"},"visible":{}}}', + ['.hidden', 'visible'], + ], + 'numeric workspace path' => [ + '{"workspaces":["0"]}', + '{"packages":{"":{"workspaces":["0"]},"0":{},"node_modules/zero":{"link":true,"resolved":"0"}}}', + ['0'], + ], + 'node_modules path separators and boundary' => [ + '{"workspaces":["packages/*"]}', + '{"packages":{"":{"workspaces":["packages/*"]},"node_modules":{},"packages/a":{},"packages\\\\a\\\\node_modules\\\\hidden":{}}}', + ['packages/a'], + ], + ]; + } + protected function getManager(): NpmManager { return new NpmManager($this->io, $this->config, $this->executor, $this->fs, $this->fallback); @@ -119,7 +257,16 @@ protected function getManager(): NpmManager protected function getUnsupportedVersion(): string { - return '12.0.1'; + return '10.9.7'; + } + + protected function getValidAuditCommand(bool $noDev): string + { + $command = 'npm audit --json --package-lock-only --package-lock=true --audit-level=info --prefix=.'; + + return $command . ($noDev + ? ' --omit=dev --include=optional --include=peer' + : ' --include=dev --include=optional --include=peer'); } protected function getValidInstallCommand(): string @@ -144,7 +291,7 @@ protected function getValidUpdateCommand(): string protected function getValidVersion(): string { - return '12.0.2'; + return '10.9.8'; } protected function getValidVersionCommand(): string @@ -154,6 +301,6 @@ protected function getValidVersionCommand(): string protected function getValidVersionConstraint(): string { - return '^12.0.2'; + return '>=10.9.8'; } } diff --git a/tests/Asset/PnpmAssetManagerTest.php b/tests/Asset/PnpmAssetManagerTest.php index d451f98..5967aae 100644 --- a/tests/Asset/PnpmAssetManagerTest.php +++ b/tests/Asset/PnpmAssetManagerTest.php @@ -4,10 +4,69 @@ namespace Foxy\Tests\Asset; +use Composer\Util\ProcessExecutor; use Foxy\Asset\PnpmManager; +use Foxy\Config\Config; + +use function file_get_contents; +use function file_put_contents; + +use const DIRECTORY_SEPARATOR; +use const PHP_BINARY; final class PnpmAssetManagerTest extends AssetManager { + public function testAuditPreventsPnpmfileHooksFromMutatingTheWorkspace(): void + { + $probePath = $this->cwd . DIRECTORY_SEPARATOR . 'pnpm-probe.php'; + $workspacePath = $this->cwd . DIRECTORY_SEPARATOR . 'pnpm-workspace.yaml'; + $lockPath = $this->cwd . DIRECTORY_SEPARATOR . 'pnpm-lock.yaml'; + + file_put_contents( + $probePath, + <<<'PHP' + cwd . DIRECTORY_SEPARATOR . '.pnpmfile.cjs', 'module.exports = {};'); + file_put_contents($lockPath, 'original'); + + $this->config = new Config( + [ + 'manager-bin' => ProcessExecutor::escape(PHP_BINARY) . ' ' . ProcessExecutor::escape($probePath), + 'run-asset-manager' => false, + ], + ); + $manager = new PnpmManager( + $this->io, + $this->config, + new ProcessExecutor($this->io), + $this->fs, + $this->fallback, + ); + + $result = $manager->audit(false); + + self::assertSame('{}', $result->output); + self::assertSame('original', file_get_contents($lockPath)); + self::assertFileDoesNotExist($workspacePath); + } + protected function getManager(): PnpmManager { return new PnpmManager($this->io, $this->config, $this->executor, $this->fs, $this->fallback); @@ -18,6 +77,19 @@ protected function getUnsupportedVersion(): string return '11.22.0'; } + protected function getValidAuditCommand(bool $noDev): string + { + $command = 'pnpm audit --json --audit-level=info --lockfile-dir=. --ignore-pnpmfile --only=null'; + + $command .= $noDev + ? ' --prod --optional=true' + : ' --prod=false --dev=false --optional=true'; + + return $command + . ' --ignore-registry-errors=false --ignore-unfixable=false ' + . ProcessExecutor::escape('--config.auditConfig={ignoreGhsas:[]}'); + } + protected function getValidInstallCommand(): string { return 'pnpm install'; diff --git a/tests/Asset/YarnAssetManagerTest.php b/tests/Asset/YarnAssetManagerTest.php index 0c84f09..d9082f6 100644 --- a/tests/Asset/YarnAssetManagerTest.php +++ b/tests/Asset/YarnAssetManagerTest.php @@ -4,14 +4,135 @@ namespace Foxy\Tests\Asset; +use Composer\Util\ProcessExecutor; use Foxy\Asset\YarnManager; +use function array_key_exists; use function file_put_contents; +use function getenv; +use function putenv; use const DIRECTORY_SEPARATOR; final class YarnAssetManagerTest extends AssetManager { + private const AUDIT_ENVIRONMENT_VARIABLES = [ + 'YARN_NPM_AUDIT_EXCLUDE_PACKAGES', + 'YARN_NPM_AUDIT_IGNORE_ADVISORIES', + ]; + + public function testAuditRestoresFilteringEnvironmentAfterExecution(): void + { + $state = self::captureEnvironment(self::AUDIT_ENVIRONMENT_VARIABLES); + + try { + $excludeVariable = self::AUDIT_ENVIRONMENT_VARIABLES[0]; + $ignoreVariable = self::AUDIT_ENVIRONMENT_VARIABLES[1]; + + putenv($excludeVariable . '=process-original'); + $_ENV[$excludeVariable] = 'env-original'; + unset($_SERVER[$excludeVariable]); + + putenv($ignoreVariable); + unset($_ENV[$ignoreVariable]); + $_SERVER[$ignoreVariable] = 'server-original'; + + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'yarn.lock', '{}'); + + $position = 0; + $executor = $this->createMock(ProcessExecutor::class); + $executor + ->expects(self::exactly(2)) + ->method('execute') + ->willReturnCallback( + static function (mixed $command, mixed &$output = null) use (&$position): int { + if (0 === $position++) { + $output = '4.18.0'; + + return 0; + } + + foreach (self::AUDIT_ENVIRONMENT_VARIABLES as $variable) { + self::assertSame('__FOXY_AUDIT_NO_MATCH__', getenv($variable)); + self::assertSame('__FOXY_AUDIT_NO_MATCH__', $_ENV[$variable]); + self::assertSame('__FOXY_AUDIT_NO_MATCH__', $_SERVER[$variable]); + } + + $output = '{}'; + + return 0; + }, + ); + $executor->expects(self::once())->method('getErrorOutput')->willReturn(''); + + $manager = new YarnManager($this->io, $this->config, $executor, $this->fs, $this->fallback); + $manager->audit(false); + + self::assertSame('process-original', getenv($excludeVariable)); + self::assertSame('env-original', $_ENV[$excludeVariable]); + self::assertArrayNotHasKey($excludeVariable, $_SERVER); + self::assertFalse(getenv($ignoreVariable)); + self::assertArrayNotHasKey($ignoreVariable, $_ENV); + self::assertSame('server-original', $_SERVER[$ignoreVariable]); + } finally { + self::restoreEnvironment($state); + } + } + + public function testAuditRestoresFilteringEnvironmentWhenExecutionFails(): void + { + $state = self::captureEnvironment(self::AUDIT_ENVIRONMENT_VARIABLES); + + try { + foreach (self::AUDIT_ENVIRONMENT_VARIABLES as $variable) { + putenv($variable); + unset($_ENV[$variable], $_SERVER[$variable]); + } + + file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'yarn.lock', '{}'); + + $position = 0; + $executor = $this->createMock(ProcessExecutor::class); + $executor + ->expects(self::exactly(2)) + ->method('execute') + ->willReturnCallback( + static function (mixed $command, mixed &$output = null) use (&$position): int { + if (0 === $position++) { + $output = '4.18.0'; + + return 0; + } + + foreach (self::AUDIT_ENVIRONMENT_VARIABLES as $variable) { + self::assertSame('__FOXY_AUDIT_NO_MATCH__', getenv($variable)); + self::assertSame('__FOXY_AUDIT_NO_MATCH__', $_ENV[$variable]); + self::assertSame('__FOXY_AUDIT_NO_MATCH__', $_SERVER[$variable]); + } + + throw new \RuntimeException('Audit execution failed.'); + }, + ); + + $manager = new YarnManager($this->io, $this->config, $executor, $this->fs, $this->fallback); + + try { + $manager->audit(false); + self::fail('Expected the audit process to fail.'); + } catch (\RuntimeException $exception) { + self::assertSame('Audit execution failed.', $exception->getMessage()); + } + + foreach (self::AUDIT_ENVIRONMENT_VARIABLES as $variable) { + self::assertFalse(getenv($variable)); + self::assertArrayNotHasKey($variable, $_ENV); + self::assertArrayNotHasKey($variable, $_SERVER); + } + } finally { + self::restoreEnvironment($state); + } + } + public function testIsInstalledRequiresLockFileWithPlugAndPlayState(): void { file_put_contents($this->cwd . DIRECTORY_SEPARATOR . 'package.json', '{}'); @@ -47,6 +168,13 @@ protected function getUnsupportedVersion(): string return '4.17.1'; } + protected function getValidAuditCommand(bool $noDev): string + { + $command = 'yarn npm audit --all --recursive --json --no-deprecations --severity info'; + + return $command . ($noDev ? ' --environment production' : ''); + } + protected function getValidInstallCommand(): string { return 'yarn install'; @@ -81,4 +209,60 @@ protected function getValidVersionConstraint(): string { return '^4.18.0'; } + + /** + * @param list $variables + * + * @return array + */ + private static function captureEnvironment(array $variables): array + { + $state = []; + + foreach ($variables as $variable) { + $state[$variable] = [ + 'process' => getenv($variable), + 'envExists' => array_key_exists($variable, $_ENV), + 'env' => $_ENV[$variable] ?? null, + 'serverExists' => array_key_exists($variable, $_SERVER), + 'server' => $_SERVER[$variable] ?? null, + ]; + } + + return $state; + } + + /** + * @param array $state + */ + private static function restoreEnvironment(array $state): void + { + foreach ($state as $variable => $values) { + putenv(false === $values['process'] ? $variable : $variable . '=' . $values['process']); + + if ($values['envExists']) { + $_ENV[$variable] = $values['env']; + } else { + unset($_ENV[$variable]); + } + + if ($values['serverExists']) { + $_SERVER[$variable] = $values['server']; + } else { + unset($_SERVER[$variable]); + } + } + } } diff --git a/tests/Audit/AuditDomainTest.php b/tests/Audit/AuditDomainTest.php new file mode 100644 index 0000000..c5c53af --- /dev/null +++ b/tests/Audit/AuditDomainTest.php @@ -0,0 +1,96 @@ +lodash'], + ); + + $resolved = $finding->withCveResolution(['CVE-2021-23337'], CveStatus::RESOLVED); + + self::assertNotSame($finding, $resolved); + self::assertSame([], $finding->cves); + self::assertSame(CveStatus::NOT_REQUESTED, $finding->cveStatus); + self::assertSame(['CVE-2021-23337'], $resolved->cves); + self::assertSame(CveStatus::RESOLVED, $resolved->cveStatus); + self::assertSame($finding->affectedVersions, $resolved->affectedVersions); + self::assertSame($finding->dependencyPaths, $resolved->dependencyPaths); + } + + public function testAuditReportCountsAndReplacesFindings(): void + { + $report = new AuditReport( + 'npm', + [ + $this->finding('lodash', Severity::HIGH, 'GHSA-35jh-r3h4-6jhm'), + $this->finding('lodash', Severity::LOW, 'GHSA-jf85-cpcp-j695'), + $this->finding('example-package', Severity::INFO, '1107000'), + ], + 'manager diagnostics', + ); + + self::assertSame(2, $report->countPackages()); + self::assertSame( + ['critical' => 0, 'high' => 1, 'moderate' => 0, 'low' => 1, 'info' => 1], + $report->countSeverities(), + ); + self::assertTrue($report->hasFindingAtLeast(Severity::HIGH)); + self::assertFalse($report->hasFindingAtLeast(Severity::CRITICAL)); + + $replacement = $report->withFindings([$report->findings[0]]); + + self::assertNotSame($report, $replacement); + self::assertCount(1, $replacement->findings); + self::assertSame('GHSA-35jh-r3h4-6jhm', $replacement->findings[0]->advisoryId); + self::assertSame('manager diagnostics', $replacement->diagnostics); + } + + public function testAuditRequestUsesSafeDefaults(): void + { + $request = new AuditRequest(); + + self::assertSame(Severity::LOW, $request->minimumSeverity); + self::assertFalse($request->noDev); + } + + public function testSeverityUsesStableThresholdOrder(): void + { + self::assertSame(0, Severity::INFO->weight()); + self::assertSame(1, Severity::LOW->weight()); + self::assertSame(2, Severity::MODERATE->weight()); + self::assertSame(3, Severity::HIGH->weight()); + self::assertSame(4, Severity::CRITICAL->weight()); + self::assertTrue(Severity::CRITICAL->isAtLeast(Severity::CRITICAL)); + self::assertTrue(Severity::HIGH->isAtLeast(Severity::LOW)); + self::assertFalse(Severity::INFO->isAtLeast(Severity::LOW)); + } + + private function finding(string $package, Severity $severity, string $advisoryId): AuditFinding + { + return new AuditFinding( + $package, + $severity, + $advisoryId, + $advisoryId, + 'Advisory title', + '<1.0.0', + ); + } +} diff --git a/tests/Audit/AuditFixture.php b/tests/Audit/AuditFixture.php new file mode 100644 index 0000000..5b6de31 --- /dev/null +++ b/tests/Audit/AuditFixture.php @@ -0,0 +1,26 @@ +, string}> + */ + public static function getCveStatusData(): array + { + return [ + 'resolved' => [CveStatus::RESOLVED, ['CVE-2021-23337'], 'CVE-2021-23337'], + 'none assigned' => [CveStatus::NONE_ASSIGNED, [], 'None assigned'], + 'unavailable' => [CveStatus::UNAVAILABLE, [], 'Unavailable'], + 'not requested' => [CveStatus::NOT_REQUESTED, [], 'Not requested'], + ]; + } + + public function testJsonFormatProvidesStableMachineReadableDocument(): void + { + $report = new AuditReport( + 'npm', + [ + $this->finding( + 'lodash', + Severity::HIGH, + 'GHSA-35jh-r3h4-6jhm', + CveStatus::RESOLVED, + ['CVE-2021-23337'], + 'https://github.com/advisories/GHSA-35jh-r3h4-6jhm', + ), + $this->finding('example-package', Severity::INFO, '1107000', CveStatus::UNAVAILABLE), + ], + ); + $output = new BufferedOutput(); + + (new AuditFormatter())->write($report, Severity::CRITICAL, AuditFormat::JSON, $output); + $formatted = self::normalizeLineEndings($output->fetch()); + + self::assertStringContainsString("{\n \"schema_version\": 1,", $formatted); + self::assertStringContainsString( + '"url": "https://github.com/advisories/GHSA-35jh-r3h4-6jhm"', + $formatted, + ); + + self::assertSame( + [ + 'schema_version' => 1, + 'manager' => 'npm', + 'audit_level' => 'critical', + 'affected' => false, + 'advisories' => [ + [ + 'package' => 'lodash', + 'severity' => 'high', + 'advisory_id' => 'GHSA-35jh-r3h4-6jhm', + 'source_id' => '1106913', + 'cves' => ['CVE-2021-23337'], + 'cve_status' => 'resolved', + 'title' => 'Advisory title', + 'url' => 'https://github.com/advisories/GHSA-35jh-r3h4-6jhm', + 'vulnerable_versions' => '<1.0.0', + 'affected_versions' => ['0.9.0'], + 'dependency_paths' => ['project>package'], + ], + [ + 'package' => 'example-package', + 'severity' => 'info', + 'advisory_id' => '1107000', + 'source_id' => '1106913', + 'cves' => [], + 'cve_status' => 'unavailable', + 'title' => 'Advisory title', + 'url' => null, + 'vulnerable_versions' => '<1.0.0', + 'affected_versions' => ['0.9.0'], + 'dependency_paths' => ['project>package'], + ], + ], + 'summary' => [ + 'total' => 2, + 'packages' => 2, + 'severity' => [ + 'critical' => 0, + 'high' => 1, + 'moderate' => 0, + 'low' => 0, + 'info' => 1, + ], + ], + ], + json_decode($formatted, true, 512, JSON_THROW_ON_ERROR), + ); + } + + #[DataProvider('getCveStatusData')] + public function testPlainFormatExplainsCveResolutionStatus( + CveStatus $status, + array $cves, + string $expected, + ): void { + $output = new BufferedOutput(); + $report = new AuditReport( + 'npm', + [$this->finding('lodash', Severity::HIGH, 'GHSA-35jh-r3h4-6jhm', $status, $cves)], + ); + + (new AuditFormatter())->write($report, Severity::LOW, AuditFormat::PLAIN, $output); + + self::assertStringContainsString(' | ' . $expected . ' | ', $output->fetch()); + } + + public function testPlainFormatIncludesAdvisoryDetailsAndSummary(): void + { + $report = new AuditReport( + 'pnpm', + [ + $this->finding( + 'lodash', + Severity::HIGH, + 'GHSA-35jh-r3h4-6jhm', + CveStatus::RESOLVED, + ['CVE-2021-23337'], + 'https://github.com/advisories/GHSA-35jh-r3h4-6jhm', + ), + ], + ); + $output = new BufferedOutput(); + + (new AuditFormatter())->write($report, Severity::LOW, AuditFormat::PLAIN, $output); + + self::assertSame( + "high | lodash | GHSA-35jh-r3h4-6jhm | CVE-2021-23337 | <1.0.0 | Advisory title | https://github.com/advisories/GHSA-35jh-r3h4-6jhm\n" + . "1 advisory affecting 1 package (high: 1).\n", + self::normalizeLineEndings($output->fetch()), + ); + } + + public function testPlainFormatReportsCleanAuditOnce(): void + { + $output = new BufferedOutput(); + + (new AuditFormatter())->write( + new AuditReport('npm', []), + Severity::LOW, + AuditFormat::PLAIN, + $output, + ); + + self::assertSame( + "No known frontend vulnerabilities found.\n", + self::normalizeLineEndings($output->fetch()), + ); + } + + public function testSummaryFormatReportsAdvisoriesPackagesAndSeverities(): void + { + $report = new AuditReport( + 'yarn', + [ + $this->finding('lodash', Severity::CRITICAL, 'GHSA-jf85-cpcp-j695'), + $this->finding('lodash', Severity::HIGH, 'GHSA-35jh-r3h4-6jhm'), + $this->finding('example-package', Severity::INFO, '1107000'), + ], + ); + $output = new BufferedOutput(); + + (new AuditFormatter())->write($report, Severity::LOW, AuditFormat::SUMMARY, $output); + + self::assertSame( + "3 advisories affecting 2 packages (critical: 1, high: 1, info: 1).\n", + self::normalizeLineEndings($output->fetch()), + ); + } + + public function testSummaryFormatReportsCleanAudit(): void + { + $output = new BufferedOutput(); + + (new AuditFormatter())->write( + new AuditReport('npm', []), + Severity::LOW, + AuditFormat::SUMMARY, + $output, + ); + + self::assertSame( + "No known frontend vulnerabilities found.\n", + self::normalizeLineEndings($output->fetch()), + ); + } + + public function testTableFormatEscapesAdvisoryMarkup(): void + { + $report = new AuditReport( + 'npm', + [ + new AuditFinding( + 'package', + Severity::HIGH, + 'GHSA-35jh-r3h4-6jhm', + '1106913', + 'Advisory title', + '<1.0.0', + ), + ], + ); + $output = new BufferedOutput(); + + (new AuditFormatter())->write($report, Severity::LOW, AuditFormat::TABLE, $output); + $formatted = $output->fetch(); + + self::assertStringContainsString('package', $formatted); + self::assertStringContainsString('Advisory title', $formatted); + } + + public function testTableFormatIncludesCveAndSummary(): void + { + $report = new AuditReport( + 'bun', + [ + $this->finding( + 'lodash', + Severity::HIGH, + 'GHSA-35jh-r3h4-6jhm', + CveStatus::RESOLVED, + ['CVE-2021-23337'], + 'https://github.com/advisories/GHSA-35jh-r3h4-6jhm', + ), + ], + ); + $output = new BufferedOutput(); + + (new AuditFormatter())->write($report, Severity::LOW, AuditFormat::TABLE, $output); + $formatted = $output->fetch(); + + self::assertStringContainsString('Severity', $formatted); + self::assertStringContainsString( + '| high | lodash | GHSA-35jh-r3h4-6jhm', + $formatted, + ); + self::assertStringContainsString('lodash', $formatted); + self::assertStringContainsString('GHSA-35jh-r3h4-6jhm', $formatted); + self::assertStringContainsString( + 'https://github.com/advisories/GHSA-35jh-r3h4-6jhm', + $formatted, + ); + self::assertStringContainsString('CVE-2021-23337', $formatted); + self::assertStringContainsString('1 advisory affecting 1 package (high: 1).', $formatted); + } + + /** + * @param list $cves + */ + private function finding( + string $package, + Severity $severity, + string $advisoryId, + CveStatus $cveStatus = CveStatus::NOT_REQUESTED, + array $cves = [], + string|null $url = null, + ): AuditFinding { + return new AuditFinding( + $package, + $severity, + $advisoryId, + '1106913', + 'Advisory title', + '<1.0.0', + $url, + $cves, + $cveStatus, + ['0.9.0'], + ['project>package'], + ); + } + + private static function normalizeLineEndings(string $output): string + { + return str_replace(["\r\n", "\r"], "\n", $output); + } +} diff --git a/tests/Audit/AuditParserTest.php b/tests/Audit/AuditParserTest.php new file mode 100644 index 0000000..eb97651 --- /dev/null +++ b/tests/Audit/AuditParserTest.php @@ -0,0 +1,615 @@ + + */ + public static function getMalformedReportData(): array + { + return [ + 'npm error document takes precedence' => [ + new NpmAuditParser(), + '{"error":{},"auditReportVersion":1,"vulnerabilities":[],"metadata":[]}', + 'the manager returned an error document', + ], + 'npm v1 report' => [ + new NpmAuditParser(), + '{"auditReportVersion":1,"vulnerabilities":{},"metadata":{}}', + 'auditReportVersion must be 2', + ], + 'npm invalid via entry' => [ + new NpmAuditParser(), + '{"auditReportVersion":2,"vulnerabilities":{"pkg":{"name":"pkg","severity":"low","isDirect":true,"via":[false],"effects":[],"range":"<1","nodes":[],"fixAvailable":false}},"metadata":{}}', + 'via.0 must be a string or object', + ], + 'npm empty vulnerability key' => [ + new NpmAuditParser(), + '{"auditReportVersion":2,"vulnerabilities":{"":{}},"metadata":{}}', + 'each vulnerability must be keyed by a package name', + ], + 'npm mismatched vulnerability name' => [ + new NpmAuditParser(), + '{"auditReportVersion":2,"vulnerabilities":{"pkg":{"name":"other"}},"metadata":{}}', + 'vulnerabilities.pkg.name must match its vulnerability key', + ], + 'npm metadata list' => [ + new NpmAuditParser(), + '{"auditReportVersion":2,"vulnerabilities":[],"metadata":[]}', + 'metadata must be an object', + ], + 'npm vulnerabilities list' => [ + new NpmAuditParser(), + '{"auditReportVersion":2,"vulnerabilities":[],"metadata":{}}', + 'vulnerabilities must be an object', + ], + 'npm incomplete metadata' => [ + new NpmAuditParser(), + '{"auditReportVersion":2,"vulnerabilities":{},"metadata":{}}', + 'metadata.vulnerabilities must be an object', + ], + 'npm missing metadata dependencies' => [ + new NpmAuditParser(), + '{"auditReportVersion":2,"vulnerabilities":{},"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0,"total":0}}}', + 'metadata.dependencies must be an object', + ], + 'npm empty via' => [ + new NpmAuditParser(), + '{"auditReportVersion":2,"vulnerabilities":{"pkg":{"name":"pkg","severity":"low","isDirect":true,"via":[],"effects":[],"range":"<1","nodes":[],"fixAvailable":false}},"metadata":{}}', + 'via must be a non-empty list', + ], + 'npm missing nodes' => [ + new NpmAuditParser(), + '{"auditReportVersion":2,"vulnerabilities":{"pkg":{"name":"pkg","severity":"low","isDirect":true,"via":["dependency"],"effects":[],"range":"<1","fixAvailable":false}},"metadata":{}}', + 'nodes must be a list', + ], + 'pnpm mismatched advisory key' => [ + new PnpmAuditParser(), + '{"advisories":{"1":{"id":2}},"metadata":{}}', + 'id must match its advisory key', + ], + 'pnpm error document takes precedence' => [ + new PnpmAuditParser(), + '{"error":{},"advisories":[],"metadata":[]}', + 'the manager returned an error document', + ], + 'pnpm advisories list' => [ + new PnpmAuditParser(), + '{"advisories":[],"metadata":[]}', + 'advisories must be an object', + ], + 'pnpm metadata list' => [ + new PnpmAuditParser(), + '{"advisories":{},"metadata":[]}', + 'metadata must be an object', + ], + 'pnpm incomplete metadata' => [ + new PnpmAuditParser(), + '{"advisories":{},"metadata":{}}', + 'metadata.vulnerabilities must be an object', + ], + 'pnpm missing dependency metadata' => [ + new PnpmAuditParser(), + '{"advisories":{},"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0}}}', + 'metadata.dependencies must be a non-negative integer', + ], + 'pnpm missing advisory id' => [ + new PnpmAuditParser(), + '{"advisories":{"1":{}},"metadata":{}}', + 'advisories.1.id must be a non-negative integer', + ], + 'pnpm empty findings' => [ + new PnpmAuditParser(), + '{"advisories":{"1":{"id":1,"url":"","github_advisory_id":"","cwe":"","findings":[]}},"metadata":{}}', + 'findings must be a non-empty list', + ], + 'pnpm missing finding paths' => [ + new PnpmAuditParser(), + '{"advisories":{"1":{"id":1,"url":"","github_advisory_id":"","cwe":"","findings":[{"version":"1.0.0","dev":false,"optional":false,"bundled":false}]}},"metadata":{}}', + 'advisories.1.findings.0.paths must be a list', + ], + 'Yarn malformed second line' => [ + new YarnAuditParser(), + "{\"value\":\"pkg\",\"children\":{\"ID\":1,\"Issue\":\"Issue\",\"Severity\":\"low\",\"Vulnerable Versions\":\"<1\",\"Tree Versions\":[],\"Dependents\":[]}}\nnot-json", + 'line 2 contains invalid JSON', + ], + 'Yarn non-object record' => [ + new YarnAuditParser(), + '[]', + 'line 1 is not an audit finding', + ], + 'Yarn missing tree versions' => [ + new YarnAuditParser(), + '{"value":"pkg","children":{"ID":1,"Issue":"Issue","Severity":"low","Vulnerable Versions":"<1","Dependents":[]}}', + 'line 1.children.Tree Versions must be a list', + ], + 'Yarn invalid package on second line' => [ + new YarnAuditParser(), + "{\"value\":\"first\",\"children\":{\"ID\":1,\"Issue\":\"Issue\",\"Severity\":\"low\",\"Vulnerable Versions\":\"<1\",\"Tree Versions\":[],\"Dependents\":[]}}\n{\"value\":false,\"children\":{\"ID\":2,\"Issue\":\"Issue\",\"Severity\":\"low\",\"Vulnerable Versions\":\"<1\",\"Tree Versions\":[],\"Dependents\":[]}}", + 'line 2.value must be a string', + ], + 'Yarn invalid advisory ID' => [ + new YarnAuditParser(), + '{"value":"pkg","children":{"ID":false,"Issue":"Issue","Severity":"low","Vulnerable Versions":"<1","Tree Versions":[],"Dependents":[]}}', + 'line 1.children.ID must be a string or integer', + ], + 'Yarn invalid dependents' => [ + new YarnAuditParser(), + '{"value":"pkg","children":{"ID":1,"Issue":"Issue","Severity":"low","Vulnerable Versions":"<1","Tree Versions":[],"Dependents":false}}', + 'line 1.children.Dependents must be a list', + ], + 'Bun root list' => [ + new BunAuditParser(), + '[]', + 'expected a JSON object', + ], + 'Bun empty package key' => [ + new BunAuditParser(), + '{"":[]}', + 'each package must contain a list of advisories', + ], + 'Bun advisories object' => [ + new BunAuditParser(), + '{"pkg":{}}', + 'each package must contain a list of advisories', + ], + 'Bun non-string optional URL' => [ + new BunAuditParser(), + '{"pkg":[{"id":1,"url":false}]}', + 'pkg.0.url must be a string', + ], + 'Bun non-scalar advisory ID' => [ + new BunAuditParser(), + '{"pkg":[{"id":false}]}', + 'pkg.0.id must be a string or integer', + ], + 'Bun empty advisory ID' => [ + new BunAuditParser(), + '{"pkg":[{"id":" "}]}', + 'pkg.0.id must not be empty', + ], + 'Bun blank required string' => [ + new BunAuditParser(), + '{"pkg":[{"id":1,"severity":"low","vulnerable_versions":" "}]}', + 'pkg.0.vulnerable_versions must be a string', + ], + 'unsupported severity' => [ + new BunAuditParser(), + '{"pkg":[{"id":1,"title":"Issue","severity":"unknown","vulnerable_versions":"<1"}]}', + 'has an unsupported severity', + ], + ]; + } + + /** + * Provides malformed npm fields for extracted validation tests. + * + * @return array): void, string}> + */ + public static function getNpmFieldValidationData(): array + { + return [ + 'aggregate severity' => [ + static function (array &$data): void { + $data['vulnerabilities']['lodash']['severity'] = 'severe'; + }, + 'vulnerabilities.lodash has an unsupported severity', + ], + 'direct dependency flag' => [ + static function (array &$data): void { + $data['vulnerabilities']['lodash']['isDirect'] = 1; + }, + 'vulnerabilities.lodash.isDirect must be a boolean', + ], + 'via object' => [ + static function (array &$data): void { + $data['vulnerabilities']['lodash']['via'] = ['advisory' => []]; + }, + 'vulnerabilities.lodash.via must be a non-empty list', + ], + 'effects list' => [ + static function (array &$data): void { + $data['vulnerabilities']['lodash']['effects'] = false; + }, + 'vulnerabilities.lodash.effects must be a list', + ], + 'aggregate range' => [ + static function (array &$data): void { + $data['vulnerabilities']['lodash']['range'] = false; + }, + 'vulnerabilities.lodash.range must be a string', + ], + 'fix availability' => [ + static function (array &$data): void { + $data['vulnerabilities']['lodash']['fixAvailable'] = 'yes'; + }, + 'vulnerabilities.lodash.fixAvailable must be a boolean or object', + ], + 'production dependency count' => [ + static function (array &$data): void { + $data['metadata']['dependencies']['prod'] = -1; + }, + 'metadata.dependencies.prod must be a non-negative integer', + ], + 'severity count context' => [ + static function (array &$data): void { + $data['metadata']['vulnerabilities']['high'] = -1; + }, + 'metadata.vulnerabilities.high must be a non-negative integer', + ], + 'total severity count context' => [ + static function (array &$data): void { + $data['metadata']['vulnerabilities']['total'] = -1; + }, + 'metadata.vulnerabilities.total must be a non-negative integer', + ], + ]; + } + + /** + * Provides malformed pnpm fields for extracted validation tests. + * + * @return array): void, string}> + */ + public static function getPnpmFieldValidationData(): array + { + return [ + 'CWE field' => [ + static function (array &$data): void { + $data['advisories']['1106913']['cwe'] = false; + }, + 'advisories.1106913.cwe must be a string', + ], + 'findings object' => [ + static function (array &$data): void { + $data['advisories']['1106913']['findings'] = ['finding' => []]; + }, + 'advisories.1106913.findings must be a non-empty list', + ], + 'CVE list' => [ + static function (array &$data): void { + $data['advisories']['1106913']['cves'] = [false]; + }, + 'advisories.1106913.cves must contain only strings', + ], + 'invalid CVE identifier' => [ + static function (array &$data): void { + $data['advisories']['1106913']['cves'] = ['CVE-invalid']; + }, + 'advisories.1106913.cves contains an invalid CVE identifier', + ], + 'prefixed CVE identifier' => [ + static function (array &$data): void { + $data['advisories']['1106913']['cves'] = ['prefix-CVE-2021-23337']; + }, + 'advisories.1106913.cves contains an invalid CVE identifier', + ], + 'suffixed CVE identifier' => [ + static function (array &$data): void { + $data['advisories']['1106913']['cves'] = ['CVE-2021-23337-suffix']; + }, + 'advisories.1106913.cves contains an invalid CVE identifier', + ], + 'development dependency flag' => [ + static function (array &$data): void { + $data['advisories']['1106913']['findings'][0]['dev'] = 1; + }, + 'advisories.1106913.findings.0.dev must be a boolean', + ], + 'optional dependency flag' => [ + static function (array &$data): void { + $data['advisories']['1106913']['findings'][0]['optional'] = 1; + }, + 'advisories.1106913.findings.0.optional must be a boolean', + ], + 'bundled dependency flag' => [ + static function (array &$data): void { + $data['advisories']['1106913']['findings'][0]['bundled'] = 1; + }, + 'advisories.1106913.findings.0.bundled must be a boolean', + ], + ]; + } + + public function testBunParserAcceptsCaseInsensitiveSeverity(): void + { + $data = json_decode(self::fixture('bun-populated.json'), true, 512, JSON_THROW_ON_ERROR); + $data['lodash'][0]['severity'] = 'HIGH'; + + $findings = (new BunAuditParser())->parse(json_encode($data, JSON_THROW_ON_ERROR)); + + self::assertSame(Severity::HIGH, $findings[0]->severity); + } + + public function testBunParserAcceptsSurroundingWhitespace(): void + { + $output = "\n " . self::fixture('bun-clean.json') . " \n"; + + self::assertSame([], (new BunAuditParser())->parse($output)); + } + + public function testBunParserNormalizesBlankOptionalUrl(): void + { + $data = json_decode(self::fixture('bun-populated.json'), true, 512, JSON_THROW_ON_ERROR); + $data['lodash'][0]['url'] = ' '; + + $findings = (new BunAuditParser())->parse(json_encode($data, JSON_THROW_ON_ERROR)); + + self::assertNull($findings[0]->url); + self::assertSame('1106913', $findings[0]->advisoryId); + } + + public function testBunParserReadsNumericPackageName(): void + { + $findings = (new BunAuditParser())->parse( + '{"0":[{"id":1,"severity":"low","vulnerable_versions":"<1"}]}', + ); + + self::assertSame('0', $findings[0]->package); + } + + public function testBunParserReadsRawBulkAuditReport(): void + { + $findings = (new BunAuditParser())->parse(self::fixture('bun-populated.json')); + + self::assertCount(2, $findings); + self::assertSame('lodash', $findings[0]->package); + self::assertSame(Severity::HIGH, $findings[0]->severity); + self::assertSame('GHSA-35jh-r3h4-6jhm', $findings[0]->advisoryId); + self::assertSame('1106913', $findings[0]->sourceId); + self::assertSame('Command Injection in lodash', $findings[0]->title); + self::assertSame([], $findings[0]->affectedVersions); + self::assertSame([], $findings[0]->dependencyPaths); + self::assertSame('1107000', $findings[1]->advisoryId); + self::assertSame(Severity::INFO, $findings[1]->severity); + self::assertSame('Vulnerability found', $findings[1]->title); + } + + public function testNpmParserAcceptsNumericPackageKeyAndObjectFixAvailability(): void + { + $data = json_decode(self::fixture('npm-populated.json'), true, 512, JSON_THROW_ON_ERROR); + $vulnerability = $data['vulnerabilities']['lodash']; + $vulnerability['name'] = '0'; + $vulnerability['fixAvailable'] = [ + 'name' => 'lodash', + 'version' => '4.17.21', + 'isSemVerMajor' => false, + ]; + $data['vulnerabilities'] = (object) ['0' => $vulnerability]; + + $findings = (new NpmAuditParser())->parse(json_encode($data, JSON_THROW_ON_ERROR)); + + self::assertCount(2, $findings); + self::assertSame('0', $findings[0]->package); + self::assertSame('0', $findings[1]->package); + } + + #[DataProvider('getNpmFieldValidationData')] + public function testNpmParserAppliesExtractedFieldValidation(Closure $mutate, string $expectedMessage): void + { + $data = json_decode(self::fixture('npm-populated.json'), true, 512, JSON_THROW_ON_ERROR); + $mutate($data); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($expectedMessage); + + (new NpmAuditParser())->parse(json_encode($data, JSON_THROW_ON_ERROR)); + } + + public function testNpmParserEmitsOnlyConcreteViaAdvisories(): void + { + $findings = (new NpmAuditParser())->parse(self::fixture('npm-populated.json')); + + self::assertCount(2, $findings); + self::assertSame('lodash', $findings[0]->package); + self::assertSame(Severity::HIGH, $findings[0]->severity); + self::assertSame('GHSA-35jh-r3h4-6jhm', $findings[0]->advisoryId); + self::assertSame('1106913', $findings[0]->sourceId); + self::assertSame('Command Injection in lodash', $findings[0]->title); + self::assertSame('<4.17.21', $findings[0]->vulnerableVersions); + self::assertSame( + ['node_modules/lodash', 'node_modules/parent/node_modules/lodash'], + $findings[0]->dependencyPaths, + ); + self::assertSame('GHSA-jf85-cpcp-j695', $findings[1]->advisoryId); + self::assertSame('1108258', $findings[1]->sourceId); + self::assertSame(Severity::CRITICAL, $findings[1]->severity); + self::assertSame('Vulnerability found', $findings[1]->title); + } + + public function testNpmParserRejectsInconsistentMetadataCounts(): void + { + $data = json_decode(self::fixture('npm-populated.json'), true, 512, JSON_THROW_ON_ERROR); + $data['metadata']['vulnerabilities']['critical'] = 2; + $data['metadata']['vulnerabilities']['total'] = 2; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('metadata vulnerability counts must equal the vulnerability entries'); + + (new NpmAuditParser())->parse(json_encode($data, JSON_THROW_ON_ERROR)); + } + + public function testNpmParserRejectsInconsistentMetadataTotal(): void + { + $data = json_decode(self::fixture('npm-populated.json'), true, 512, JSON_THROW_ON_ERROR); + $data['metadata']['vulnerabilities']['total'] = 2; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('metadata.vulnerabilities.total must equal the severity counts'); + + (new NpmAuditParser())->parse(json_encode($data, JSON_THROW_ON_ERROR)); + } + + public function testParserAcceptsReportAtSafetyLimit(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('auditReportVersion must be 2'); + + (new NpmAuditParser())->parse('{' . str_repeat(' ', 16 * 1024 * 1024 - 2) . '}'); + } + + public function testParserFactoryCreatesEverySupportedParser(): void + { + self::assertInstanceOf(NpmAuditParser::class, AuditParserFactory::create('npm')); + self::assertInstanceOf(PnpmAuditParser::class, AuditParserFactory::create('pnpm')); + self::assertInstanceOf(YarnAuditParser::class, AuditParserFactory::create('yarn')); + self::assertInstanceOf(BunAuditParser::class, AuditParserFactory::create('bun')); + } + + public function testParserFactoryRejectsUnsupportedManager(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The asset manager "legacy" does not provide a supported audit report.'); + + AuditParserFactory::create('legacy'); + } + + public function testParserRejectsReportAboveSafetyLimit(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('the report exceeds the 16 MiB safety limit'); + + (new NpmAuditParser())->parse('{' . str_repeat(' ', 16 * 1024 * 1024) . '}'); + } + + public function testParsersReadCleanReports(): void + { + self::assertSame([], (new NpmAuditParser())->parse(self::fixture('npm-clean.json'))); + self::assertSame([], (new PnpmAuditParser())->parse(self::fixture('pnpm-clean.json'))); + self::assertSame([], (new YarnAuditParser())->parse(self::fixture('yarn-clean.ndjson'))); + self::assertSame([], (new BunAuditParser())->parse(self::fixture('bun-clean.json'))); + } + + #[DataProvider('getMalformedReportData')] + public function testParsersRejectMalformedReports( + AuditParserInterface $parser, + string $output, + string $expectedMessage, + ): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($expectedMessage); + + $parser->parse($output); + } + + public function testPnpmParserAcceptsZeroIdAndPreservesOptionalHeaderValuesAndCves(): void + { + $data = json_decode(self::fixture('pnpm-populated.json'), true, 512, JSON_THROW_ON_ERROR); + $advisory = $data['advisories']['1106913']; + $advisory['id'] = 0; + $advisory['title'] = ''; + $advisory['url'] = 'https://security.example.test/advisories/0'; + $advisory['cves'] = ['cve-2021-23337', 'CVE-2021-23337']; + $data['advisories'] = (object) ['0' => $advisory]; + $data['metadata']['vulnerabilities']['info'] = 0; + + $findings = (new PnpmAuditParser())->parse(json_encode($data, JSON_THROW_ON_ERROR)); + + self::assertCount(1, $findings); + self::assertSame('0', $findings[0]->sourceId); + self::assertSame('GHSA-35jh-r3h4-6jhm', $findings[0]->advisoryId); + self::assertSame('', $findings[0]->title); + self::assertSame('https://security.example.test/advisories/0', $findings[0]->url); + self::assertSame(['CVE-2021-23337'], $findings[0]->cves); + self::assertSame(CveStatus::RESOLVED, $findings[0]->cveStatus); + } + + #[DataProvider('getPnpmFieldValidationData')] + public function testPnpmParserAppliesExtractedFieldValidation(Closure $mutate, string $expectedMessage): void + { + $data = json_decode(self::fixture('pnpm-populated.json'), true, 512, JSON_THROW_ON_ERROR); + $mutate($data); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($expectedMessage); + + (new PnpmAuditParser())->parse(json_encode($data, JSON_THROW_ON_ERROR)); + } + + public function testPnpmParserPrefersExplicitGhsaIdOverAdvisoryUrl(): void + { + $data = json_decode(self::fixture('pnpm-populated.json'), true, 512, JSON_THROW_ON_ERROR); + $data['advisories']['1106913']['github_advisory_id'] = 'GHSA-2222-3333-4444'; + + $findings = (new PnpmAuditParser())->parse(json_encode($data, JSON_THROW_ON_ERROR)); + + self::assertSame('GHSA-2222-3333-4444', $findings[0]->advisoryId); + } + + public function testPnpmParserReadsInstalledVersionsAndPaths(): void + { + $findings = (new PnpmAuditParser())->parse(self::fixture('pnpm-populated.json')); + + self::assertCount(2, $findings); + self::assertSame('lodash', $findings[0]->package); + self::assertSame(Severity::HIGH, $findings[0]->severity); + self::assertSame('GHSA-35jh-r3h4-6jhm', $findings[0]->advisoryId); + self::assertSame('1106913', $findings[0]->sourceId); + self::assertSame('https://github.com/advisories/GHSA-35jh-r3h4-6jhm', $findings[0]->url); + self::assertSame(CveStatus::NOT_REQUESTED, $findings[0]->cveStatus); + self::assertSame(['4.17.19', '4.17.20'], $findings[0]->affectedVersions); + self::assertSame(['project>lodash', 'project>parent>lodash'], $findings[0]->dependencyPaths); + self::assertSame('1107000', $findings[1]->advisoryId); + self::assertSame(['1.0.0'], $findings[1]->affectedVersions); + self::assertSame([], $findings[1]->dependencyPaths); + } + + public function testPnpmParserRejectsInconsistentMetadataCounts(): void + { + $data = json_decode(self::fixture('pnpm-populated.json'), true, 512, JSON_THROW_ON_ERROR); + $data['metadata']['vulnerabilities']['high'] = 2; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('metadata vulnerability counts must equal the advisory entries'); + + (new PnpmAuditParser())->parse(json_encode($data, JSON_THROW_ON_ERROR)); + } + + public function testYarnParserReadsEveryNdjsonRecord(): void + { + $findings = (new YarnAuditParser())->parse(self::fixture('yarn-populated.ndjson')); + + self::assertCount(2, $findings); + self::assertSame('@scope/package', $findings[0]->package); + self::assertSame(Severity::MODERATE, $findings[0]->severity); + self::assertSame('GHSA-2222-3333-4444', $findings[0]->advisoryId); + self::assertSame('1089254', $findings[0]->sourceId); + self::assertSame(['1.2.5', '1.2.6'], $findings[0]->affectedVersions); + self::assertSame( + ['parent@npm:2.0.3', 'workspace@workspace:.'], + $findings[0]->dependencyPaths, + ); + self::assertSame('1107000', $findings[1]->advisoryId); + self::assertSame(Severity::INFO, $findings[1]->severity); + } + + public function testYarnParserRejectsReportAboveSafetyLimit(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('the report exceeds the 16 MiB safety limit'); + + (new YarnAuditParser())->parse(str_repeat(' ', 16 * 1024 * 1024 + 1)); + } +} diff --git a/tests/Audit/AuditRunnerTest.php b/tests/Audit/AuditRunnerTest.php new file mode 100644 index 0000000..122e4b4 --- /dev/null +++ b/tests/Audit/AuditRunnerTest.php @@ -0,0 +1,214 @@ + [0, self::fixture('bun-clean.json')], + 'native status one and vulnerable body' => [1, self::fixture('bun-populated.json')], + ]; + } + + public function testRunnerAcceptsExitOneWithValidFindingsAndForwardsNoDev(): void + { + $manager = $this->manager( + 'npm', + new AuditProcessResult(1, self::fixture('npm-populated.json'), 'registry warning'), + true, + ); + + $report = (new AuditRunner($manager))->audit(new AuditRequest(Severity::HIGH, true)); + + self::assertSame('npm', $report->manager); + self::assertSame('registry warning', $report->diagnostics); + self::assertCount(2, $report->findings); + self::assertSame(Severity::CRITICAL, $report->findings[0]->severity); + self::assertSame(Severity::HIGH, $report->findings[1]->severity); + } + + public function testRunnerAcceptsSuccessfulCleanReport(): void + { + $manager = $this->manager('bun', new AuditProcessResult(0, self::fixture('bun-clean.json'), "\n")); + + $report = (new AuditRunner($manager))->audit(new AuditRequest()); + + self::assertSame('bun', $report->manager); + self::assertSame([], $report->findings); + self::assertSame('', $report->diagnostics); + } + + public function testRunnerDeduplicatesAndMergesEquivalentFindings(): void + { + $manager = $this->manager( + 'yarn', + new AuditProcessResult(1, self::fixture('yarn-duplicates.ndjson'), ''), + ); + + $report = (new AuditRunner($manager))->audit(new AuditRequest()); + + self::assertCount(1, $report->findings); + self::assertSame(Severity::HIGH, $report->findings[0]->severity); + self::assertSame('Second title', $report->findings[0]->title); + self::assertSame('https://security.example.test/second', $report->findings[0]->url); + self::assertSame(['1.0.0', '1.1.0'], $report->findings[0]->affectedVersions); + self::assertSame( + ['parent-a@npm:1.0.0', 'parent-b@npm:2.0.0'], + $report->findings[0]->dependencyPaths, + ); + } + + public function testRunnerMarksMergedDuplicateCvesAsResolved(): void + { + $data = json_decode(self::fixture('pnpm-populated.json'), true, 512, JSON_THROW_ON_ERROR); + $duplicate = $data['advisories']['1106913']; + $duplicate['id'] = 1106914; + $duplicate['severity'] = 'moderate'; + $duplicate['cves'] = ['CVE-2021-23337']; + $data['advisories']['1106914'] = $duplicate; + $data['metadata']['vulnerabilities']['moderate'] = 1; + $manager = $this->manager( + 'pnpm', + new AuditProcessResult(1, json_encode($data, JSON_THROW_ON_ERROR), ''), + ); + + $report = (new AuditRunner($manager))->audit(new AuditRequest()); + + self::assertCount(2, $report->findings); + self::assertSame(Severity::HIGH, $report->findings[0]->severity); + self::assertSame(['CVE-2021-23337'], $report->findings[0]->cves); + self::assertSame(CveStatus::RESOLVED, $report->findings[0]->cveStatus); + } + + public function testRunnerPreservesTheHigherSeverityWhenDuplicateFindingsAreReversed(): void + { + $findings = explode("\n", trim(self::fixture('yarn-duplicates.ndjson'))); + $manager = $this->manager( + 'yarn', + new AuditProcessResult(1, implode("\n", array_reverse($findings)), ''), + ); + + $report = (new AuditRunner($manager))->audit(new AuditRequest()); + + self::assertCount(1, $report->findings); + self::assertSame(Severity::HIGH, $report->findings[0]->severity); + self::assertSame('Second title', $report->findings[0]->title); + self::assertSame('https://security.example.test/second', $report->findings[0]->url); + } + + #[DataProvider('bunPartialReports')] + public function testRunnerRejectsBunPartialReportDiagnostics(int $exitCode, string $output): void + { + $manager = $this->manager( + 'bun', + new AuditProcessResult( + $exitCode, + $output, + 'warn: https://registry.example/ did not answer the audit request (404); skipped @scope/private', + ), + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'The bun audit command produced diagnostics and may have returned a partial report. ' + . 'warn: https://registry.example/ did not answer the audit request (404); skipped @scope/private', + ); + + (new AuditRunner($manager))->audit(new AuditRequest()); + } + + public function testRunnerRejectsCleanReportWithExitOne(): void + { + $manager = $this->manager( + 'npm', + new AuditProcessResult(1, self::fixture('npm-clean.json'), 'network error'), + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionCode(1); + $this->expectExceptionMessage('The npm audit command failed with status code 1. network error'); + + (new AuditRunner($manager))->audit(new AuditRequest()); + } + + public function testRunnerRejectsMalformedOutputAndIncludesManagerDiagnostics(): void + { + $manager = $this->manager('npm', new AuditProcessResult(1, '{', 'registry unavailable')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The npm audit output is malformed: invalid JSON. Manager error: registry unavailable'); + + (new AuditRunner($manager))->audit(new AuditRequest()); + } + + public function testRunnerRejectsSuccessfulStatusWithBlockingFinding(): void + { + $manager = $this->manager( + 'bun', + new AuditProcessResult(0, self::fixture('bun-populated.json'), ''), + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'The bun audit report contains vulnerabilities but the manager returned a successful status.', + ); + + (new AuditRunner($manager))->audit(new AuditRequest()); + } + + public function testRunnerRejectsUnexpectedExitCode(): void + { + $manager = $this->manager('pnpm', new AuditProcessResult(2, '', " request failed \n")); + + $this->expectException(RuntimeException::class); + $this->expectExceptionCode(2); + $this->expectExceptionMessage('The pnpm audit command failed with status code 2. request failed'); + + (new AuditRunner($manager))->audit(new AuditRequest()); + } + + private function manager( + string $name, + AuditProcessResult $result, + bool $expectedNoDev = false, + ): AssetManagerInterface&AuditableAssetManagerInterface&MockObject { + /** @var AssetManagerInterface&AuditableAssetManagerInterface&MockObject $manager */ + $manager = $this->createMockForIntersectionOfInterfaces([ + AssetManagerInterface::class, + AuditableAssetManagerInterface::class, + ]); + $manager->expects(self::once())->method('getName')->willReturn($name); + $manager->expects(self::once())->method('audit')->with($expectedNoDev)->willReturn($result); + + return $manager; + } +} diff --git a/tests/Audit/CveEnricherTest.php b/tests/Audit/CveEnricherTest.php new file mode 100644 index 0000000..ac42ebb --- /dev/null +++ b/tests/Audit/CveEnricherTest.php @@ -0,0 +1,184 @@ + ['prefix-GHSA-35jh-r3h4-6jhm'], + 'suffix' => ['GHSA-35jh-r3h4-6jhm-suffix'], + ]; + } + + public function testEnricherCachesResolutionForRepeatedGhsa(): void + { + $resolver = $this->createMock(CveResolverInterface::class); + $resolver + ->expects(self::once()) + ->method('resolve') + ->with('GHSA-35jh-r3h4-6jhm') + ->willReturn(new CveResolution(['CVE-2021-23337'], CveStatus::RESOLVED)); + $report = new AuditReport( + 'npm', + [ + $this->finding('lodash', 'GHSA-35jh-r3h4-6jhm'), + $this->finding('dependent-package', 'GHSA-35jh-r3h4-6jhm'), + ], + ); + + $enriched = (new CveEnricher($resolver))->enrich( + $report, + static fn(string $warning) => self::fail($warning), + ); + + self::assertSame(['CVE-2021-23337'], $enriched->findings[0]->cves); + self::assertSame(CveStatus::RESOLVED, $enriched->findings[0]->cveStatus); + self::assertSame(['CVE-2021-23337'], $enriched->findings[1]->cves); + self::assertSame(CveStatus::RESOLVED, $enriched->findings[1]->cveStatus); + } + + public function testEnricherDoesNotRequestExistingCves(): void + { + $resolver = $this->createMock(CveResolverInterface::class); + $resolver + ->expects(self::once()) + ->method('resolve') + ->with('GHSA-35jh-r3h4-6jhm') + ->willReturn(new CveResolution([], CveStatus::NONE_ASSIGNED)); + $finding = new AuditFinding( + 'lodash', + Severity::HIGH, + '1106913', + '1106913', + 'Prototype pollution', + '<4.17.21', + cves: ['CVE-2021-23337'], + ); + + $enriched = (new CveEnricher($resolver))->enrich( + new AuditReport( + 'pnpm', + [$finding, $this->finding('example-package', 'GHSA-35jh-r3h4-6jhm')], + ), + static fn(string $warning) => self::fail($warning), + ); + + self::assertCount(2, $enriched->findings); + self::assertSame(['CVE-2021-23337'], $enriched->findings[0]->cves); + self::assertSame(CveStatus::RESOLVED, $enriched->findings[0]->cveStatus); + self::assertSame(CveStatus::NONE_ASSIGNED, $enriched->findings[1]->cveStatus); + } + + public function testEnricherMarksNativeAdvisoryAsUnavailableWithoutNetworkRequest(): void + { + $resolver = $this->createMock(CveResolverInterface::class); + $resolver->expects(self::never())->method('resolve'); + + $enriched = (new CveEnricher($resolver))->enrich( + new AuditReport( + 'bun', + [ + $this->finding('example-package', '1107000'), + $this->finding('dependent-package', '1107001'), + ], + ), + static fn(string $warning) => self::fail($warning), + ); + + self::assertCount(2, $enriched->findings); + self::assertSame(CveStatus::UNAVAILABLE, $enriched->findings[0]->cveStatus); + self::assertSame(CveStatus::UNAVAILABLE, $enriched->findings[1]->cveStatus); + } + + public function testEnricherMarksSuccessfulResolutionWithoutCve(): void + { + $resolver = $this->createMock(CveResolverInterface::class); + $resolver + ->expects(self::once()) + ->method('resolve') + ->willReturn(new CveResolution([], CveStatus::NONE_ASSIGNED)); + + $enriched = (new CveEnricher($resolver))->enrich( + new AuditReport('bun', [$this->finding('lodash', 'GHSA-35jh-r3h4-6jhm')]), + static fn(string $warning) => self::fail($warning), + ); + + self::assertSame([], $enriched->findings[0]->cves); + self::assertSame(CveStatus::NONE_ASSIGNED, $enriched->findings[0]->cveStatus); + } + + #[DataProvider('advisoryIdsWithSurroundingText')] + public function testEnricherRejectsGhsaWithSurroundingText(string $advisoryId): void + { + $resolver = $this->createMock(CveResolverInterface::class); + $resolver->expects(self::never())->method('resolve'); + + $enriched = (new CveEnricher($resolver))->enrich( + new AuditReport('npm', [$this->finding('example-package', $advisoryId)]), + static fn(string $warning) => self::fail($warning), + ); + + self::assertSame(CveStatus::UNAVAILABLE, $enriched->findings[0]->cveStatus); + } + + public function testEnricherWarnsOnceAndPreservesFindingsWhenResolutionFails(): void + { + $resolver = $this->createMock(CveResolverInterface::class); + $resolver + ->expects(self::once()) + ->method('resolve') + ->willThrowException(new RuntimeException('rate limited')); + $warnings = []; + $report = new AuditReport( + 'yarn', + [ + $this->finding('lodash', 'GHSA-35jh-r3h4-6jhm'), + $this->finding('dependent-package', 'GHSA-35jh-r3h4-6jhm'), + ], + ); + + $enriched = (new CveEnricher($resolver))->enrich( + $report, + static function (string $warning) use (&$warnings): void { + $warnings[] = $warning; + }, + ); + + self::assertCount(2, $enriched->findings); + self::assertSame(CveStatus::UNAVAILABLE, $enriched->findings[0]->cveStatus); + self::assertSame(CveStatus::UNAVAILABLE, $enriched->findings[1]->cveStatus); + self::assertSame( + ['Unable to resolve CVE identifiers for GHSA-35jh-r3h4-6jhm: rate limited'], + $warnings, + ); + } + + private function finding(string $package, string $advisoryId): AuditFinding + { + return new AuditFinding( + $package, + Severity::HIGH, + $advisoryId, + '1106913', + 'Prototype pollution', + '<4.17.21', + ); + } +} diff --git a/tests/Audit/GitHubAdvisoryCveResolverTest.php b/tests/Audit/GitHubAdvisoryCveResolverTest.php new file mode 100644 index 0000000..2fc646b --- /dev/null +++ b/tests/Audit/GitHubAdvisoryCveResolverTest.php @@ -0,0 +1,194 @@ + ['prefix-GHSA-35jh-r3h4-6jhm'], + 'suffix' => ['GHSA-35jh-r3h4-6jhm-suffix'], + ]; + } + + public static function invalidIdentifiers(): array + { + return [ + 'not an array' => ['"invalid"'], + 'not a list' => ['{"type":"CVE","value":"CVE-2021-23337"}'], + ]; + } + + public function testResolverCollectsDeduplicatesAndSortsCves(): void + { + $resolver = new GitHubAdvisoryCveResolver( + $this->downloader( + self::fixture('github-advisory-with-cves.json'), + self::GHSA_WITH_CVES_ID, + ), + ); + + $resolution = $resolver->resolve(' ghsa-AAaa-bBBb-CccC '); + + self::assertSame(CveStatus::RESOLVED, $resolution->status); + self::assertSame(['CVE-2020-8203', 'CVE-2021-23337'], $resolution->cves); + } + + public function testResolverDistinguishesAdvisoryWithoutAssignedCve(): void + { + $resolver = new GitHubAdvisoryCveResolver( + $this->downloader( + self::fixture('github-advisory-without-cve.json'), + self::GHSA_WITHOUT_CVE_ID, + ), + ); + + $resolution = $resolver->resolve(self::GHSA_WITHOUT_CVE_ID); + + self::assertSame(CveStatus::NONE_ASSIGNED, $resolution->status); + self::assertSame([], $resolution->cves); + } + + public function testResolverNormalizesAndFiltersCveIdentifiers(): void + { + $resolver = new GitHubAdvisoryCveResolver( + $this->downloader( + <<<'JSON' + { + "ghsa_id": "GHSA-35JH-R3H4-6JHM", + "cve_id": " cve-2021-9999 ", + "identifiers": [ + {"type": "GHSA", "value": "CVE-2019-0001"}, + {"type": "CVE", "value": " CVE-2023-0002 "}, + {"type": "CVE", "value": "cve-2022-0001"}, + {"type": "CVE", "value": "prefixCVE-2020-0001"}, + {"type": "CVE", "value": "CVE-2020-0001suffix"} + ] + } + JSON, + self::GHSA_ID, + ), + ); + + $resolution = $resolver->resolve(self::GHSA_ID); + + self::assertSame(CveStatus::RESOLVED, $resolution->status); + self::assertSame( + ['CVE-2021-9999', 'CVE-2022-0001', 'CVE-2023-0002'], + $resolution->cves, + ); + } + + #[DataProvider('ghsaIdsWithSurroundingText')] + public function testResolverRejectsGhsaWithSurroundingTextBeforeRequest(string $ghsaId): void + { + $downloader = $this->createMock(HttpDownloader::class); + $downloader->expects(self::never())->method('get'); + $resolver = new GitHubAdvisoryCveResolver($downloader); + + $this->expectException(RuntimeException::class); + + $resolver->resolve($ghsaId); + } + + public function testResolverRejectsInvalidGhsaBeforeRequest(): void + { + $downloader = $this->createMock(HttpDownloader::class); + $downloader->expects(self::never())->method('get'); + $resolver = new GitHubAdvisoryCveResolver($downloader); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The advisory identifier "not-a-ghsa" is not a valid GHSA identifier.'); + + $resolver->resolve('not-a-ghsa'); + } + + #[DataProvider('invalidIdentifiers')] + public function testResolverRejectsInvalidIdentifiers(string $identifiers): void + { + $resolver = new GitHubAdvisoryCveResolver( + $this->downloader( + '{"ghsa_id":"GHSA-35jh-r3h4-6jhm","identifiers":' . $identifiers . '}', + self::GHSA_ID, + ), + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'GitHub returned invalid identifiers for GHSA-35jh-r3h4-6jhm.', + ); + + $resolver->resolve(self::GHSA_ID); + } + + public function testResolverRejectsMismatchedAdvisoryDocument(): void + { + $resolver = new GitHubAdvisoryCveResolver( + $this->downloader( + '{"ghsa_id":"GHSA-aaaa-bbbb-cccc","identifiers":[]}', + self::GHSA_ID, + ), + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'GitHub returned a mismatched advisory document for GHSA-35jh-r3h4-6jhm.', + ); + + $resolver->resolve(self::GHSA_ID); + } + + public function testResolverRejectsNonObjectAdvisoryDocument(): void + { + $resolver = new GitHubAdvisoryCveResolver( + $this->downloader('null', self::GHSA_ID), + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'GitHub returned an invalid advisory document for GHSA-35jh-r3h4-6jhm.', + ); + + $resolver->resolve(self::GHSA_ID); + } + + private function downloader(string $body, string $ghsaId): HttpDownloader&MockObject + { + $url = 'https://api.github.com/advisories/' . $ghsaId; + $downloader = $this->createMock(HttpDownloader::class); + $downloader + ->expects(self::once()) + ->method('get') + ->with( + $url, + [ + 'http' => [ + 'header' => [ + 'Accept: application/vnd.github+json', + 'X-GitHub-Api-Version: 2022-11-28', + ], + ], + ], + ) + ->willReturn(new Response(['url' => $url], 200, [], $body)); + + return $downloader; + } +} diff --git a/tests/Command/AuditCommandTest.php b/tests/Command/AuditCommandTest.php new file mode 100644 index 0000000..a4d2d10 --- /dev/null +++ b/tests/Command/AuditCommandTest.php @@ -0,0 +1,333 @@ + [AuditFormat::TABLE->value], + 'plain' => [AuditFormat::PLAIN->value], + 'json' => [AuditFormat::JSON->value], + 'summary' => [AuditFormat::SUMMARY->value], + ]; + } + + public static function invalidAuditLevels(): array + { + return [ + 'informational is not a failure threshold' => [Severity::INFO->value], + 'unknown severity' => ['severe'], + ]; + } + + #[DataProvider('auditFormats')] + public function testAcceptsEveryDocumentedFormat(string $format): void + { + $runner = $this->createMock(AuditRunnerInterface::class); + $runner->expects(self::once())->method('audit')->willReturn(new AuditReport('npm', [])); + $tester = $this->createTester($runner); + + self::assertSame(AuditCommand::STATUS_OK, $tester->execute(['--format' => $format])); + self::assertNotSame('', $tester->getDisplay(true)); + } + + #[DataProvider('thresholdStatuses')] + public function testAuditLevelControlsTheExitStatus( + Severity $findingSeverity, + Severity $minimumSeverity, + int $expectedStatus, + ): void { + $runner = $this->createMock(AuditRunnerInterface::class); + $runner + ->expects(self::once()) + ->method('audit') + ->with( + self::callback( + static fn(AuditRequest $request): bool => $minimumSeverity === $request->minimumSeverity + && $request->noDev, + ), + ) + ->willReturn(new AuditReport('npm', [self::finding($findingSeverity)])); + $tester = $this->createTester($runner); + + self::assertSame( + $expectedStatus, + $tester->execute( + [ + '--audit-level' => $minimumSeverity->value, + '--format' => AuditFormat::SUMMARY->value, + '--no-dev' => true, + ], + ), + ); + self::assertStringContainsString('1 advisory affecting 1 package', $tester->getDisplay(true)); + } + + public function testCommandConfiguration(): void + { + $command = new AuditCommand($this->createMock(AuditRunnerInterface::class)); + $definition = $command->getDefinition(); + + self::assertSame('foxy:audit', $command->getName()); + self::assertSame('Checks frontend dependencies for known security vulnerabilities', $command->getDescription()); + self::assertSame(AuditFormat::TABLE->value, $definition->getOption('format')->getDefault()); + self::assertSame('f', $definition->getOption('format')->getShortcut()); + self::assertSame(Severity::LOW->value, $definition->getOption('audit-level')->getDefault()); + self::assertFalse($definition->getOption('no-dev')->getDefault()); + self::assertFalse($definition->getOption('no-cve')->getDefault()); + self::assertStringContainsString('Exit status 0', $command->getHelp()); + self::assertStringContainsString('1 means at least one advisory', $command->getHelp()); + self::assertStringContainsString('2 means the audit could not be completed reliably', $command->getHelp()); + } + + /** + * @throws JsonException + */ + public function testCveResolutionFailureWritesASanitizedWarningAndPreservesTheReport(): void + { + $runner = $this->createMock(AuditRunnerInterface::class); + $runner + ->expects(self::once()) + ->method('audit') + ->willReturn(new AuditReport('npm', [self::finding(Severity::HIGH)])); + $resolver = $this->createMock(CveResolverInterface::class); + $resolver + ->expects(self::once()) + ->method('resolve') + ->with('GHSA-aaaa-bbbb-cccc') + ->willThrowException(new RuntimeException("rate\nlimited\0now")); + $io = $this->createMock(IOInterface::class); + $io + ->expects(self::once()) + ->method('writeError') + ->with( + 'Unable to resolve CVE identifiers for GHSA-aaaa-bbbb-cccc: rate limited now', + ); + $tester = $this->createTester($runner, $resolver, $io); + + self::assertSame( + AuditCommand::STATUS_VULNERABLE, + $tester->execute(['--format' => AuditFormat::JSON->value]), + ); + + /** @var array $document */ + $document = json_decode($tester->getDisplay(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame(CveStatus::UNAVAILABLE->value, $document['advisories'][0]['cve_status']); + } + + public function testDefaultOptionsProduceASuccessfulTableAudit(): void + { + $runner = $this->createMock(AuditRunnerInterface::class); + $runner + ->expects(self::once()) + ->method('audit') + ->with( + self::callback( + static fn(AuditRequest $request): bool => Severity::LOW === $request->minimumSeverity + && !$request->noDev, + ), + ) + ->willReturn(new AuditReport('npm', [])); + $tester = $this->createTester($runner); + + self::assertSame(AuditCommand::STATUS_OK, $tester->execute([])); + self::assertSame("No known frontend vulnerabilities found.\n", $tester->getDisplay(true)); + } + + #[DataProvider('invalidAuditLevels')] + public function testInvalidAuditLevelReturnsOperationalFailureWithoutRunningAudit(string $auditLevel): void + { + $runner = $this->createMock(AuditRunnerInterface::class); + $runner->expects(self::never())->method('audit'); + $io = $this->createMock(IOInterface::class); + $io + ->expects(self::once()) + ->method('writeError') + ->with('The audit level must be low, moderate, high, or critical.'); + $tester = $this->createTester($runner, io: $io); + + self::assertSame(AuditCommand::STATUS_FAILED, $tester->execute(['--audit-level' => $auditLevel])); + self::assertSame('', $tester->getDisplay(true)); + } + + public function testInvalidFormatReturnsOperationalFailureWithoutRunningAudit(): void + { + $runner = $this->createMock(AuditRunnerInterface::class); + $runner->expects(self::never())->method('audit'); + $io = $this->createMock(IOInterface::class); + $io + ->expects(self::once()) + ->method('writeError') + ->with('The audit output format must be table, plain, json, or summary.'); + $tester = $this->createTester($runner, io: $io); + + self::assertSame(AuditCommand::STATUS_FAILED, $tester->execute(['--format' => 'xml'])); + self::assertSame('', $tester->getDisplay(true)); + } + + /** + * @throws JsonException + */ + public function testJsonKeepsDiagnosticsOnComposerErrorOutput(): void + { + $runner = $this->createMock(AuditRunnerInterface::class); + $runner + ->expects(self::once()) + ->method('audit') + ->willReturn(new AuditReport('npm', [self::finding(Severity::HIGH)], "manager warning\nsecond line")); + $resolver = $this->createMock(CveResolverInterface::class); + $resolver + ->expects(self::once()) + ->method('resolve') + ->with('GHSA-aaaa-bbbb-cccc') + ->willReturn(new CveResolution(['CVE-2026-12345'], CveStatus::RESOLVED)); + $io = $this->createMock(IOInterface::class); + $io->expects(self::once())->method('writeError')->with('manager warning second line'); + $tester = $this->createTester($runner, $resolver, $io); + + self::assertSame( + AuditCommand::STATUS_VULNERABLE, + $tester->execute(['--format' => AuditFormat::JSON->value]), + ); + + /** @var array $document */ + $document = json_decode($tester->getDisplay(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame(1, $document['schema_version']); + self::assertSame('npm', $document['manager']); + self::assertTrue($document['affected']); + self::assertSame(['CVE-2026-12345'], $document['advisories'][0]['cves']); + self::assertSame(CveStatus::RESOLVED->value, $document['advisories'][0]['cve_status']); + self::assertStringNotContainsString('manager warning', $tester->getDisplay()); + } + + /** + * @throws JsonException + */ + public function testNoCveSkipsResolverAndPreservesMachineReadableOutput(): void + { + $runner = $this->createMock(AuditRunnerInterface::class); + $runner + ->expects(self::once()) + ->method('audit') + ->willReturn(new AuditReport('npm', [self::finding(Severity::LOW)])); + $resolver = $this->createMock(CveResolverInterface::class); + $resolver->expects(self::never())->method('resolve'); + $tester = $this->createTester($runner, $resolver); + + self::assertSame( + AuditCommand::STATUS_VULNERABLE, + $tester->execute(['--format' => AuditFormat::JSON->value, '--no-cve' => true]), + ); + + /** @var array $document */ + $document = json_decode($tester->getDisplay(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame([], $document['advisories'][0]['cves']); + self::assertSame(CveStatus::NOT_REQUESTED->value, $document['advisories'][0]['cve_status']); + } + + public function testRunnerFailureIsSanitizedAndReturnsOperationalFailure(): void + { + $runner = $this->createMock(AuditRunnerInterface::class); + $runner + ->expects(self::once()) + ->method('audit') + ->willThrowException(new RuntimeException("Process\nfailed\0now")); + $io = $this->createMock(IOInterface::class); + $io + ->expects(self::once()) + ->method('writeError') + ->with('Foxy audit failed: Process failed now'); + $tester = $this->createTester($runner, io: $io); + + self::assertSame(AuditCommand::STATUS_FAILED, $tester->execute([])); + self::assertSame('', $tester->getDisplay(true)); + } + + public function testRunnerFailurePreservesMessageWithInvalidUtf8(): void + { + $runner = $this->createMock(AuditRunnerInterface::class); + $runner + ->expects(self::once()) + ->method('audit') + ->willThrowException(new RuntimeException(" Process \xFF\nfailed ")); + $io = $this->createMock(IOInterface::class); + $io + ->expects(self::once()) + ->method('writeError') + ->with("Foxy audit failed: Process \xFF failed"); + $tester = $this->createTester($runner, io: $io); + + self::assertSame(AuditCommand::STATUS_FAILED, $tester->execute([])); + self::assertSame('', $tester->getDisplay(true)); + } + + public static function thresholdStatuses(): array + { + return [ + 'below threshold' => [Severity::MODERATE, Severity::HIGH, AuditCommand::STATUS_OK], + 'at threshold' => [Severity::HIGH, Severity::HIGH, AuditCommand::STATUS_VULNERABLE], + 'above threshold' => [Severity::CRITICAL, Severity::HIGH, AuditCommand::STATUS_VULNERABLE], + ]; + } + + private function createTester( + AuditRunnerInterface $runner, + CveResolverInterface|null $resolver = null, + IOInterface|null $io = null, + ): CommandTester { + $composer = $this->createMock(Composer::class); + $composer->method('getEventDispatcher')->willReturn($this->createMock(EventDispatcher::class)); + $command = new AuditCommand($runner, cveResolver: $resolver); + $command->setComposer($composer); + $command->setIO($io ?? new NullIO()); + $command->setApplication(new Application()); + + return new CommandTester($command); + } + + private static function finding(Severity $severity): AuditFinding + { + return new AuditFinding( + 'example-package', + $severity, + 'GHSA-aaaa-bbbb-cccc', + '1234', + 'Example vulnerability', + '<2.0.0', + 'https://github.com/advisories/GHSA-aaaa-bbbb-cccc', + ); + } +} diff --git a/tests/Command/FoxyCapabilityTest.php b/tests/Command/FoxyCapabilityTest.php new file mode 100644 index 0000000..3dd0873 --- /dev/null +++ b/tests/Command/FoxyCapabilityTest.php @@ -0,0 +1,112 @@ +createMockForIntersectionOfInterfaces( + [AssetManagerInterface::class, AuditableAssetManagerInterface::class], + ); + $manager->expects(self::once())->method('getName')->willReturn('npm'); + $manager + ->expects(self::once()) + ->method('audit') + ->with(true) + ->willReturn( + new AuditProcessResult( + 0, + '{"auditReportVersion":2,"vulnerabilities":{},"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0,"total":0},"dependencies":{"prod":0,"dev":0,"optional":0,"peer":0,"peerOptional":0,"total":0}}}', + ' manager diagnostic ', + ), + ); + $foxy = new Foxy(); + self::setProperty($foxy, 'config', new Config([], ['enabled' => true])); + self::setProperty($foxy, 'assetManager', $manager); + + $report = $foxy->audit(new AuditRequest(Severity::HIGH, true)); + + self::assertSame('npm', $report->manager); + self::assertSame([], $report->findings); + self::assertSame('manager diagnostic', $report->diagnostics); + } + + public function testAuditRejectsDisabledPlugin(): void + { + $foxy = new Foxy(); + self::setProperty($foxy, 'config', new Config([], ['enabled' => false])); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Foxy is disabled; frontend dependencies cannot be audited.'); + + $foxy->audit(new AuditRequest()); + } + + public function testAuditRejectsManagerWithoutAuditCapability(): void + { + $foxy = new Foxy(); + self::setProperty($foxy, 'config', new Config([], ['enabled' => true])); + self::setProperty($foxy, 'assetManager', $this->createMock(AssetManagerInterface::class)); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The selected asset manager does not support security audits.'); + + $foxy->audit(new AuditRequest()); + } + + public function testAuditRejectsMissingAssetManager(): void + { + $foxy = new Foxy(); + self::setProperty($foxy, 'config', new Config([], ['enabled' => true])); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The selected asset manager does not support security audits.'); + + $foxy->audit(new AuditRequest()); + } + + public function testAuditRejectsUnactivatedPlugin(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Foxy is disabled; frontend dependencies cannot be audited.'); + + (new Foxy())->audit(new AuditRequest()); + } + + public function testDeclaresCommandProviderCapability(): void + { + $foxy = new Foxy(); + + self::assertInstanceOf(Capable::class, $foxy); + self::assertInstanceOf(AuditRunnerInterface::class, $foxy); + self::assertSame( + [ComposerCommandProvider::class => FoxyCommandProvider::class], + $foxy->getCapabilities(), + ); + } + + private static function setProperty(Foxy $foxy, string $property, object $value): void + { + (new ReflectionClass($foxy))->getProperty($property)->setValue($foxy, $value); + } +} diff --git a/tests/Command/FoxyCommandProviderTest.php b/tests/Command/FoxyCommandProviderTest.php new file mode 100644 index 0000000..74e695b --- /dev/null +++ b/tests/Command/FoxyCommandProviderTest.php @@ -0,0 +1,70 @@ + ['composer'], + 'io' => ['io'], + 'plugin' => ['plugin'], + ]; + } + + public function testProvidesAuditCommandWithActiveComposerAndIo(): void + { + $composer = $this->createMock(Composer::class); + $io = $this->createMock(IOInterface::class); + $provider = new FoxyCommandProvider( + [ + 'composer' => $composer, + 'io' => $io, + 'plugin' => new Foxy(), + ], + ); + + $commands = $provider->getCommands(); + + self::assertCount(1, $commands); + self::assertInstanceOf(AuditCommand::class, $commands[0]); + self::assertSame($composer, $commands[0]->requireComposer()); + self::assertSame($io, $commands[0]->getIO()); + } + + #[DataProvider('invalidArguments')] + public function testRejectsInvalidCapabilityArgument(string $argument): void + { + $arguments = [ + 'composer' => $this->createMock(Composer::class), + 'io' => $this->createMock(IOInterface::class), + 'plugin' => new Foxy(), + ]; + $arguments[$argument] = new stdClass(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Composer provided invalid Foxy command capability arguments.'); + + new FoxyCommandProvider($arguments); + } + + public function testRejectsMissingCapabilityArguments(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Composer provided invalid Foxy command capability arguments.'); + + new FoxyCommandProvider([]); + } +} diff --git a/tests/Config/ConfigTest.php b/tests/Config/ConfigTest.php index 842ca4a..96bdff3 100644 --- a/tests/Config/ConfigTest.php +++ b/tests/Config/ConfigTest.php @@ -283,7 +283,7 @@ public function testResolvedManagerSelectsManagerSpecificDefaults(): void { $config = new FoxyConfig( [], - ['manager-version' => ['npm' => '^12.0.2', 'yarn' => '^4.18.0']], + ['manager-version' => ['npm' => '>=10.9.8', 'yarn' => '^4.18.0']], ); self::assertNull($config->get('manager-version')); diff --git a/tests/Fallback/ComposerFallbackTest.php b/tests/Fallback/ComposerFallbackTest.php index 698daa6..305ea87 100644 --- a/tests/Fallback/ComposerFallbackTest.php +++ b/tests/Fallback/ComposerFallbackTest.php @@ -233,6 +233,54 @@ public function testRestoreCombinesOptimizeAutoloaderSourcesIndependently(): voi self::assertTrue($this->getInstallerProperty('optimizeAutoloader')); } + /** + * @throws Exception|JsonException + */ + public function testRestorePreservesLockMetadata(): void + { + $packages = [['name' => 'foo/bar', 'version' => '1.0.0.0']]; + $devPackages = [['name' => 'foo/dev', 'version' => '2.0.0.0']]; + $platform = ['php' => '^8.2']; + $platformDev = ['ext-json' => '*']; + $stabilityFlags = ['foo/bar' => 10]; + $platformOverrides = ['php' => '8.3.4']; + + $this->setupRestoreEnvironment( + $packages, + static fn($option): bool|null => 'verbose' === $option ? false : null, + lockOptions: [ + 'packages-dev' => $devPackages, + 'platform' => $platform, + 'platform-dev' => $platformDev, + 'minimum-stability' => 'beta', + 'stability-flags' => $stabilityFlags, + 'prefer-lowest' => true, + 'platform-overrides' => $platformOverrides, + ], + ); + + $this->expectInstallerRun(); + $this->composerFallback->save(); + $this->composerFallback->restore(); + + $restoredLock = json_decode( + file_get_contents($this->cwd . '/composer.lock'), + true, + 512, + JSON_THROW_ON_ERROR, + ); + + self::assertSame('foo/bar', $restoredLock['packages'][0]['name']); + self::assertSame('foo/dev', $restoredLock['packages-dev'][0]['name']); + self::assertSame($platform, $restoredLock['platform']); + self::assertSame($platformDev, $restoredLock['platform-dev']); + self::assertSame('beta', $restoredLock['minimum-stability']); + self::assertSame($stabilityFlags, $restoredLock['stability-flags']); + self::assertTrue($restoredLock['prefer-stable']); + self::assertTrue($restoredLock['prefer-lowest']); + self::assertSame($platformOverrides, $restoredLock['platform-overrides']); + } + /** * @throws Exception|JsonException */ @@ -766,6 +814,7 @@ private function setupRestoreEnvironment( array $aliases = [], int $restoreCount = 1, array $configOptions = [], + array $lockOptions = [], ): void { $composerFile = 'composer.json'; $composerContent = '{}'; @@ -776,13 +825,13 @@ private function setupRestoreEnvironment( file_put_contents( $this->cwd . '/' . $lockFile, json_encode( - [ + array_replace([ 'content-hash' => 'HASH_VALUE', 'packages' => $packages, 'packages-dev' => [], 'aliases' => $aliases, 'prefer-stable' => true, - ], + ], $lockOptions), JSON_THROW_ON_ERROR, ), ); diff --git a/tests/Fixtures/Asset/InspectableAssetManager.php b/tests/Fixtures/Asset/InspectableAssetManager.php index 8e27ed6..3f51497 100644 --- a/tests/Fixtures/Asset/InspectableAssetManager.php +++ b/tests/Fixtures/Asset/InspectableAssetManager.php @@ -8,11 +8,18 @@ final class InspectableAssetManager extends AbstractAssetManager { + /** + * @var array|null + */ + private array|null $auditEnvironment = null; + /** * @var list|null */ private array|null $handledDependencies = null; + private bool|null $validatedNoDev = null; + public function buildCommandForTest(string $defaultBin, string $action, array|string $command): string { return $this->buildCommand($defaultBin, $action, $command); @@ -23,6 +30,16 @@ public function disableVersionConverterForTest(): void $this->versionConverter = null; } + public function getAuditCommandForTest(bool $noDev = false): string + { + return $this->getAuditCommand($noDev); + } + + public function getAuditValidationForTest(): bool|null + { + return $this->validatedNoDev; + } + /** * @return list|null */ @@ -66,6 +83,14 @@ public function getVersionForTest(): string|null return $this->getVersion(); } + /** + * @param array $environment + */ + public function setAuditEnvironmentForTest(array $environment): void + { + $this->auditEnvironment = $environment; + } + protected function actionWhenComposerDependenciesAreAlreadyInstalled(array $names): void { parent::actionWhenComposerDependenciesAreAlreadyInstalled($names); @@ -73,6 +98,16 @@ protected function actionWhenComposerDependenciesAreAlreadyInstalled(array $name $this->handledDependencies = $names; } + protected function getAuditCommand(bool $noDev): string + { + return $this->buildUnconfiguredCommand('inspectable', $noDev ? ['audit', '--prod'] : ['audit']); + } + + protected function getAuditEnvironment(): array + { + return [...parent::getAuditEnvironment(), ...($this->auditEnvironment ?? [])]; + } + protected function getInstallCommand(): string { return $this->buildCommand('inspectable', 'install', 'install'); @@ -85,6 +120,13 @@ protected function getUpdateCommand(): string protected function getVersionCommand(): string { - return $this->buildCommand('inspectable', 'version', '--version'); + return $this->buildUnconfiguredCommand('inspectable', '--version'); + } + + protected function validateAuditConfiguration(bool $noDev): void + { + parent::validateAuditConfiguration($noDev); + + $this->validatedNoDev = $noDev; } } diff --git a/tests/Fixtures/Audit/bun-clean.json b/tests/Fixtures/Audit/bun-clean.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/Fixtures/Audit/bun-clean.json @@ -0,0 +1 @@ +{} diff --git a/tests/Fixtures/Audit/bun-populated.json b/tests/Fixtures/Audit/bun-populated.json new file mode 100644 index 0000000..0c73bc4 --- /dev/null +++ b/tests/Fixtures/Audit/bun-populated.json @@ -0,0 +1,27 @@ +{ + "lodash": [ + { + "id": 1106913, + "url": "https://github.com/advisories/GHSA-35jh-r3h4-6jhm", + "title": "Command Injection in lodash", + "severity": "high", + "vulnerable_versions": "<4.17.21", + "cwe": [ + "CWE-77", + "CWE-94" + ], + "cvss": { + "score": 7.2, + "vectorString": "CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H" + } + } + ], + "example-package": [ + { + "id": "1107000", + "url": "https://security.example.test/advisories/1107000", + "severity": "info", + "vulnerable_versions": "<=1.0.0" + } + ] +} diff --git a/tests/Fixtures/Audit/github-advisory-with-cves.json b/tests/Fixtures/Audit/github-advisory-with-cves.json new file mode 100644 index 0000000..b7d7ce8 --- /dev/null +++ b/tests/Fixtures/Audit/github-advisory-with-cves.json @@ -0,0 +1,22 @@ +{ + "ghsa_id": "GHSA-AAAA-BBBB-CCCC", + "cve_id": "CVE-2021-23337", + "identifiers": [ + { + "value": "GHSA-aaaa-bbbb-cccc", + "type": "GHSA" + }, + { + "value": "CVE-2020-8203", + "type": "CVE" + }, + { + "value": "CVE-2021-23337", + "type": "CVE" + }, + { + "value": "not-a-cve", + "type": "CVE" + } + ] +} diff --git a/tests/Fixtures/Audit/github-advisory-without-cve.json b/tests/Fixtures/Audit/github-advisory-without-cve.json new file mode 100644 index 0000000..afc7f06 --- /dev/null +++ b/tests/Fixtures/Audit/github-advisory-without-cve.json @@ -0,0 +1,10 @@ +{ + "ghsa_id": "GHSA-DDDD-EEEE-FFFF", + "cve_id": null, + "identifiers": [ + { + "value": "GHSA-dddd-eeee-ffff", + "type": "GHSA" + } + ] +} diff --git a/tests/Fixtures/Audit/npm-clean.json b/tests/Fixtures/Audit/npm-clean.json new file mode 100644 index 0000000..c83f76c --- /dev/null +++ b/tests/Fixtures/Audit/npm-clean.json @@ -0,0 +1,22 @@ +{ + "auditReportVersion": 2, + "vulnerabilities": {}, + "metadata": { + "vulnerabilities": { + "info": 0, + "low": 0, + "moderate": 0, + "high": 0, + "critical": 0, + "total": 0 + }, + "dependencies": { + "prod": 0, + "dev": 0, + "optional": 0, + "peer": 0, + "peerOptional": 0, + "total": 0 + } + } +} diff --git a/tests/Fixtures/Audit/npm-populated.json b/tests/Fixtures/Audit/npm-populated.json new file mode 100644 index 0000000..e256b9d --- /dev/null +++ b/tests/Fixtures/Audit/npm-populated.json @@ -0,0 +1,64 @@ +{ + "auditReportVersion": 2, + "vulnerabilities": { + "lodash": { + "name": "lodash", + "severity": "critical", + "isDirect": true, + "via": [ + { + "source": 1106913, + "name": "lodash", + "dependency": "lodash", + "title": "Command\nInjection in lodash", + "url": "https://github.com/advisories/GHSA-35jh-r3h4-6jhm", + "severity": "high", + "cwe": [ + "CWE-77", + "CWE-94" + ], + "cvss": { + "score": 7.2, + "vectorString": "CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H" + }, + "range": "<4.17.21" + }, + "transitive-vulnerable-package", + { + "source": "1108258", + "name": "lodash", + "dependency": "lodash", + "url": "https://github.com/advisories/GHSA-jf85-cpcp-j695", + "severity": "critical", + "range": ">=4.0.0 <4.17.21" + } + ], + "effects": [], + "range": "<=4.17.20", + "nodes": [ + "node_modules/lodash", + "node_modules/parent/node_modules/lodash", + "node_modules/lodash" + ], + "fixAvailable": true + } + }, + "metadata": { + "vulnerabilities": { + "info": 0, + "low": 0, + "moderate": 0, + "high": 0, + "critical": 1, + "total": 1 + }, + "dependencies": { + "prod": 1, + "dev": 0, + "optional": 0, + "peer": 0, + "peerOptional": 0, + "total": 1 + } + } +} diff --git a/tests/Fixtures/Audit/pnpm-clean.json b/tests/Fixtures/Audit/pnpm-clean.json new file mode 100644 index 0000000..1e50fc7 --- /dev/null +++ b/tests/Fixtures/Audit/pnpm-clean.json @@ -0,0 +1,16 @@ +{ + "advisories": {}, + "metadata": { + "vulnerabilities": { + "info": 0, + "low": 0, + "moderate": 0, + "high": 0, + "critical": 0 + }, + "dependencies": 0, + "devDependencies": 0, + "optionalDependencies": 0, + "totalDependencies": 0 + } +} diff --git a/tests/Fixtures/Audit/pnpm-populated.json b/tests/Fixtures/Audit/pnpm-populated.json new file mode 100644 index 0000000..f801dc6 --- /dev/null +++ b/tests/Fixtures/Audit/pnpm-populated.json @@ -0,0 +1,68 @@ +{ + "advisories": { + "1106913": { + "findings": [ + { + "version": "4.17.20", + "paths": [ + "project>lodash", + "project>lodash" + ], + "dev": false, + "optional": false, + "bundled": false + }, + { + "version": "4.17.19", + "paths": [ + "project>parent>lodash" + ], + "dev": true, + "optional": false, + "bundled": false + } + ], + "id": 1106913, + "title": "Command Injection in lodash", + "module_name": "lodash", + "vulnerable_versions": "<4.17.21", + "patched_versions": ">=4.17.21", + "severity": "high", + "cwe": "CWE-77", + "github_advisory_id": "GHSA-35JH-R3H4-6JHM", + "url": "https://github.com/advisories/GHSA-35jh-r3h4-6jhm" + }, + "1107000": { + "findings": [ + { + "version": "1.0.0", + "paths": [], + "dev": false, + "optional": true, + "bundled": false + } + ], + "id": 1107000, + "title": "Informational advisory", + "module_name": "example-package", + "vulnerable_versions": "<=1.0.0", + "severity": "info", + "cwe": "", + "github_advisory_id": "", + "url": "https://security.example.test/advisories/1107000" + } + }, + "metadata": { + "vulnerabilities": { + "info": 1, + "low": 0, + "moderate": 0, + "high": 1, + "critical": 0 + }, + "dependencies": 1, + "devDependencies": 1, + "optionalDependencies": 1, + "totalDependencies": 3 + } +} diff --git a/tests/Fixtures/Audit/yarn-clean.ndjson b/tests/Fixtures/Audit/yarn-clean.ndjson new file mode 100644 index 0000000..e69de29 diff --git a/tests/Fixtures/Audit/yarn-duplicates.ndjson b/tests/Fixtures/Audit/yarn-duplicates.ndjson new file mode 100644 index 0000000..df1623b --- /dev/null +++ b/tests/Fixtures/Audit/yarn-duplicates.ndjson @@ -0,0 +1,2 @@ +{"value":"duplicate-package","children":{"ID":1089254,"Issue":"First title","URL":"https://security.example.test/first","Severity":"moderate","Vulnerable Versions":"<2.0.0","Tree Versions":["1.1.0"],"Dependents":["parent-b@npm:2.0.0"]}} +{"value":"duplicate-package","children":{"ID":1089254,"Issue":"Second title","URL":"https://security.example.test/second","Severity":"high","Vulnerable Versions":"<2.0.0","Tree Versions":["1.0.0","1.1.0"],"Dependents":["parent-a@npm:1.0.0"]}} diff --git a/tests/Fixtures/Audit/yarn-populated.ndjson b/tests/Fixtures/Audit/yarn-populated.ndjson new file mode 100644 index 0000000..4282128 --- /dev/null +++ b/tests/Fixtures/Audit/yarn-populated.ndjson @@ -0,0 +1,2 @@ +{"value":"@scope/package","children":{"ID":1089254,"Issue":"Prototype pollution in scoped package","URL":"https://github.com/advisories/GHSA-2222-3333-4444","Severity":"moderate","Vulnerable Versions":"<=1.9.0","Tree Versions":["1.2.6","1.2.5","1.2.6"],"Dependents":["workspace@workspace:.","parent@npm:2.0.3"]}} +{"value":"example-package","children":{"ID":"1107000","Issue":"Informational advisory","Severity":"info","Vulnerable Versions":"<2.0.0","Tree Versions":[],"Dependents":[]}} diff --git a/tests/Support/InternalMockerExtension.php b/tests/Support/InternalMockerExtension.php index 7abe776..d61775f 100644 --- a/tests/Support/InternalMockerExtension.php +++ b/tests/Support/InternalMockerExtension.php @@ -48,6 +48,10 @@ public static function load(): void 'namespace' => 'Foxy\\Asset', 'name' => 'chdir', ], + [ + 'namespace' => 'Foxy\\Asset', + 'name' => 'file_get_contents', + ], [ 'namespace' => 'Foxy\\Json', 'name' => 'file_get_contents', diff --git a/tests/Util/AssetUtilTest.php b/tests/Util/AssetUtilTest.php index ad91442..af1b1e5 100644 --- a/tests/Util/AssetUtilTest.php +++ b/tests/Util/AssetUtilTest.php @@ -251,6 +251,37 @@ public function testGetPathAcceptsManifestWithinFilesystemRoot(): void ); } + /** + * @throws JsonException + */ + public function testGetPathIgnoresNonStringConfiguredDirectory(): void + { + $installPath = $this->cwd . '/invalid-configured-package'; + + $this->sfs->mkdir($installPath); + file_put_contents( + $installPath . '/composer.json', + '{"config":{"foxy":{"root-package-json-dir":[]}}}', + ); + file_put_contents($installPath . '/package.json', '{}'); + + $installationManager = $this->createMock(InstallationManager::class); + $installationManager->expects(self::once())->method('getInstallPath')->willReturn($installPath); + + $assetManager = $this->createMock(AssetManagerInterface::class); + $assetManager->expects(self::once())->method('getPackageName')->willReturn('package.json'); + + $package = $this->createMock(PackageInterface::class); + $package->method('getExtra')->willReturn(['foxy' => true]); + $package->method('getRequires')->willReturn([]); + $package->method('getDevRequires')->willReturn([]); + + self::assertSame( + str_replace('\\', '/', (string) realpath($installPath . '/package.json')), + AssetUtil::getPath($installationManager, $assetManager, $package), + ); + } + /** * @throws JsonException */ diff --git a/tests/Util/ConsoleUtilTest.php b/tests/Util/ConsoleUtilTest.php index fcc75ef..74acd52 100644 --- a/tests/Util/ConsoleUtilTest.php +++ b/tests/Util/ConsoleUtilTest.php @@ -21,6 +21,7 @@ public static function getPreferredInstallOptionsData(): array [false, false, 'auto', false], [false, true, 'auto', [false, true]], [true, false, 'source', false], + [true, false, 'source', [false, null]], [false, true, 'dist', false], [true, false, 'auto', [1, 0]], ]; @@ -47,13 +48,13 @@ public function testGetInputWithoutValidInput(): void public function testGetPreferredInstallOptions( bool $expectedPreferSource, bool $expectedPreferDist, - string $preferedInstall, + string $preferredInstall, mixed $inputPrefer, ): void { $config = $this->createMock(Config::class); $input = $this->createMock(InputInterface::class); - $config->expects(self::once())->method('get')->with('preferred-install')->willReturn($preferedInstall); + $config->expects(self::once())->method('get')->with('preferred-install')->willReturn($preferredInstall); if (is_array($inputPrefer)) { $input->expects(self::atLeastOnce())