Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- [#169](https://github.com/itk-dev/devops_itksites/pull/169)
Mark advisories as not affected per git repo, so they stop counting towards
a site's advisories, and sort sites by their open advisories
- [#168](https://github.com/itk-dev/devops_itksites/pull/168)
Rewrite the fixtures to cover all content with made-up data shaped like
production
Expand Down
10 changes: 10 additions & 0 deletions assets/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ const tomSelectHandler = () => {
window.addEventListener("DOMContentLoaded", tomSelectHandler);
document.addEventListener("ea.collection.item-added", tomSelectHandler);

// Disable the buttons of a [data-submit-once] form once it submits, so a
// slow page load does not invite a second click.
document.addEventListener("submit", (event) => {
if (event.target.matches("[data-submit-once]")) {
event.target
.querySelectorAll("button")
.forEach((button) => (button.disabled = true));
}
});

// Copy the text of a [data-copy-text] button to the clipboard, and show a
// check mark for a moment, like GitHub's copy buttons.
document.addEventListener("click", async (event) => {
Expand Down
10 changes: 10 additions & 0 deletions fixtures/package.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
# - drupal/core has a drupal.org and a Packagist advisory for the same CVE.
# - league/commonmark and drupal/webform have advisories without a CVE.
# - guzzle has no advisories.
# - example-org/intranet has the commonmark advisory marked as not affected.

App\Entity\Package:
package/symfony-http-foundation:
Expand Down Expand Up @@ -106,6 +107,15 @@ App\Entity\Advisory:
- name: "Drupal"
remoteId: "SA-CONTRIB-2099-001"

# Advisories a developer has marked as not affected, per git repo.
App\Entity\AdvisoryAssessment:
advisory_assessment/intranet-commonmark:
advisoryId: "PKSA-m3r8-5tyv-1h6p"
reason: "Markdown is only rendered from editor input"
assessedBy: "user@example.com"
__calls:
- setGitRepo: ["@git_repo/intranet"]

App\Entity\PackageVersion:
package_version/symfony-http-foundation-6.4.10:
package: "@package/symfony-http-foundation"
Expand Down
26 changes: 26 additions & 0 deletions migrations/Version20261021140107.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20261021140107 extends AbstractMigration
{
public function getDescription(): string
{
return 'Store advisories marked as not affected per git repo';
}

public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE advisory_assessment (id BINARY(16) NOT NULL, created_at DATETIME NOT NULL, modified_at DATETIME NOT NULL, created_by VARCHAR(255) DEFAULT \'\' NOT NULL, modified_by VARCHAR(255) DEFAULT \'\' NOT NULL, repo_provider VARCHAR(255) NOT NULL, repo_organization VARCHAR(255) NOT NULL, repo_name VARCHAR(255) NOT NULL, advisory_id VARCHAR(255) NOT NULL, reason LONGTEXT NOT NULL, assessed_by VARCHAR(180) NOT NULL, UNIQUE INDEX repo_advisory (repo_provider, repo_organization, repo_name, advisory_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`');
}

public function down(Schema $schema): void
{
$this->addSql('DROP TABLE advisory_assessment');
}
}
66 changes: 51 additions & 15 deletions src/Admin/AdvisoryCountSort.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
namespace App\Admin;

use App\Entity\Advisory;
use App\Entity\AdvisoryAssessment;
use App\Entity\GitRepo;
use Doctrine\ORM\QueryBuilder;
use EasyCorp\Bundle\EasyAdminBundle\Config\Option\SortOrder;
use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto;
Expand All @@ -15,32 +17,66 @@ final class AdvisoryCountSort

/**
* Order the index by Site::getAdvisoryCount(), which is computed in PHP and
* so has no column to sort on. EasyAdmin drops the sort before it reaches
* the query — isValidCustomSort() only accepts mapped fields and
* associations — so read it off the SearchDto and order by a scalar sub
* query instead. Intended for use from a CRUD controller's
* createIndexQueryBuilder() after the parent has built the QB.
* so has no column to sort on. EasyAdmin drops the sort from the URL before
* it reaches the query — isValidCustomSort() only accepts mapped fields and
* associations — and orders by a non-existent entity.advisoryCount column
* when it is the default sort. So read it off the SearchDto, drop that
* column, and order by a scalar sub query instead. Intended for use from a
* CRUD controller's createIndexQueryBuilder() after the parent has built
* the QB.
*/
public static function apply(QueryBuilder $qb, SearchDto $searchDto): void
{
$rootAlias = (string) current($qb->getRootAliases());
self::removeColumnOrder($qb, $rootAlias);

// The property name is ours, but the direction comes from the URL and
// reaches DQL as a string, so it is whitelisted rather than trusted.
$direction = strtoupper((string) ($searchDto->getCustomSort()[self::PROPERTY] ?? ''));
$direction = strtoupper((string) ($searchDto->getSort()[self::PROPERTY] ?? ''));
if (SortOrder::ASC !== $direction && SortOrder::DESC !== $direction) {
return;
}

$rootAlias = (string) current($qb->getRootAliases());
$qb->addSelect(sprintf('(%s) AS HIDDEN %s', self::subQuery($rootAlias), self::PROPERTY));
$qb->addOrderBy(self::PROPERTY, SortOrder::DESC === $direction ? \SortDirection::Descending : \SortDirection::Ascending);
}

// An advisory belongs to a single package and an installation holds a
// single version of it, so the advisory cannot be counted twice.
$qb->addSelect(sprintf(
'(SELECT COUNT(adv.id) FROM %s adv JOIN adv.packageVersions advPackageVersion JOIN advPackageVersion.installations advInstallation WHERE advInstallation.id = IDENTITY(%s.installation)) AS HIDDEN %s',
/**
* DQL counting the open advisories of the site aliased $siteAlias.
*
* An advisory belongs to a single package and an installation holds a
* single version of it, so the advisory cannot be counted twice.
* Advisories marked as not affected for the installation's git repo do
* not count, as in AdvisoryAssessor::openCount().
*/
public static function subQuery(string $siteAlias): string
{
return sprintf(
'SELECT COUNT(adv.id) FROM %1$s adv JOIN adv.packageVersions advPackageVersion JOIN advPackageVersion.installations advInstallation WHERE advInstallation.id = IDENTITY(%2$s.installation) AND NOT EXISTS (SELECT advAssessment.id FROM %3$s advAssessment JOIN %4$s advRepo WITH advRepo.provider = advAssessment.repoProvider AND advRepo.organization = advAssessment.repoOrganization AND advRepo.repo = advAssessment.repoName JOIN advRepo.gitTags advTag JOIN advTag.installations advTagInstallation WHERE advTagInstallation.id = advInstallation.id AND advAssessment.advisoryId = adv.advisoryId)',
Advisory::class,
$rootAlias,
self::PROPERTY
));
$siteAlias,
AdvisoryAssessment::class,
GitRepo::class,
);
}

$qb->addOrderBy(self::PROPERTY, SortOrder::DESC === $direction ? \SortDirection::Descending : \SortDirection::Ascending);
private static function removeColumnOrder(QueryBuilder $qb, string $rootAlias): void
{
$column = $rootAlias.'.'.self::PROPERTY.' ';
$parts = [];
foreach ($qb->getDQLPart('orderBy') as $orderBy) {
foreach ($orderBy->getParts() as $part) {
if (!str_starts_with($part, $column)) {
$parts[] = $part;
}
}
}

// A part reads "<sort> <direction>", and addOrderBy() wants them apart.
$qb->resetDQLPart('orderBy');
foreach ($parts as $part) {
$at = (int) strrpos($part, ' ');
$qb->addOrderBy(substr($part, 0, $at), substr($part, $at + 1));
}
}
}
83 changes: 83 additions & 0 deletions src/Controller/Admin/AdvisoryAssessmentController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

namespace App\Controller\Admin;

use App\Entity\Site;
use App\Service\AdvisoryAssessor;
use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminRoute;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;

/**
* Marks a site's advisories as not affected, or removes a mark, from the
* advisories table on the site's detail page.
*/
class AdvisoryAssessmentController extends AbstractController
{
public function __construct(
private readonly AdvisoryAssessor $assessor,
) {
}

public static function csrfIntent(Site $site): string
{
return 'site_advisories_'.$site->getId();
}

#[AdminRoute(path: '/site/{id}/advisories/not-affected', name: 'site_advisories_not_affected', options: ['methods' => ['POST']])]
public function markNotAffected(#[MapEntity(id: 'id')] Site $site, Request $request): RedirectResponse
{
$this->denyUnlessTokenIsValid($site, $request);

try {
$marked = $this->assessor->markNotAffected(
$site,
array_values(array_map(strval(...), $request->request->all('advisoryIds'))),
$request->request->getString('reason'),
(string) $this->getUser()?->getUserIdentifier(),
);
if (0 === $marked) {
$this->addFlash('info', 'These advisories are already marked as not affected.');
} else {
$this->addFlash('success', sprintf(
'Marked %d %s as not affected for all checkouts of %s.',
$marked,
1 === $marked ? 'advisory' : 'advisories',
$this->assessor->gitRepo($site),
));
}
} catch (\InvalidArgumentException $exception) {
$this->addFlash('danger', $exception->getMessage());
}

return $this->redirectToSite($site);
}

#[AdminRoute(path: '/site/{id}/advisories/remove-mark', name: 'site_advisories_remove_mark', options: ['methods' => ['POST']])]
public function removeMark(#[MapEntity(id: 'id')] Site $site, Request $request): RedirectResponse
{
$this->denyUnlessTokenIsValid($site, $request);

$advisoryId = $request->request->getString('advisoryId');
$this->assessor->removeMark($site, $advisoryId);
$this->addFlash('success', sprintf('Removed the mark on %s, so it counts again.', $advisoryId));

return $this->redirectToSite($site);
}

private function denyUnlessTokenIsValid(Site $site, Request $request): void
{
if (!$this->isCsrfTokenValid(self::csrfIntent($site), $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
}

private function redirectToSite(Site $site): RedirectResponse
{
return $this->redirectToRoute('admin_site_detail', ['entityId' => $site->getId()]);
}
}
4 changes: 3 additions & 1 deletion src/Controller/Admin/DashboardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Controller\Admin;

use App\Repository\AdvisoryRepository;
use App\Repository\SiteRepository;
use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminDashboard;
use EasyCorp\Bundle\EasyAdminBundle\Config\Assets;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
Expand All @@ -22,6 +23,7 @@ class DashboardController extends AbstractDashboardController
public function __construct(
private readonly AdminUrlGenerator $adminUrlGenerator,
private readonly AdvisoryRepository $advisoryRepository,
private readonly SiteRepository $siteRepository,
) {
}

Expand Down Expand Up @@ -57,7 +59,7 @@ public function configureMenuItems(): iterable
yield MenuItem::linkToDashboard('Dashboard', 'fa fa-home');
yield MenuItem::linkTo(ServerCrudController::class, 'Servers', 'fas fa-server');
yield MenuItem::linkTo(InstallationCrudController::class, 'Installations', 'fas fa-folder');
yield MenuItem::linkTo(SiteCrudController::class, 'Sites', 'fas fa-bookmark');
yield MenuItem::linkTo(SiteCrudController::class, 'Sites', 'fas fa-bookmark')->setBadge($this->siteRepository->countWithOpenAdvisories() ?: null, 'danger');
yield MenuItem::linkTo(DomainCrudController::class, 'Domains', 'fas fa-link');
// OIDC and Service certificates are deprecated. The controllers and
// entities are kept so existing rows stay reachable by URL, but the
Expand Down
18 changes: 14 additions & 4 deletions src/Controller/Admin/SiteCrudController.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use App\Form\Type\Admin\HostingProviderFilter;
use App\Form\Type\Admin\SemverFilter;
use App\Form\Type\Admin\ServerTypeFilter;
use App\Service\AdvisoryAssessor;
use App\Trait\ExportCrudControllerTrait;
use App\Trait\SemverSortableCrudControllerTrait;
use Doctrine\ORM\QueryBuilder;
Expand Down Expand Up @@ -43,8 +44,9 @@ class SiteCrudController extends AbstractCrudController
createIndexQueryBuilder as private semverIndexQueryBuilder;
}

public function __construct()
{
public function __construct(
private readonly AdvisoryAssessor $assessor,
) {
}

public static function getEntityFqcn(): string
Expand All @@ -55,7 +57,9 @@ public static function getEntityFqcn(): string
#[\Override]
public function configureCrud(Crud $crud): Crud
{
return $crud->showEntityActionsInlined();
return $crud
->showEntityActionsInlined()
->setDefaultSort([AdvisoryCountSort::PROPERTY => 'DESC']);
}

#[\Override]
Expand All @@ -71,7 +75,13 @@ public function configureActions(Actions $actions): Actions
public function configureFields(string $pageName): iterable
{
yield DomainField::new('primaryDomain')->setColumns(12);
yield AdvisoryCountField::new('advisoryCount')->setLabel('Adv.')->setSortable(true);
// Advisories marked as not affected do not count. The detail page
// lists them all, with their marks.
yield AdvisoryCountField::new('advisoryCount')->setLabel('Adv.')->setSortable(true)
->setTemplatePath('EasyAdminBundle/Fields/site_advisories.html.twig')
->formatValue(Crud::PAGE_DETAIL === $pageName
? fn (mixed $value, Site $site): array => ['gitRepo' => $this->assessor->gitRepo($site), 'advisories' => $this->assessor->advisories($site)]
: fn (mixed $value, Site $site): int => $this->assessor->openCount($site));
yield AssociationField::new('domains')->hideOnIndex();
yield SiteTypeField::new('type')->setLabel('Stack');
yield ConfigFilePathField::new('configFilePath')->setColumns(12)->hideOnIndex();
Expand Down
Loading
Loading