diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
index a12cfc5..a14c20f 100644
--- a/.github/workflows/run-tests.yml
+++ b/.github/workflows/run-tests.yml
@@ -13,12 +13,12 @@ jobs:
fail-fast: true
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
- php: [8.0, 8.1]
- laravel: [8.*]
+ php: [8.1]
+ laravel: [9.12.*]
stability: [prefer-lowest, prefer-stable]
include:
- - laravel: 8.*
- testbench: ^6.24
+ - laravel: 9.12.*
+ testbench: ^7.0
name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }}
diff --git a/README.md b/README.md
index 1f96b8d..e0b61cf 100644
--- a/README.md
+++ b/README.md
@@ -194,6 +194,30 @@ Which will add the correct `links` tags to your head tag in the HTML document, l
```
+## Dependency Maintenance Commands
+
+Maintaining a healthy dependency list can be tricky. Here's a couple of commands to help you with this task.
+
+### Outdated Dependencies
+
+To keep your dependencies up-to-date, make sure you run the `importmap:outdated` command from time to time:
+
+```bash
+php artisan importmap:outdated
+```
+
+This command will scan your `config/importmap.php` file, find your current versions, then use the NPM registry API to look for the latest version of the packages you're using. It also handles locally served vendor libs that you added using the `--download` flag from the `importmap:pin` command.
+
+### Auditing Dependencies
+
+If you want to a security audit on your dependecies to see if you're using a version that's been breached, run the `importmap:audit` command from time to time. Better yet, add that command to your CI build:
+
+```bash
+php artisan importmap:audit
+```
+
+This will also scan your `config/importmap.php` file, find your current versions, then use the NPM registry API to look for vulnerabilities on your packages. It also handles locally serverd vendor libs that you added using the `--download` flag from the `importmap:pin` command.
+
## Known Problems
### Browser Console Errors
diff --git a/composer.json b/composer.json
index fd77496..9e7c72f 100644
--- a/composer.json
+++ b/composer.json
@@ -16,14 +16,16 @@
}
],
"require": {
- "php": "^8.0|^8.1",
- "spatie/laravel-package-tools": "^1.9.2",
- "illuminate/contracts": "^8.73|^9.0"
+ "php": "^8.1",
+ "illuminate/contracts": "^9.0",
+ "illuminate/support": "^9.0",
+ "spatie/laravel-package-tools": "^1.9.2"
},
"require-dev": {
+ "guzzlehttp/guzzle": "^7.4",
"nunomaduro/collision": "^5.10|^6.0",
"nunomaduro/larastan": "^1.0",
- "orchestra/testbench": "^6.24",
+ "orchestra/testbench": "^7.0",
"pestphp/pest": "^1.21",
"pestphp/pest-plugin-laravel": "^1.2",
"phpstan/extension-installer": "^1.1",
@@ -45,7 +47,7 @@
"scripts": {
"analyse": "vendor/bin/phpstan analyse",
"test": "vendor/bin/pest",
- "test-coverage": "vendor/bin/pest coverage"
+ "test-coverage": "vendor/bin/pest --coverage"
},
"config": {
"sort-packages": true,
diff --git a/resources/views/tags.blade.php b/resources/views/tags.blade.php
index 4dce2f1..b662d41 100644
--- a/resources/views/tags.blade.php
+++ b/resources/views/tags.blade.php
@@ -8,7 +8,7 @@
@if (config('importmap.use_shim'))
@if ($nonce) @endif
-
+
@endif
diff --git a/src/Commands/AuditCommand.php b/src/Commands/AuditCommand.php
new file mode 100644
index 0000000..44f928f
--- /dev/null
+++ b/src/Commands/AuditCommand.php
@@ -0,0 +1,51 @@
+vulnerablePackages();
+
+ if ($vulnerablePackages->isEmpty()) {
+ $this->info("No vulnerable packages found.");
+
+ return self::SUCCESS;
+ }
+
+ $this->table(
+ ['Package', 'Severity', 'Vulnerable Versions', 'Vulnerability'],
+ $vulnerablePackages
+ ->map(fn (VulnerablePackage $package) => [$package->name, $package->severity, $package->vulnerableVersions, $package->vulnerability])
+ ->all()
+ );
+
+ $this->newLine();
+
+ $summary = $vulnerablePackages
+ ->groupBy('severity')
+ ->map(fn ($vulns) => $vulns->count())
+ ->sortDesc()
+ ->map(fn ($count, $severity) => "$count {$severity}")
+ ->join(", ");
+
+ $this->error(sprintf(
+ "%d %s found: %s",
+ $vulnerablePackages->count(),
+ Str::plural('vulnerability', $vulnerablePackages->count()),
+ $summary,
+ ));
+
+ return self::FAILURE;
+ }
+}
diff --git a/src/Commands/OutdatedCommand.php b/src/Commands/OutdatedCommand.php
new file mode 100644
index 0000000..5e0dc3d
--- /dev/null
+++ b/src/Commands/OutdatedCommand.php
@@ -0,0 +1,43 @@
+outdatedPackages();
+
+ if ($outdatedPackages->isEmpty()) {
+ $this->info("No outdated packages found.");
+
+ return self::SUCCESS;
+ }
+
+ $this->table(
+ ['Package', 'Current', 'Latest'],
+ $outdatedPackages
+ ->map(fn (OutdatedPackage $package) => [$package->name, $package->currentVersion, $package->latestVersion ?: $package->error])
+ ->all(),
+ );
+
+ $this->newLine();
+
+ $this->error(sprintf(
+ '%d outdated %s found.',
+ $outdatedPackages->count(),
+ Str::plural('package', $outdatedPackages->count()),
+ ));
+
+ return self::FAILURE;
+ }
+}
diff --git a/src/ImportmapLaravelServiceProvider.php b/src/ImportmapLaravelServiceProvider.php
index dacad4e..0fd25f3 100644
--- a/src/ImportmapLaravelServiceProvider.php
+++ b/src/ImportmapLaravelServiceProvider.php
@@ -26,6 +26,8 @@ public function configurePackage(Package $package): void
->hasCommand(Commands\JsonCommand::class)
->hasCommand(Commands\PinCommand::class)
->hasCommand(Commands\UnpinCommand::class)
+ ->hasCommand(Commands\OutdatedCommand::class)
+ ->hasCommand(Commands\AuditCommand::class)
;
}
diff --git a/src/Npm.php b/src/Npm.php
new file mode 100644
index 0000000..7e680de
--- /dev/null
+++ b/src/Npm.php
@@ -0,0 +1,156 @@
+configPath ??= base_path("routes/importmap.php");
+ }
+
+ public function outdatedPackages(): Collection
+ {
+ return $this->packagesWithVersion()
+ ->reduce(function (Collection $outdatedPackages, PackageVersion $package) {
+ $latestVersion = null;
+ $error = null;
+
+ if (! ($response = $this->getPackage($package))) {
+ $error = "Response error";
+ } elseif ($response["error"] ?? false) {
+ $error = $response["error"];
+ } else {
+ $latestVersion = $this->findLatestVersion($response);
+
+ if (! $this->outdated($package->version, $latestVersion)) {
+ return $outdatedPackages;
+ }
+ }
+
+ return $outdatedPackages->add(new OutdatedPackage(
+ name: $package->name,
+ currentVersion: $package->version,
+ latestVersion: $latestVersion,
+ error: $error,
+ ));
+ }, collect());
+ }
+
+ public function vulnerablePackages(): Collection
+ {
+ $data = $this->packagesWithVersion()
+ ->mapWithKeys(fn (PackageVersion $package) => [
+ $package->name => [$package->version],
+ ])
+ ->all();
+
+ return $this->getAudit($data)
+ ->collect()
+ ->flatMap(function (array $vulnerabilities, string $package) {
+ return collect($vulnerabilities)
+ ->map(fn (array $vulnerability) => new VulnerablePackage(
+ name: $package,
+ severity: $vulnerability['severity'],
+ vulnerableVersions: $vulnerability['vulnerable_versions'],
+ vulnerability: $vulnerability['title'],
+ ));
+ })
+ ->sortBy([
+ ['name', 'asc'],
+ ['severity', 'asc'],
+ ])
+ ->values();
+ }
+
+ private function packagesWithVersion(): Collection
+ {
+ $content = File::get($this->configPath);
+
+ return $this->findPackagesFromCdnMatches($content)
+ ->merge($this->findPackagesFromLocalMatches($content))
+ ->unique('name')
+ ->values();
+ }
+
+ private function findPackagesFromCdnMatches(string $content)
+ {
+ preg_match_all('/^Importmap\:\:pin\(.*(?<=npm:|npm\/|skypack\.dev\/|unpkg\.com\/)(.*)(?=@\d+\.\d+\.\d+)@(\d+\.\d+\.\d+(?:[^\/\s"]*)).*\)\;\r?$/m', $content, $matches);
+
+ if (count($matches) !== 3) {
+ return collect();
+ }
+
+ return collect($matches[1])
+ ->zip($matches[2])
+ ->map(fn ($items) => new PackageVersion(name: $items[0], version: $items[1]))
+ ->values();
+ }
+
+ private function findPackagesFromLocalMatches(string $content)
+ {
+ preg_match_all('/^Importmap::pin\("([^"]*)".*\)\; \/\/.*@(\d+\.\d+\.\d+(?:[^\s]*)).*\r?$/m', $content, $matches);
+
+ if (count($matches) !== 3) {
+ return collect();
+ }
+
+ return collect($matches[1])
+ ->zip($matches[2])
+ ->map(fn ($items) => new PackageVersion(name: $items[0], version: $items[1]))
+ ->values();
+ }
+
+ private function getPackage(PackageVersion $package)
+ {
+ $response = Http::get($this->baseUrl . "/" . $package->name);
+
+ if (! $response->ok()) {
+ return null;
+ }
+
+ return $response->json();
+ }
+
+ private function findLatestVersion(array $json)
+ {
+ $latestVersion = data_get($json, "dist-tags.latest");
+
+ if ($latestVersion) {
+ return $latestVersion;
+ }
+
+ if (! isset($json["versions"])) {
+ return;
+ }
+
+ return collect($json["versions"])
+ ->keys()
+ ->sort(fn ($versionA, $versionB) => version_compare($versionB, $versionA))
+ ->values()
+ ->first();
+ }
+
+ private function outdated(string $currentVersion, string $latestVersion)
+ {
+ return version_compare($currentVersion, $latestVersion) === -1;
+ }
+
+ private function getAudit(array $packages)
+ {
+ $response = Http::asJson()
+ ->post($this->baseUrl . "/-/npm/v1/security/advisories/bulk", $packages);
+
+ if (! $response->ok()) {
+ return collect();
+ }
+
+ return $response->collect();
+ }
+}
diff --git a/src/OutdatedPackage.php b/src/OutdatedPackage.php
new file mode 100644
index 0000000..2a42d7a
--- /dev/null
+++ b/src/OutdatedPackage.php
@@ -0,0 +1,15 @@
+npm = new Npm(configPath: __DIR__ . DIRECTORY_SEPARATOR . join(DIRECTORY_SEPARATOR, ["fixtures", "npm", "audit-importmap.php"]));
+
+ Http::preventStrayRequests();
+});
+
+it("finds no audit vulnerabilities", function () {
+ Http::fake(fn () => Http::response([]));
+
+ expect($this->npm->vulnerablePackages())->toHaveCount(0);
+
+ Http::assertSent(fn (Request $request) => (
+ $request->data() == [
+ "is-svg" => ["3.0.0"],
+ "lodash" => ["4.17.12"],
+ ]
+ ));
+});
+
+it("finds audit vulnerabilities", function () {
+ Http::fake(fn () => Http::response([
+ "is-svg" => [
+ [
+ "title" => "Regular Expression Denial of Service (ReDoS)",
+ "severity" => "high",
+ "vulnerable_versions" => ">=2.1.0 <4.2.2",
+ ],
+ [
+ "title" => "ReDOS in IS-SVG",
+ "severity" => "high",
+ "vulnerable_versions" => ">=2.1.0 <4.3.0",
+ ],
+ ],
+ ]));
+
+ expect($vulnerabilities = $this->npm->vulnerablePackages())->toHaveCount(2);
+
+ expect($vulnerabilities->first()->name)->toEqual("is-svg");
+ expect($vulnerabilities->first()->vulnerability)->toEqual("Regular Expression Denial of Service (ReDoS)");
+ expect($vulnerabilities->first()->severity)->toEqual("high");
+ expect($vulnerabilities->first()->vulnerableVersions)->toEqual(">=2.1.0 <4.2.2");
+
+ expect($vulnerabilities->last()->name)->toEqual("is-svg");
+ expect($vulnerabilities->last()->vulnerability)->toEqual("ReDOS in IS-SVG");
+ expect($vulnerabilities->last()->severity)->toEqual("high");
+ expect($vulnerabilities->last()->vulnerableVersions)->toEqual(">=2.1.0 <4.3.0");
+
+ Http::assertSent(fn (Request $request) => (
+ $request->data() == [
+ "is-svg" => ["3.0.0"],
+ "lodash" => ["4.17.12"],
+ ]
+ ));
+});
diff --git a/tests/NpmOutdatedTest.php b/tests/NpmOutdatedTest.php
new file mode 100644
index 0000000..88fe727
--- /dev/null
+++ b/tests/NpmOutdatedTest.php
@@ -0,0 +1,75 @@
+npm = new Npm(configPath: __DIR__ . DIRECTORY_SEPARATOR . join(DIRECTORY_SEPARATOR, ["fixtures", "npm", "outdated-importmap.php"]));
+
+ Http::preventStrayRequests();
+});
+
+it("finds no outdated packages", function () {
+ Http::fakeSequence()
+ ->push(["dist-tags" => ["latest" => "3.0.0"]])
+ ->push(["dist-tags" => ["latest" => "4.0.0"]]);
+
+ expect($this->npm->outdatedPackages()->count())->toEqual(0);
+});
+
+it("handles error when fails to fetch latest version of package", function () {
+ Http::fake([
+ 'https://registry.npmjs.org/is-svg' => Http::response([], 404),
+ 'https://registry.npmjs.org/lodash' => Http::response(["dist-tags" => ["latest" => "4.0.0"]]),
+ ]);
+
+ expect($packages = $this->npm->outdatedPackages())->toHaveCount(1);
+ expect($packages->first()->name)->toEqual("is-svg");
+ expect($packages->first()->currentVersion)->toEqual("3.0.0");
+ expect($packages->first()->latestVersion)->toBeNull();
+ expect($packages->first()->error)->toEqual("Response error");
+});
+
+it("handles error when returns ok but response json contains error", function () {
+ Http::fake([
+ 'https://registry.npmjs.org/is-svg' => Http::response(["error" => "Something went wrong"]),
+ 'https://registry.npmjs.org/lodash' => Http::response(["dist-tags" => ["latest" => "4.0.0"]]),
+ ]);
+
+ expect($packages = $this->npm->outdatedPackages())->toHaveCount(1);
+ expect($packages->first()->name)->toEqual("is-svg");
+ expect($packages->first()->currentVersion)->toEqual("3.0.0");
+ expect($packages->first()->latestVersion)->toBeNull();
+ expect($packages->first()->error)->toEqual("Something went wrong");
+});
+
+it("finds outdated packages", function () {
+ Http::fake([
+ 'https://registry.npmjs.org/is-svg' => Http::response(["dist-tags" => ["latest" => "4.0.0"]]),
+ 'https://registry.npmjs.org/lodash' => Http::response([
+ "versions" => [
+ "2.0.0" => [],
+ "5.0.0" => [],
+ "1.2.0" => [],
+ "1.7.0" => [],
+ ],
+ ]),
+ ]);
+
+ expect($packages = $this->npm->outdatedPackages())->toHaveCount(2);
+
+ $svgPackage = $packages->firstWhere('name', 'is-svg');
+
+ expect($svgPackage->name)->toEqual("is-svg");
+ expect($svgPackage->currentVersion)->toEqual("3.0.0");
+ expect($svgPackage->latestVersion)->toEqual("4.0.0");
+ expect($svgPackage->error)->toBeNull();
+
+ $lodashPackage = $packages->firstWhere('name', 'lodash');
+
+ expect($lodashPackage->name)->toEqual("lodash");
+ expect($lodashPackage->currentVersion)->toEqual("4.0.0");
+ expect($lodashPackage->latestVersion)->toEqual("5.0.0");
+ expect($lodashPackage->error)->toBeNull();
+});
diff --git a/tests/TagsComponentTest.php b/tests/TagsComponentTest.php
index bf039fa..bf998b9 100644
--- a/tests/TagsComponentTest.php
+++ b/tests/TagsComponentTest.php
@@ -21,12 +21,12 @@
$this->blade('')
->assertSee('', escape: false)
->assertDontSee('', escape: false);
+ ->assertSee('', escape: false);
});
it('uses given CSP nonce', function () {
$this->blade('')
->assertSee('', escape: false)
->assertSee('', escape: false)
- ->assertSee('', escape: false);
+ ->assertSee('', escape: false);
});
diff --git a/tests/fixtures/npm/audit-importmap.php b/tests/fixtures/npm/audit-importmap.php
new file mode 100644
index 0000000..8748684
--- /dev/null
+++ b/tests/fixtures/npm/audit-importmap.php
@@ -0,0 +1,6 @@
+