329 lines
19 KiB
TypeScript
329 lines
19 KiB
TypeScript
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';
|
||
}
|