mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 23:01:30 +00:00
6993e4484c
Closes Audit-2026-04-25 L-015 (Low) and L-019 (Low) — both
verified-already-clean at HEAD; new CI regression guards prevent
regression. Partial closures for M-009, M-010, M-026 — Bundle 8 ships
the helpers + contract tests + a soft CI budget guard, defers the
long-tail per-page migrations to a new tracker ID M-029.
What changed
- web/src/utils/safeHtml.ts (NEW) — sanitizeHtml() chokepoint for
any future code that genuinely needs dangerouslySetInnerHTML.
Bundle-8 placeholder body throws — DOMPurify dependency is the
activation procedure documented in the file header.
- web/src/components/ExternalLink.tsx (NEW) — single chokepoint for
target="_blank" anchors. Hardcodes rel="noopener noreferrer".
- web/src/hooks/useListParams.ts (NEW) — URL-state hook for filter /
sort / pagination state on list pages. Canonicalises the existing
DashboardPage useSearchParams pattern. Per-page migrations of the
~14 remaining list pages tracked as M-029.
- web/src/hooks/useTrackedMutation.ts (NEW) — useMutation wrapper
enforcing the M-009 invalidation contract via discriminated-union
type: caller MUST declare invalidates: QueryKey[] OR
invalidates: 'noop' + noopReason: string.
- 4 new Vitest test files — full unit coverage for ExternalLink
(target/rel preservation), safeHtml (placeholder throws + activation
hint), useListParams (URL contract / defaults / filter-resets-page),
useTrackedMutation (invalidate-then-onSuccess / noop variant).
- .github/workflows/ci.yml — three new regression guards:
Bundle-8 / L-015: greps for any target="_blank" outside ExternalLink
that lacks rel="noopener noreferrer"; clean at HEAD.
Bundle-8 / L-019: greps for any dangerouslySetInnerHTML outside
safeHtml.ts; clean at HEAD (0 sites).
Bundle-8 / M-009: SOFT budget guard — useMutation sites must not
exceed invalidation sites + 5. At HEAD: 61 mutations vs 82
invalidations + 5 = 87 budget. Stricter per-site enforcement
tracked as M-029.
Verification at HEAD
- web/src/ target=_blank sites: 3 (all in OnboardingWizard.tsx)
— all three already carry rel="noopener noreferrer". L-015 closed.
- web/src/ dangerouslySetInnerHTML sites: 0. L-019 closed.
- useMutation sites: 61 / invalidateQueries: 82 (M-009 budget healthy)
Per-finding mapping
- L-015 closed (CWE-1022) — verified-already-clean + ExternalLink
component + CI grep guard.
- L-019 closed (CWE-79) — verified-already-clean + safeHtml chokepoint
+ CI grep guard.
- M-009 partial — useTrackedMutation wrapper authored; soft CI budget
guard. Migrating the 56 existing useMutation sites to the wrapper
tracked as M-029.
- M-010 partial — useListParams hook authored + tested. Per-page
migration of the ~14 list pages tracked as M-029.
- M-026 partial — bundle-prompt called for XSS-hardening tests on the
T-1 deferred allowlist of 14 pages. Bundle 8 ships the testing
pattern via the new helpers but does NOT execute the per-page
migrations — tracked as M-029.
NOT addressed in this bundle (deferred to M-029)
- Migrating existing 56 useMutation sites to useTrackedMutation
- Migrating ~14 list pages from local useState to useListParams
- Adding XSS-hardening tests to the 14 T-1-deferred pages
Verification
- npx tsc --noEmit → clean
- npx vitest run on the 4 new Bundle-8 test files → 15/15 pass
- L-015 grep guard simulation → clean
- L-019 grep guard simulation → clean
- M-009 budget simulation → 61 ≤ 87 (clean)
- go vet ./... → clean (no backend changes)
- python3 yaml.safe_load(api/openapi.yaml) → clean
- python3 yaml.safe_load(.github/workflows/ci.yml) → clean
Backwards compatibility
- All 4 new helper files are additive; no existing call sites were
modified. Existing list pages keep their useState pagination until
M-029 ships per-page migrations.
Bundle 8 of the 2026-04-25 comprehensive audit. Per-page migration
backlog tracked as new audit finding M-029.
111 lines
4.1 KiB
TypeScript
111 lines
4.1 KiB
TypeScript
// Bundle-8 / Audit M-010:
|
|
//
|
|
// Single hook for filter / sort / pagination state on every list page.
|
|
// Pre-Bundle-8, list pages stored these in local `useState` (see
|
|
// CertificatesPage:417 `const [page, setPage] = useState(1)`), which
|
|
// broke deep-linking and browser-back consistency. The DashboardPage
|
|
// already used `useSearchParams` directly; this hook canonicalises that
|
|
// pattern so the rest of the list pages can migrate mechanically.
|
|
//
|
|
// URL contract:
|
|
//
|
|
// ?page=2&page_size=25&sort=-created_at&filter[status]=active&filter[team_id]=t-platform
|
|
//
|
|
// Defaults are applied client-side — they do NOT appear in the URL when
|
|
// the user hasn't customised them, keeping shareable URLs short.
|
|
//
|
|
// Bundle-8 ships the hook + 1 demonstration migration (CertificatesPage)
|
|
// per the bundle prompt. The remaining list pages (IssuersPage,
|
|
// TargetsPage, AgentsPage, PoliciesPage, ProfilesPage, OwnersPage,
|
|
// TeamsPage, AgentGroupsPage, AuditEventsPage, NotificationsPage,
|
|
// JobsPage, RenewalPoliciesPage, DiscoveryPage) are deferred to a
|
|
// follow-up bundle — tracked as new ID `M-029`.
|
|
|
|
import { useCallback, useMemo } from 'react';
|
|
import { useSearchParams } from 'react-router-dom';
|
|
|
|
export interface ListParams {
|
|
/** Current page (1-indexed). Default: 1. */
|
|
page: number;
|
|
/** Page size. Default: 25. */
|
|
pageSize: number;
|
|
/** Sort key (e.g., `created_at`, `-name` for descending). Default: `''` (no sort). */
|
|
sort: string;
|
|
/** Filter map keyed by filter name (e.g. `{status: 'active', team_id: 't-platform'}`). */
|
|
filters: Record<string, string>;
|
|
}
|
|
|
|
export interface ListParamsControls {
|
|
params: ListParams;
|
|
setPage: (page: number) => void;
|
|
setPageSize: (pageSize: number) => void;
|
|
setSort: (sort: string) => void;
|
|
setFilter: (key: string, value: string | null) => void;
|
|
resetParams: () => void;
|
|
}
|
|
|
|
const DEFAULT_PAGE = 1;
|
|
const DEFAULT_PAGE_SIZE = 25;
|
|
|
|
/**
|
|
* Read filter/sort/pagination state from URL search params, with helpers to
|
|
* update the URL via `setSearchParams({ replace: true })` (preserves
|
|
* browser-back history without flooding it with intermediate states).
|
|
*
|
|
* @param defaults - per-page overrides for the global defaults above
|
|
*/
|
|
export function useListParams(defaults?: Partial<ListParams>): ListParamsControls {
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
|
|
const params = useMemo<ListParams>(() => {
|
|
const page = parsePositiveInt(searchParams.get('page'), defaults?.page ?? DEFAULT_PAGE);
|
|
const pageSize = parsePositiveInt(
|
|
searchParams.get('page_size'),
|
|
defaults?.pageSize ?? DEFAULT_PAGE_SIZE,
|
|
);
|
|
const sort = searchParams.get('sort') ?? defaults?.sort ?? '';
|
|
const filters: Record<string, string> = { ...(defaults?.filters ?? {}) };
|
|
searchParams.forEach((value, key) => {
|
|
const m = /^filter\[(.+)\]$/.exec(key);
|
|
if (m && value) {
|
|
filters[m[1]] = value;
|
|
}
|
|
});
|
|
return { page, pageSize, sort, filters };
|
|
}, [searchParams, defaults]);
|
|
|
|
const updateParam = useCallback(
|
|
(key: string, value: string | null) => {
|
|
const next = new URLSearchParams(searchParams);
|
|
if (value === null || value === '') {
|
|
next.delete(key);
|
|
} else {
|
|
next.set(key, value);
|
|
}
|
|
// Bundle-8: filter / sort changes reset page to 1 (the existing
|
|
// CertificatesPage behaviour we're preserving). Only the page
|
|
// setter is allowed to set page > 1 directly.
|
|
if (key !== 'page') {
|
|
next.delete('page');
|
|
}
|
|
setSearchParams(next, { replace: true });
|
|
},
|
|
[searchParams, setSearchParams],
|
|
);
|
|
|
|
return {
|
|
params,
|
|
setPage: (page) => updateParam('page', page > 1 ? String(page) : null),
|
|
setPageSize: (size) => updateParam('page_size', size !== DEFAULT_PAGE_SIZE ? String(size) : null),
|
|
setSort: (sort) => updateParam('sort', sort || null),
|
|
setFilter: (key, value) => updateParam(`filter[${key}]`, value),
|
|
resetParams: () => setSearchParams(new URLSearchParams(), { replace: true }),
|
|
};
|
|
}
|
|
|
|
function parsePositiveInt(raw: string | null, fallback: number): number {
|
|
if (!raw) return fallback;
|
|
const n = Number(raw);
|
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
|
}
|