Assessor_2.0/src/features/results-browser/ResultsBrowser.tsx

329 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useState } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import type { UnifiedResultFacetResult, UnifiedResultProjection, UnifiedResultQuery, UnifiedResultQueryResult } from '../../domain/unified-result';
import { ResultsFilterPanel } from './ResultsFilterPanel';
import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState';
import { TriangleIcon } from '../../components/TriangleIcon';
import { browserDownloadAdapter } from '../../platform/browser/browser-download-adapter';
import { downloadText } from '../../exports/text-download';
import { buildUnifiedResultJsonl, queryAllUnifiedResults, unifiedResultJsonlFileName } from '../../exports/unified-result-jsonl';
import { hasSelectedTimeslotScope } from '../../app/work-scope';
import { baselineVariantSummary } from '../../evaluation/policy-baseline-variants';
import { POLICY_CLASSIFICATION_LABELS } from '../../domain/policy-status-labels';
import { BackendConnectionDialog } from '../../components/BackendConnectionDialog/BackendConnectionDialog';
import { cweDetailUrl } from '../../domain/cve-enrichment';
const assessorApi = new AssessorApiClient();
const PAGE_SIZE = 50;
export const DEFAULT_RESULT_DB_FILTERS: UnifiedResultQuery = { deviation: true };
const MAX_QUERY_PAGE_SIZE = 10000;
export function shouldShowNvdDescription(scannerDescription: string | undefined, nvdDescription: string | undefined): boolean {
if (!nvdDescription?.trim()) return false;
const comparisonKey = (value: string | undefined) => value
?.normalize('NFKC')
.replace(/\p{Cf}/gu, '')
.toLocaleLowerCase('en-US')
.match(/[\p{L}\p{N}]+/gu)
?.join(' ') ?? '';
const normalizedScanner = comparisonKey(scannerDescription);
const normalizedNvd = comparisonKey(nvdDescription);
return normalizedScanner !== normalizedNvd;
}
type BackendStatus = 'connected' | 'checking' | 'unavailable';
interface ResultsBrowserProps {
backendStatus: BackendStatus;
projectId?: string;
scanRunIds?: string[];
assessmentIds?: string[];
}
export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmentIds }: ResultsBrowserProps) {
const [filters, setFilters] = useState<UnifiedResultQuery>(() => ({ ...DEFAULT_RESULT_DB_FILTERS }));
const [limit, setLimit] = useState(PAGE_SIZE);
const [loadAll, setLoadAll] = useState(false);
const [facetResult, setFacetResult] = useState<UnifiedResultFacetResult | null>(null);
const [queryResult, setQueryResult] = useState<UnifiedResultQueryResult | null>(null);
const [loading, setLoading] = useState(true);
const [exporting, setExporting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const hasTimeslotScope = hasSelectedTimeslotScope(scanRunIds, assessmentIds);
const load = useCallback(async () => {
if (backendStatus !== 'connected' || !projectId || !hasTimeslotScope) {
setFacetResult(null);
setQueryResult(null);
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const scopedQuery: UnifiedResultQuery = {
...filters,
projectId,
scanRunIds,
assessmentIds
};
const [facets, firstPage] = await Promise.all([
assessorApi.queryUnifiedResultFacets(scopedQuery),
assessorApi.queryUnifiedResults({ ...scopedQuery, offset: 0, limit })
]);
const items = [...firstPage.items];
while (loadAll && items.length < firstPage.total) {
const nextPage = await assessorApi.queryUnifiedResults({
...scopedQuery,
offset: items.length,
limit: MAX_QUERY_PAGE_SIZE
});
if (!nextPage.items.length) break;
items.push(...nextPage.items);
}
setFacetResult(facets);
setQueryResult({ ...firstPage, items });
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Results could not be loaded.');
} finally {
setLoading(false);
}
}, [backendStatus, projectId, scanRunIds, assessmentIds, hasTimeslotScope, filters, limit, loadAll]);
useEffect(() => { void load(); }, [load]);
const updateFilters = (next: UnifiedResultQuery) => {
setExpandedId(null);
setLimit(PAGE_SIZE);
setLoadAll(false);
setFilters(next);
};
const exportFilteredResults = async () => {
if (!projectId || !hasTimeslotScope) return;
setExporting(true);
setError(null);
try {
const scopedQuery: UnifiedResultQuery = { ...filters, projectId, scanRunIds, assessmentIds };
const results = await queryAllUnifiedResults(query => assessorApi.queryUnifiedResults(query), scopedQuery);
const generatedAt = new Date().toISOString();
downloadText(
browserDownloadAdapter,
unifiedResultJsonlFileName(generatedAt),
buildUnifiedResultJsonl(scopedQuery, results, generatedAt),
'application/x-ndjson;charset=utf-8'
);
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Filtered results could not be exported.');
} finally {
setExporting(false);
}
};
const processingFacet = facetResult?.facets.find(facet => facet.id === 'processingStatus');
const processingWarnings = processingFacet?.values.filter(value =>
['unknown', 'incompatible', 'reimport-recommended'].includes(value.value)) ?? [];
if (backendStatus !== 'connected') {
return <div className="datahub-page">
<BackendConnectionDialog
backendStatus={backendStatus}
message="Results will be available again once the local API is connected."
/>
</div>;
}
if (!projectId) {
return <div className="datahub-page">
<div className="datahub-empty"><h3>No client selected</h3><p>Open a client in the Datahub first to browse its results.</p></div>
</div>;
}
if (!hasTimeslotScope) {
return <div className="datahub-page">
<div className="datahub-empty"><h3>No timeslot selected</h3><p>Select a timeslot in Work to browse exactly this assessment state.</p></div>
</div>;
}
return <div className="datahub-page results-browser-page">
{error &&<div className="datahub-alert" role="alert"><span>{error}</span><button className="btn" type="button" onClick={() => void load()}>Reload</button></div>}
{loading && !queryResult ? <PanelLoadingState message="Loading results …"/> : <div className="results-browser-layout">
<ResultsFilterPanel facets={facetResult?.facets ?? []} selected={filters} onChange={updateFilters}
onReset={() => updateFilters({ ...DEFAULT_RESULT_DB_FILTERS })}/>
<div className="results-browser-table-wrap">
{processingWarnings.length > 0 && <div className="datahub-alert results-processing-alert" role="status">
<span><strong>Processing readiness:</strong> {processingWarnings.map(value => `${processingStatusLabel(value.value)} (${value.count.toLocaleString('en-US')})`).join(' · ')}. Comparisons with unknown or incompatible contracts are blocked; reimport the original source with the current profile where recommended.</span>
</div>}
<div className="page-actions-row">
<p className="db-structure-meta">{(queryResult?.total ?? 0).toLocaleString('en-US')} hits{queryResult && queryResult.total > queryResult.items.length ? ` · ${queryResult.items.length.toLocaleString('en-US')} loaded` : ''}</p>
<div className="page-action-buttons">
<button className="btn" type="button" disabled={loading} onClick={() => void load()}>{loading ? 'Loading …' : 'Refresh'}</button>
<button className="btn primary" type="button" disabled={loading || exporting || !queryResult?.total}
onClick={() => void exportFilteredResults()}>{exporting ? 'Exporting …' : 'Export JSONL'}</button>
</div>
</div>
{!queryResult?.items.length ? <div className="datahub-empty"><h3>No hits</h3><p>No result matches the current filter combination.</p></div> : <div className="results-browser-table" role="table" aria-label="Results">
<div className="results-browser-row results-browser-head" role="row">
<span>Title</span><span>Severity</span><span>Outcome</span><span>Result Type</span><span>IOE</span><span>Deviation</span><span>Tool</span><span>Processing</span><span>Review</span>
</div>
{queryResult.items.map(item => <ResultRow key={item.resultProjectionId} item={item}
expanded={expandedId === item.resultProjectionId}
onUpdated={load}
onToggle={() => setExpandedId(current => current === item.resultProjectionId ? null : item.resultProjectionId)}/>)}
</div>}
{queryResult && queryResult.total > queryResult.items.length && <div className="results-browser-load-actions">
<button className="btn" type="button" disabled={loading} onClick={() => setLimit(current => current + PAGE_SIZE)}>Load more</button>
<button className="btn" type="button" disabled={loading} onClick={() => loadAll ? void load() : setLoadAll(true)}>Load all</button>
</div>}
</div>
</div>}
</div>;
}
export function ResultRow({ item, expanded, onToggle, onUpdated }: {
item: UnifiedResultProjection;
expanded: boolean;
onToggle(): void;
onUpdated(): Promise<void>;
}) {
const environment = item.sourceKind === 'environment-observation';
const policyObservation = item.sourceKind === 'policy-observation';
const policyComparison = item.sourceKind === 'policy-evaluation' && Boolean(item.policyBaselineLabel);
const contextRecord = environment || policyObservation;
const cveId = item.sourceProfileId === 'defender-for-iot-vulnerabilities'
&& /^CVE-\d{4}-\d{4,}$/i.test(item.sourceRuleId ?? '') ? item.sourceRuleId!.toUpperCase() : undefined;
const [enriching, setEnriching] = useState(false);
const [enrichmentError, setEnrichmentError] = useState<string | null>(null);
const enrich = async () => {
if (!cveId || enriching) return;
setEnriching(true);
setEnrichmentError(null);
try {
await assessorApi.enrichCve(cveId);
await onUpdated();
} catch (reason) {
setEnrichmentError(reason instanceof Error ? reason.message : 'CVE enrichment failed.');
} finally {
setEnriching(false);
}
};
return <div className={`results-browser-row${expanded ? ' expanded' : ''}`} role="row">
<button className="results-browser-row-toggle" type="button" onClick={onToggle} aria-expanded={expanded}>
<span data-label="Title" className="results-browser-title"><TriangleIcon direction={expanded ? 'down' : 'right'} />{item.title}</span>
<span data-label="Severity">{contextRecord ? 'Context' : item.severityNormalized}</span>
<span data-label="Outcome" className={contextRecord ? '' : 'outcome-mark'} data-outcome={contextRecord ? undefined : item.outcomeNormalized}>{environment ? item.environmentState : policyObservation ? item.policyIdentityStatus : item.sourceKind === 'policy-evaluation' && item.policyCustomerState === 'missing' && item.outcomeNormalized === 'informational' ? 'Not configured' : item.outcomeNormalized}</span>
<span data-label="Result Type">{environment ? 'Environment' : policyObservation ? 'GPO Observation' : item.resultType}</span>
<span data-label="IOE">{contextRecord ? '–' : item.isIoe ? 'Yes' : 'No'}</span>
<span data-label="Deviation">{contextRecord ? '–' : item.isDeviation ? 'Yes' : 'No'}</span>
<span data-label="Tool">{item.sourceTool ?? '–'}</span>
<span data-label="Processing">{processingStatusLabel(item.processingStatus)}</span>
<span data-label="Review">{environment ? '–' : item.reviewStatus}</span>
</button>
{expanded && <div className="results-browser-row-detail">
{environment && <div className="results-environment-value">
<strong>{item.environmentProperty}</strong>
<p>{displayEnvironmentValue(item.environmentNormalizedValue)}</p>
</div>}
{policyObservation && <div className="results-environment-value">
<strong>{item.policyIdentityKey}</strong>
<p>{displayEnvironmentValue(item.policyValues)}</p>
</div>}
{item.description && <p>{item.description}</p>}
{item.recommendation && <p><strong>Recommendation:</strong> {item.recommendation}</p>}
<ul>
<li>Source: {item.sourceProfileId ?? '–'}@{item.sourceProfileVersion ?? '–'}</li>
<li>Processing: profile {item.sourceProfileVersion ?? '–'} · mapping {item.mappingRuleVersion ?? '–'} · normalization {item.normalizationRuleVersion ?? '–'} · presentation {item.presentationVersion ?? '–'}</li>
<li>Compatibility: {item.processingCompatibilityKey ?? 'unknown'} · {processingStatusLabel(item.processingStatus)}</li>
{item.policyClassification && <li>Result Status: {POLICY_CLASSIFICATION_LABELS[item.policyClassification]}{item.policySourceCount ? ` · ${item.policySourceCount.toLocaleString('en-US')} sources` : ''}</li>}
{item.processingStatus === 'reimport-recommended' && <li>Reprocessing: {item.reprocessingAvailable ? 'retained source available' : 'original source must be uploaded again'}</li>}
{environment ? <>
<li>Subject class: {item.environmentSubjectType ?? '–'}</li>
<li>State: {item.environmentState ?? 'unknown'} · Value type: {item.environmentValueType ?? 'unknown'}</li>
<li>Disclosure: {item.supplementalDisclosureClass ?? '–'} · policy {item.supplementalDisclosurePolicyVersion ?? '–'}</li>
</> : policyObservation ? <>
<li>Scope: {item.policyScope ?? '–'} · Delivery: {item.policyDeliveryMechanism ?? '–'}</li>
<li>Policy state: {item.policyState ?? '–'} · Identity: {item.policyIdentityStatus ?? 'unresolved'}</li>
<li>Backup instance: {item.policyBackupInstanceId ?? '–'}</li>
<li>Diagnostics: {item.policyDiagnosticCodes?.join(', ') || 'none'}</li>
<li>Source locator: {displayEnvironmentValue(item.sourceLocator)}</li>
</> : policyComparison ? <>
<li>Baseline: {item.policyBaselineLabel}</li>
<li>Scope: {item.policyScope ?? '–'} · Delivery: {item.policyDeliveryMechanism ?? '–'}</li>
<li>Actual: {displayEnvironmentValue(item.policyActualValue)}</li>
{item.policyBaselineVariantAssessment ? <li>
{baselineVariantSummary(item.policyBaselineVariantAssessment)}
<p>No effective device state or GPO application is established by this backup comparison.</p>
<ul>{item.policyBaselineVariantAssessment.variants.map(variant => <li key={variant.settingId}>
Setting reference: {variant.settingId} · Value comparison: {variant.comparator.relation}
<p>Expected: {displayEnvironmentValue(variant.comparator.baseline)}</p>
<p>Original baseline value: {displayEnvironmentValue(variant.baselineValues)}</p>
</li>)}</ul>
</li> : <li>Baseline value: {displayEnvironmentValue(item.policyBaselineValue)}</li>}
<li>Comparison: {item.policyComparisonRelation ?? '–'} · Customer state: {item.policyCustomerState ?? '–'}</li>
<li>Diagnostics: {item.policyDiagnosticCodes?.join(', ') || 'none'}</li>
</> : <>
<li>Rule ID: {item.sourceRuleId ?? '–'}</li>
{/* Defender-CVEs aggregieren betroffene Devices; der affectedAsset
traegt die aggregierte Betroffenheit als Anzahl (Affected Device
Count), der eigentliche Geraetebeleg steht als Tabelle im
Ergebnisfenster. */}
{item.sourceProfileId === 'defender-for-iot-vulnerabilities'
? <li>Affected Devices: {item.affectedAsset ?? '–'}</li>
: <li>Affected Object: {item.affectedAsset ?? '–'}</li>}
</>}
<li>Categories: {item.categories.map(category => category.categoryLabel).join(', ') || '–'}</li>
</ul>
{cveId && <section className="results-cve-enrichment" aria-label={`${cveId} enrichment`}>
<div className="page-actions-row">
<h4>{cveId} · NVD</h4>
<button className="btn" type="button" disabled={enriching} onClick={() => void enrich()}>
{enriching ? 'Retrieving …' : item.cveEnrichment ? 'Refresh from NVD' : 'Enrich from NVD'}
</button>
</div>
{enrichmentError && <p role="alert">{enrichmentError}</p>}
{item.cveEnrichment ? <>
{shouldShowNvdDescription(item.description, item.cveEnrichment.description)
&& <p>{item.cveEnrichment.description}</p>}
<ul>
<li>Status: {item.cveEnrichment.vulnerabilityStatus}</li>
<li>Published: {item.cveEnrichment.publishedAt} · Last modified: {item.cveEnrichment.lastModifiedAt}</li>
<li>Metrics: {item.cveEnrichment.metrics.map(metric =>
`CVSS ${metric.version} ${metric.baseScore}${metric.baseSeverity ? ` ${metric.baseSeverity}` : ''} (${metric.source})`
).join(' · ') || 'Not supplied'}</li>
<li>Weaknesses: {item.cveEnrichment.weaknesses.length ? item.cveEnrichment.weaknesses.map((weakness, index) => {
const url = cweDetailUrl(weakness.id);
return <span key={`${weakness.id}-${index}`}>
{index > 0 ? ', ' : ''}
{url ? <a href={url} target="_blank" rel="noreferrer">{weakness.id}</a> : weakness.id}
</span>;
}) : 'Not supplied'}</li>
</ul>
{item.cveEnrichment.references.length > 0 && <details>
<summary>Advisories, solutions, and tools ({item.cveEnrichment.references.length.toLocaleString('en-US')})</summary>
<ul>{item.cveEnrichment.references.map(reference => <li key={reference.url}>
<a href={reference.url} target="_blank" rel="noreferrer">{reference.url}</a>
{reference.tags.length ? ` · ${reference.tags.join(', ')}` : ''}
</li>)}</ul>
</details>}
<p><a href={item.cveEnrichment.sourceUrl} target="_blank" rel="noreferrer">Open the NVD record</a></p>
<p><strong>NVD Affected Configurations:</strong> {item.cveEnrichment.affectedConfigurations.length.toLocaleString('en-US')}</p>
</> : <p>No shared NVD enrichment has been stored yet.</p>}
</section>}
</div>}
</div>;
}
function displayEnvironmentValue(value: unknown): string {
if (value === undefined) return '–';
if (typeof value === 'string') return value || '–';
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (value === null) return 'null';
return JSON.stringify(value);
}
function processingStatusLabel(value: string | undefined): string {
if (value === 'current') return 'Current';
if (value === 'compatible-older') return 'Compatible older';
if (value === 'reimport-recommended') return 'Reimport recommended';
if (value === 'incompatible') return 'Incompatible';
return 'Unknown';
}