1.0.37
This commit is contained in:
101 files changed
+5097
-642
No files matched your search
@@ -82,3 +82,75 @@ export function formatTimePartWith(
|
||||
: `${hour24}:${minutes}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Offset of a timezone from UTC (in ms) at a given instant.
|
||||
* Positive for zones ahead of UTC (e.g. UTC+2 -> 7_200_000).
|
||||
*/
|
||||
function tzOffsetMs(timeZone: string, at: Date): number {
|
||||
const dtf = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
hourCycle: 'h23',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
const parts = dtf.formatToParts(at);
|
||||
const get = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
parseInt(parts.find((p) => p.type === type)?.value ?? '0', 10);
|
||||
const asUtc = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second'));
|
||||
// Drop sub-second precision of `at` - formatToParts only has seconds
|
||||
return asUtc - Math.floor(at.getTime() / 1000) * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current calendar date in a timezone as 'YYYY-MM-DD'.
|
||||
* Falsy timeZone means the browser's local timezone.
|
||||
*/
|
||||
export function currentDateInTimezone(timeZone?: string): string {
|
||||
const now = new Date();
|
||||
if (!timeZone) {
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(now.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
}).formatToParts(now);
|
||||
const get = (type: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === type)?.value ?? '';
|
||||
return `${get('year')}-${get('month')}-${get('day')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* UTC instant (ISO string) of a day boundary in a timezone: midnight for the
|
||||
* start of the day, 23:59:59.999 for the end. Used to turn date-only filter
|
||||
* values ('YYYY-MM-DD', meaning a day in the user's configured timezone) into
|
||||
* timestamps comparable against UTC-stored event times (#1269).
|
||||
* Falsy timeZone means the browser's local timezone.
|
||||
*/
|
||||
export function dayBoundaryToUtcISO(dateStr: string, timeZone: string | undefined, endOfDay: boolean): string {
|
||||
const [y, m, d] = dateStr.split('-').map(Number);
|
||||
const h = endOfDay ? 23 : 0;
|
||||
const min = endOfDay ? 59 : 0;
|
||||
const s = endOfDay ? 59 : 0;
|
||||
const ms = endOfDay ? 999 : 0;
|
||||
|
||||
if (!timeZone) {
|
||||
return new Date(y, m - 1, d, h, min, s, ms).toISOString();
|
||||
}
|
||||
|
||||
const wallClockUtc = Date.UTC(y, m - 1, d, h, min, s, ms);
|
||||
// Two passes so DST transitions near the boundary converge on the right offset
|
||||
let ts = wallClockUtc;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
ts = wallClockUtc - tzOffsetMs(timeZone, new Date(ts));
|
||||
}
|
||||
return new Date(ts).toISOString();
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Syntax-highlight a Dockerfile/shell command for display via {@html}.
|
||||
*
|
||||
* The input (e.g. an image layer's CreatedBy) is attacker-controlled in a
|
||||
* crafted image, so it MUST be HTML-escaped before any markup is inserted —
|
||||
* otherwise the {@html} render is an XSS sink.
|
||||
*/
|
||||
export function highlightCommand(cmd: string): string {
|
||||
if (!cmd) return '';
|
||||
|
||||
// Dockerfile instructions
|
||||
const dockerInstructions = ['ADD', 'COPY', 'ENV', 'ARG', 'WORKDIR', 'RUN', 'CMD', 'ENTRYPOINT', 'EXPOSE', 'VOLUME', 'USER', 'LABEL', 'HEALTHCHECK', 'SHELL', 'ONBUILD', 'STOPSIGNAL', 'MAINTAINER', 'FROM'];
|
||||
const dockerInstructionPattern = new RegExp(`\\b(${dockerInstructions.join('|')})\\b`, 'g');
|
||||
|
||||
// Shell keywords
|
||||
const shellKeywords = ['if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'do', 'done', 'case', 'esac', 'function', 'in', 'select'];
|
||||
const shellKeywordPattern = new RegExp(`\\b(${shellKeywords.join('|')})\\b`, 'g');
|
||||
|
||||
// Common commands
|
||||
const commands = ['apt-get', 'apk', 'yum', 'pip', 'npm', 'yarn', 'git', 'curl', 'wget', 'mkdir', 'cd', 'cp', 'mv', 'rm', 'chmod', 'chown', 'echo', 'cat', 'grep', 'sed', 'awk', 'tar', 'unzip', 'make', 'gcc', 'python', 'node', 'sh', 'bash'];
|
||||
const commandPattern = new RegExp(`\\b(${commands.join('|')})\\b`, 'g');
|
||||
|
||||
// Escape HTML before inserting any markup. The input is attacker-controlled
|
||||
// (image build history) and rendered via {@html}, so unescaped < > & would be
|
||||
// an XSS sink.
|
||||
const highlighted = cmd
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// Strings in quotes
|
||||
.replace(/("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g, '<span class="text-green-600 dark:text-green-400">$1</span>')
|
||||
// Comments
|
||||
.replace(/(#[^\n]*)/g, '<span class="text-gray-500 dark:text-gray-400 italic">$1</span>')
|
||||
// Dockerfile instructions (must be before shell keywords to take precedence)
|
||||
.replace(dockerInstructionPattern, '<span class="text-blue-600 dark:text-blue-400 font-semibold">$1</span>')
|
||||
// Shell keywords
|
||||
.replace(shellKeywordPattern, '<span class="text-purple-600 dark:text-purple-400">$1</span>')
|
||||
// Commands
|
||||
.replace(commandPattern, '<span class="text-cyan-600 dark:text-cyan-400">$1</span>')
|
||||
// Flags (words starting with - or --)
|
||||
.replace(/(\s)(--?[a-zA-Z0-9-]+)/g, '$1<span class="text-yellow-600 dark:text-yellow-400">$2</span>');
|
||||
|
||||
return highlighted;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Build a schema-valid SARIF 2.1.0 document from Dockhand vulnerability findings,
|
||||
* for ingestion by DefectDojo / GitHub code scanning / Dependency-Track (#415).
|
||||
*
|
||||
* SARIF 2.1.0 is a frozen OASIS standard, so we hand-build the object literal
|
||||
* against the @types/sarif types rather than take a runtime dependency.
|
||||
*/
|
||||
import type { Log, Run, Result, ReportingDescriptor } from 'sarif';
|
||||
import type { Finding } from '$lib/utils/vulnerability';
|
||||
|
||||
const SARIF_SCHEMA = 'https://json.schemastore.org/sarif-2.1.0.json';
|
||||
|
||||
/** Map a Dockhand severity to a SARIF result level. */
|
||||
function sarifLevel(severity: string): Result.level {
|
||||
switch (severity.toLowerCase()) {
|
||||
case 'critical':
|
||||
case 'high':
|
||||
return 'error';
|
||||
case 'medium':
|
||||
return 'warning';
|
||||
case 'low':
|
||||
case 'negligible':
|
||||
return 'note';
|
||||
default:
|
||||
return 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A repo-relative artifact URI for a package inside an image.
|
||||
*
|
||||
* SARIF's artifactLocation.uri is a uri-reference; consumers (GitHub code
|
||||
* scanning especially) resolve it against a base and drop results whose URI is
|
||||
* an *absolute* URI. An image ref like `nginx:latest` would make `nginx:` parse
|
||||
* as a URI scheme, so we prefix a constant `images/` segment (guaranteeing the
|
||||
* first path segment has no colon) and percent-encode each segment.
|
||||
*/
|
||||
function artifactUri(imageName: string, pkg: string): string {
|
||||
return `images/${encodeURIComponent(imageName)}/${encodeURIComponent(pkg)}`;
|
||||
}
|
||||
|
||||
/** GitHub/DefectDojo read `security-severity` (0-10) from rule properties. */
|
||||
function securitySeverityScore(severity: string): string {
|
||||
switch (severity.toLowerCase()) {
|
||||
case 'critical': return '9.5';
|
||||
case 'high': return '8.0';
|
||||
case 'medium': return '5.5';
|
||||
case 'low': return '3.0';
|
||||
default: return '0.0';
|
||||
}
|
||||
}
|
||||
|
||||
export interface SarifOptions {
|
||||
toolName?: string;
|
||||
toolVersion?: string;
|
||||
informationUri?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert findings into a SARIF log with a single run. Each unique CVE becomes a
|
||||
* reporting descriptor (rule); each finding becomes a result located at the
|
||||
* affected package within its image.
|
||||
*/
|
||||
export function findingsToSarif(findings: Finding[], opts: SarifOptions = {}): Log {
|
||||
const toolName = opts.toolName ?? 'Dockhand';
|
||||
// __APP_VERSION__ is a Vite define (git tag / VERSION file); guard for non-Vite
|
||||
// contexts such as `bun test`.
|
||||
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : null;
|
||||
const toolVersion = opts.toolVersion ?? appVersion ?? 'unknown';
|
||||
const informationUri = opts.informationUri ?? 'https://github.com/Finsys/dockhand';
|
||||
|
||||
// One rule per distinct CVE — dedupe, keeping the most detailed description/link.
|
||||
const ruleMap = new Map<string, ReportingDescriptor>();
|
||||
for (const f of findings) {
|
||||
if (ruleMap.has(f.cve)) continue;
|
||||
ruleMap.set(f.cve, {
|
||||
id: f.cve,
|
||||
name: f.cve,
|
||||
shortDescription: { text: `${f.cve} in ${f.package}` },
|
||||
fullDescription: { text: f.description || `${f.cve} affecting ${f.package}` },
|
||||
helpUri: f.link || `https://nvd.nist.gov/vuln/detail/${f.cve}`,
|
||||
defaultConfiguration: { level: sarifLevel(f.severity) },
|
||||
properties: {
|
||||
// GitHub code scanning / DefectDojo read the hyphenated key off the rule.
|
||||
'security-severity': securitySeverityScore(f.severity),
|
||||
tags: ['vulnerability', 'security', f.severity.toLowerCase()]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const results: Result[] = findings.map((f) => {
|
||||
const fixText = f.fixedVersion ? `Fixed in ${f.fixedVersion}.` : 'No fix available.';
|
||||
return {
|
||||
ruleId: f.cve,
|
||||
level: sarifLevel(f.severity),
|
||||
message: {
|
||||
text: `${f.severity.toUpperCase()} ${f.cve} in ${f.package} ${f.installedVersion} (image ${f.imageName}). ${fixText}`
|
||||
},
|
||||
locations: [
|
||||
{
|
||||
physicalLocation: {
|
||||
artifactLocation: {
|
||||
// Repo-relative URI identifying the vulnerable package inside the
|
||||
// image (see artifactUri: image refs contain colons, which must not
|
||||
// be read as a URI scheme).
|
||||
uri: artifactUri(f.imageName, f.package)
|
||||
}
|
||||
},
|
||||
logicalLocations: [
|
||||
{ name: f.package, kind: 'package', fullyQualifiedName: `${f.imageName}/${f.package}@${f.installedVersion}` }
|
||||
]
|
||||
}
|
||||
],
|
||||
partialFingerprints: {
|
||||
// Custom stable fingerprint so re-imports dedupe: image|cve|package|version.
|
||||
// (Not primaryLocationLineHash — that reserved key has hash semantics.)
|
||||
dockhandFinding: `${f.imageId}|${f.cve}|${f.package}|${f.installedVersion}`
|
||||
},
|
||||
properties: {
|
||||
imageName: f.imageName,
|
||||
imageId: f.imageId,
|
||||
package: f.package,
|
||||
installedVersion: f.installedVersion,
|
||||
fixedVersion: f.fixedVersion || null,
|
||||
severity: f.severity,
|
||||
scannedAt: f.scannedAt || null,
|
||||
containers: (f.containers ?? []).map((c) => c.name),
|
||||
stacks: f.stacks ?? []
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const run: Run = {
|
||||
tool: {
|
||||
driver: {
|
||||
name: toolName,
|
||||
version: toolVersion,
|
||||
informationUri,
|
||||
rules: Array.from(ruleMap.values())
|
||||
}
|
||||
},
|
||||
results
|
||||
};
|
||||
|
||||
return {
|
||||
version: '2.1.0',
|
||||
$schema: SARIF_SCHEMA,
|
||||
runs: [run]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Render a third-party template description (from a Portainer catalog) for
|
||||
* display via {@html}.
|
||||
*
|
||||
* The input is untrusted, so: HTML tags are stripped, and markdown links are
|
||||
* rebuilt only for safe http(s)/relative URLs — javascript:/data:/etc. are
|
||||
* dropped (keeping just the link text) to prevent a `[x](javascript:...)` XSS.
|
||||
*/
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// Only http(s) and relative URLs are safe to put in href; reject javascript:,
|
||||
// data:, etc.
|
||||
export function isSafeLinkUrl(url: string): boolean {
|
||||
// Browsers ignore ASCII whitespace/control chars when parsing the scheme, so
|
||||
// strip them before checking (defeats java\tscript: style bypasses).
|
||||
const cleaned = url.replace(/[\x00-\x20]/g, '').toLowerCase();
|
||||
if (cleaned.startsWith('http://') || cleaned.startsWith('https://')) return true;
|
||||
// No scheme (relative path/anchor) is safe; any other scheme is not.
|
||||
return !/^[a-z][a-z0-9+.-]*:/.test(cleaned);
|
||||
}
|
||||
|
||||
// Convert markdown links [text](url) to HTML <a> tags, strip other HTML.
|
||||
export function renderDescription(text: string): string {
|
||||
return text
|
||||
.replace(/<a\s+href="([^"]+)"[^>]*>([^<]+)<\/a>/gi, '[$2]($1)') // normalize HTML links to markdown first
|
||||
.replace(/<[^>]+>/g, '') // strip remaining HTML tags
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label: string, url: string) => {
|
||||
const safeText = escapeHtml(label);
|
||||
if (!isSafeLinkUrl(url)) return safeText; // drop unsafe scheme, keep the text
|
||||
return `<a href="${escapeHtml(url)}" target="_blank" rel="noopener" class="text-primary hover:underline">${safeText}</a>`;
|
||||
})
|
||||
.trim();
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Shared vulnerability/severity helpers used by the per-image scan view and
|
||||
* the aggregated Vulnerabilities dashboard.
|
||||
*/
|
||||
|
||||
export interface Vulnerability {
|
||||
id: string;
|
||||
severity: string;
|
||||
package: string;
|
||||
version: string;
|
||||
fixedVersion?: string;
|
||||
description?: string;
|
||||
link?: string;
|
||||
}
|
||||
|
||||
/** Sort order: lower = more severe. Unknown/unmapped sort last. */
|
||||
export const SEVERITY_ORDER: Record<string, number> = {
|
||||
critical: 0,
|
||||
high: 1,
|
||||
medium: 2,
|
||||
low: 3,
|
||||
negligible: 4,
|
||||
unknown: 5
|
||||
};
|
||||
|
||||
export function severityRank(severity: string): number {
|
||||
return SEVERITY_ORDER[severity.toLowerCase()] ?? SEVERITY_ORDER.unknown;
|
||||
}
|
||||
|
||||
/** Tailwind classes for a severity Badge (bg + text + border). */
|
||||
export function getSeverityColor(severity: string): string {
|
||||
switch (severity.toLowerCase()) {
|
||||
case 'critical':
|
||||
return 'bg-red-500/10 text-red-500 border-red-500/30';
|
||||
case 'high':
|
||||
return 'bg-orange-500/10 text-orange-500 border-orange-500/30';
|
||||
case 'medium':
|
||||
return 'bg-yellow-500/10 text-yellow-600 border-yellow-500/30';
|
||||
case 'low':
|
||||
return 'bg-blue-500/10 text-blue-500 border-blue-500/30';
|
||||
case 'negligible':
|
||||
case 'unknown':
|
||||
default:
|
||||
return 'bg-gray-500/10 text-gray-500 border-gray-500/30';
|
||||
}
|
||||
}
|
||||
|
||||
/** The four severities shown as summary pills, most severe first. */
|
||||
export const SUMMARY_SEVERITIES = ['critical', 'high', 'medium', 'low'] as const;
|
||||
export type SummarySeverity = (typeof SUMMARY_SEVERITIES)[number];
|
||||
|
||||
/** Title-case a severity label (e.g. "critical" -> "Critical"). */
|
||||
export function severityLabel(severity: string): string {
|
||||
return severity.charAt(0).toUpperCase() + severity.slice(1);
|
||||
}
|
||||
|
||||
/** A container reference attached to a finding. */
|
||||
export interface FindingContainer {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** A single vulnerability finding as returned by /api/vulnerabilities. */
|
||||
export interface Finding {
|
||||
key: string;
|
||||
cve: string;
|
||||
package: string;
|
||||
severity: string;
|
||||
installedVersion: string;
|
||||
fixedVersion: string;
|
||||
imageId: string;
|
||||
imageName: string;
|
||||
description?: string;
|
||||
link?: string;
|
||||
scannedAt?: string;
|
||||
containers?: FindingContainer[];
|
||||
stacks?: string[];
|
||||
}
|
||||
|
||||
/** Aggregate counts for the dashboard summary row / header badge. */
|
||||
export interface VulnerabilitySummary {
|
||||
total: number;
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
imagesScanned: number;
|
||||
totalImages: number;
|
||||
}
|
||||
|
||||
/** A zeroed summary — shared so the 7-field shape isn't re-typed per endpoint. */
|
||||
export const EMPTY_SUMMARY: VulnerabilitySummary = {
|
||||
total: 0, critical: 0, high: 0, medium: 0, low: 0, imagesScanned: 0, totalImages: 0
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimal finding shape needed for filtering. Both the dashboard grid (client)
|
||||
* and the export endpoint (server) filter against this, so the two can't drift.
|
||||
*/
|
||||
export interface FilterableFinding {
|
||||
cve: string;
|
||||
package: string;
|
||||
severity: string;
|
||||
imageName: string;
|
||||
containers?: { name: string }[];
|
||||
stacks?: string[];
|
||||
}
|
||||
|
||||
export interface FindingFilter {
|
||||
/** Free-text query matched against cve/package/image/container/stack names. */
|
||||
q?: string;
|
||||
/** Lowercase severity values; empty = no severity filter. */
|
||||
severities?: string[];
|
||||
images?: string[];
|
||||
containers?: string[];
|
||||
stacks?: string[];
|
||||
}
|
||||
|
||||
/** A persisted scan row shape (subset used for flattening into findings). */
|
||||
export interface ScanRow {
|
||||
imageId: string;
|
||||
imageName: string;
|
||||
scannedAt: string;
|
||||
vulnerabilities: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten persisted scan rows into deduped per-CVE findings. Shared by the
|
||||
* env-wide aggregation and the per-image export so both produce the same shape.
|
||||
* `enrich` optionally attaches containers/stacks by imageId.
|
||||
*/
|
||||
export function flattenScansToFindings(
|
||||
scans: ScanRow[],
|
||||
enrich?: {
|
||||
containersByImage?: Map<string, FindingContainer[]>;
|
||||
stacksByImage?: Map<string, Set<string>>;
|
||||
}
|
||||
): Finding[] {
|
||||
const seen = new Set<string>();
|
||||
const findings: Finding[] = [];
|
||||
for (const scan of scans) {
|
||||
const containers = enrich?.containersByImage?.get(scan.imageId);
|
||||
const stacks = enrich?.stacksByImage?.get(scan.imageId);
|
||||
|
||||
// Legacy rows were double-JSON-encoded; parse once more if still a string.
|
||||
let vulns: any[] = scan.vulnerabilities as any;
|
||||
if (typeof vulns === 'string') {
|
||||
try { vulns = JSON.parse(vulns); } catch { vulns = []; }
|
||||
}
|
||||
if (!Array.isArray(vulns)) vulns = [];
|
||||
for (const v of vulns) {
|
||||
const key = `${scan.imageId}|${v.id}|${v.package}|${v.version}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
findings.push({
|
||||
key,
|
||||
cve: v.id,
|
||||
package: v.package,
|
||||
severity: v.severity,
|
||||
installedVersion: v.version,
|
||||
fixedVersion: v.fixedVersion || '',
|
||||
imageId: scan.imageId,
|
||||
imageName: scan.imageName,
|
||||
description: v.description,
|
||||
link: v.link,
|
||||
scannedAt: scan.scannedAt,
|
||||
containers: containers && containers.length ? containers : undefined,
|
||||
stacks: stacks && stacks.size ? Array.from(stacks) : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
/** Filter findings by the dashboard's search + multi-selects. Pure, shared client/server. */
|
||||
export function filterFindings<T extends FilterableFinding>(findings: T[], filter: FindingFilter): T[] {
|
||||
const q = (filter.q ?? '').toLowerCase().trim();
|
||||
const sevSet = filter.severities?.length ? new Set(filter.severities) : null;
|
||||
const imgSet = filter.images?.length ? new Set(filter.images) : null;
|
||||
const containerSet = filter.containers?.length ? new Set(filter.containers) : null;
|
||||
const stackSet = filter.stacks?.length ? new Set(filter.stacks) : null;
|
||||
|
||||
// Fast path: nothing to filter — return the input without copying.
|
||||
if (!q && !sevSet && !imgSet && !containerSet && !stackSet) return findings;
|
||||
|
||||
return findings.filter((f) => {
|
||||
if (sevSet && !sevSet.has(f.severity.toLowerCase())) return false;
|
||||
if (imgSet && !imgSet.has(f.imageName)) return false;
|
||||
if (containerSet && !(f.containers ?? []).some((c) => containerSet.has(c.name))) return false;
|
||||
if (stackSet && !(f.stacks ?? []).some((s) => stackSet.has(s))) return false;
|
||||
if (q) {
|
||||
const containerNames = (f.containers ?? []).map((c) => c.name).join(' ');
|
||||
const stackNames = (f.stacks ?? []).join(' ');
|
||||
const hay = `${f.cve} ${f.package} ${f.imageName} ${containerNames} ${stackNames}`.toLowerCase();
|
||||
if (!hay.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export type SortField =
|
||||
| 'cve' | 'package' | 'severity' | 'image'
|
||||
| 'installed' | 'fixed' | 'container' | 'stack' | 'scannedAt';
|
||||
|
||||
/** The min (alphabetically first) name in a list — used to sort by container/stack. */
|
||||
function minName(names: string[]): string {
|
||||
let min: string | undefined;
|
||||
for (const n of names) if (min === undefined || n < min) min = n;
|
||||
return min ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort findings by a column. Pure, shared client/server. Returns a new array.
|
||||
*
|
||||
* Uses a Schwartzian transform: the sort key for each row is computed ONCE up
|
||||
* front, not inside the comparator. This matters for the container/stack fields,
|
||||
* whose key requires reducing a list — doing that per comparison would allocate
|
||||
* and re-scan O(N log N) times instead of O(N).
|
||||
*/
|
||||
export function sortFindings<T extends Finding>(findings: T[], field: SortField, direction: 'asc' | 'desc'): T[] {
|
||||
const dir = direction === 'asc' ? 1 : -1;
|
||||
|
||||
// Numeric key for severity; string key for everything else.
|
||||
const numericKey = field === 'severity';
|
||||
const keyFor = (f: T): number | string => {
|
||||
switch (field) {
|
||||
case 'severity': return severityRank(f.severity);
|
||||
case 'cve': return f.cve;
|
||||
case 'package': return f.package;
|
||||
case 'image': return f.imageName;
|
||||
case 'installed': return f.installedVersion;
|
||||
case 'fixed': return f.fixedVersion || '';
|
||||
case 'container': return minName((f.containers ?? []).map((c) => c.name));
|
||||
case 'stack': return minName(f.stacks ?? []);
|
||||
case 'scannedAt': return f.scannedAt ?? '';
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
// Version-ish fields compare numerically within the string (1.2.10 > 1.2.9).
|
||||
const numericStr = field === 'installed' || field === 'fixed';
|
||||
|
||||
const decorated = findings.map((f) => ({ f, key: keyFor(f) }));
|
||||
decorated.sort((a, b) => {
|
||||
const cmp = numericKey
|
||||
? (a.key as number) - (b.key as number)
|
||||
: (a.key as string).localeCompare(b.key as string, undefined, numericStr ? { numeric: true } : undefined);
|
||||
return cmp * dir;
|
||||
});
|
||||
return decorated.map((d) => d.f);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Generic sliding-window list for server-backed virtual grids.
|
||||
*
|
||||
* Holds at most `windowSize` items in memory regardless of the total row count,
|
||||
* so large datasets don't blow up browser memory. Pairs directly with the
|
||||
* DataGrid virtual-scroll windowing props:
|
||||
*
|
||||
* <DataGrid data={list.items} virtualScroll windowed
|
||||
* dataOffset={list.offset} virtualTotal={list.total}
|
||||
* onWindowShift={list.shiftTo} loading={list.loading} />
|
||||
*
|
||||
* You supply one `fetchPage(offset, limit, signal)` that returns `{ items, total }`.
|
||||
* The primitive owns the window offset, abort/generation guarding of superseded
|
||||
* fetches, and centering the window on the requested scroll position.
|
||||
*
|
||||
* The fetch orchestration lives in `WindowedListCore` (plain, unit-testable);
|
||||
* `createWindowedList` is a thin Svelte-runes adapter over it.
|
||||
*/
|
||||
|
||||
export interface WindowPage<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** State snapshot pushed to observers whenever the window changes. */
|
||||
export interface WindowState<T> {
|
||||
items: T[];
|
||||
offset: number;
|
||||
total: number;
|
||||
loading: boolean;
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
export interface WindowedListOptions<T> {
|
||||
/** Fetch one contiguous slice. Must honor `signal` for cancellation. */
|
||||
fetchPage: (offset: number, limit: number, signal: AbortSignal) => Promise<WindowPage<T>>;
|
||||
/** Rows kept in memory at once (default 400). */
|
||||
windowSize?: number;
|
||||
/** Called on fetch error (non-abort). */
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The absolute offset at which to start a window so it's centered on `targetStart`,
|
||||
* clamped to [0, ...). Pure — unit-tested independently of the reactive factory.
|
||||
*/
|
||||
export function windowOffsetFor(targetStart: number, windowSize: number): number {
|
||||
return Math.max(0, targetStart - Math.floor(windowSize / 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Framework-agnostic core: owns the window offset, abort/generation guarding, and
|
||||
* centering. Pushes a fresh WindowState to `onState` whenever anything changes.
|
||||
* No Svelte runes, so it's fully unit-testable.
|
||||
*/
|
||||
export class WindowedListCore<T> {
|
||||
private windowSize: number;
|
||||
private controller: AbortController | null = null;
|
||||
private generation = 0;
|
||||
/** Absolute offset of the currently in-flight fetch, or null when idle. */
|
||||
private inflightDesired: number | null = null;
|
||||
|
||||
items: T[] = [];
|
||||
offset = 0;
|
||||
total = 0;
|
||||
loading = false;
|
||||
loaded = false;
|
||||
|
||||
constructor(
|
||||
private options: WindowedListOptions<T>,
|
||||
private onState: (s: WindowState<T>) => void = () => {}
|
||||
) {
|
||||
this.windowSize = options.windowSize ?? 400;
|
||||
}
|
||||
|
||||
private emit() {
|
||||
this.onState({
|
||||
items: this.items,
|
||||
offset: this.offset,
|
||||
total: this.total,
|
||||
loading: this.loading,
|
||||
loaded: this.loaded
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift/center the window on an absolute row index.
|
||||
*
|
||||
* `force` bypasses the same-offset dedupe: a scroll fling emits shiftTo per
|
||||
* frame and we skip an identical in-flight offset (fling case), but a
|
||||
* data-invalidating reset (filter/sort change) maps to the SAME offset while
|
||||
* the underlying dataset differs, so it must NOT be deduped — it aborts the
|
||||
* stale in-flight fetch and refetches with the new query.
|
||||
*/
|
||||
async shiftTo(targetStart: number, force = false): Promise<void> {
|
||||
const desired = windowOffsetFor(targetStart, this.windowSize);
|
||||
// A fling emits shiftTo per scroll frame; without this every frame would
|
||||
// abort the in-flight request and start an identical one. If the same
|
||||
// offset is already loading, let it finish instead of thrashing the network.
|
||||
// `force` skips this so a reset refetches even at the same offset.
|
||||
if (!force && this.loading && desired === this.inflightDesired) return;
|
||||
const gen = ++this.generation;
|
||||
this.controller?.abort();
|
||||
this.controller = new AbortController();
|
||||
this.loading = true;
|
||||
this.inflightDesired = desired;
|
||||
this.emit();
|
||||
try {
|
||||
const page = await this.options.fetchPage(desired, this.windowSize, this.controller.signal);
|
||||
if (gen !== this.generation) return; // superseded by a newer fetch
|
||||
this.total = page.total;
|
||||
this.items = page.items;
|
||||
this.offset = desired;
|
||||
this.loaded = true;
|
||||
} catch (error) {
|
||||
if ((error as Error)?.name !== 'AbortError') this.options.onError?.(error);
|
||||
} finally {
|
||||
if (gen === this.generation) {
|
||||
this.loading = false;
|
||||
this.inflightDesired = null;
|
||||
this.emit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset to the first window (filter/sort/dataset change). Forces a refetch
|
||||
* even if an offset-0 load is already in flight for the previous query. */
|
||||
reset(): Promise<void> {
|
||||
return this.shiftTo(0, true);
|
||||
}
|
||||
|
||||
/** Clear all state without fetching. */
|
||||
clear(): void {
|
||||
this.controller?.abort();
|
||||
this.generation++;
|
||||
this.items = [];
|
||||
this.offset = 0;
|
||||
this.total = 0;
|
||||
this.loaded = false;
|
||||
this.loading = false;
|
||||
this.inflightDesired = null;
|
||||
this.emit();
|
||||
}
|
||||
}
|
||||
|
||||
export interface WindowedList<T> {
|
||||
readonly items: T[];
|
||||
readonly offset: number;
|
||||
readonly total: number;
|
||||
readonly loading: boolean;
|
||||
readonly loaded: boolean;
|
||||
shiftTo: (targetStart: number) => void;
|
||||
reset: () => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
/** Svelte-runes adapter over WindowedListCore — the reactive state a component binds to. */
|
||||
export function createWindowedList<T>(options: WindowedListOptions<T>): WindowedList<T> {
|
||||
let items = $state<T[]>([]);
|
||||
let offset = $state(0);
|
||||
let total = $state(0);
|
||||
let loading = $state(false);
|
||||
let loaded = $state(false);
|
||||
|
||||
const core = new WindowedListCore<T>(options, (s) => {
|
||||
items = s.items;
|
||||
offset = s.offset;
|
||||
total = s.total;
|
||||
loading = s.loading;
|
||||
loaded = s.loaded;
|
||||
});
|
||||
|
||||
return {
|
||||
get items() { return items; },
|
||||
get offset() { return offset; },
|
||||
get total() { return total; },
|
||||
get loading() { return loading; },
|
||||
get loaded() { return loaded; },
|
||||
shiftTo: (targetStart: number) => { void core.shiftTo(targetStart); },
|
||||
reset: () => { void core.reset(); },
|
||||
clear: () => core.clear()
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user