Assessor_2.0/server/gpo/microsoft-security-baseline...

191 lines
9.2 KiB
TypeScript

import { createHash } from 'node:crypto';
import type { AssessorRepository } from '../../src/persistence/assessor-repository';
import {
MICROSOFT_BASELINE_METADATA_DETECTOR_VERSION,
baselineCatalogStatus,
detectMicrosoftSecurityBaselineMetadata,
selectMicrosoftSecurityBaseline,
type MicrosoftSecurityBaselineClassification,
type MicrosoftSecurityBaselineRecord,
type MicrosoftSecurityBaselineSelectionContext
} from '../../src/domain/microsoft-security-baseline';
import type { BackendArtifactStore } from '../artifacts/backend-artifact-store';
import { RepositoryNotFoundError, RepositoryValidationError } from '../../src/persistence/repository-errors';
import { MICROSOFT_BASELINE_SETTINGS_ANALYZER, type StoredMicrosoftSecurityBaselineAnalysis } from '../../src/domain/microsoft-security-baseline-analysis';
import type { MicrosoftBaselineWorkerExecutor } from './gpo-inventory-executor';
const zipSignatures = ['504b0304', '504b0506', '504b0708'];
function classificationId(baselineId: string): string {
const digest = createHash('sha256').update(`${baselineId}\0${MICROSOFT_BASELINE_METADATA_DETECTOR_VERSION}`).digest('hex');
return `ms-baseline-classification-${digest.slice(0, 32)}`;
}
function latestClassifications(classifications: readonly MicrosoftSecurityBaselineClassification[]) {
const latest = new Map<string, MicrosoftSecurityBaselineClassification>();
for (const classification of classifications) {
const current = latest.get(classification.baselineId);
if (!current || Date.parse(classification.createdAt) > Date.parse(current.createdAt)
|| (classification.createdAt === current.createdAt && classification.id.localeCompare(current.id) > 0)) latest.set(classification.baselineId, classification);
}
return latest;
}
function applyClassifications(records: readonly MicrosoftSecurityBaselineRecord[], classifications: readonly MicrosoftSecurityBaselineClassification[]): MicrosoftSecurityBaselineRecord[] {
const latest = latestClassifications(classifications);
return records.map(record => {
const classification = latest.get(record.id);
return classification ? { ...record, status: classification.status, metadata: classification.metadata, classificationId: classification.id } : record;
});
}
export class MicrosoftSecurityBaselineService {
constructor(
private readonly repository: AssessorRepository,
private readonly artifactStore: BackendArtifactStore,
private readonly maximumBytes: number,
private readonly analysisExecutor?: MicrosoftBaselineWorkerExecutor
) {}
async refreshDetectedMetadata(classifiedAt = new Date().toISOString()): Promise<number> {
if (!Number.isFinite(Date.parse(classifiedAt))) throw new RepositoryValidationError('The baseline classification timestamp is invalid.');
const [records, classifications] = await Promise.all([
this.repository.listMicrosoftSecurityBaselines(),
this.repository.listMicrosoftSecurityBaselineClassifications()
]);
const latest = latestClassifications(classifications);
const currentKeys = new Set(classifications.map(item => `${item.baselineId}\0${item.detectorVersion}`));
let created = 0;
for (const record of records) {
const key = `${record.id}\0${MICROSOFT_BASELINE_METADATA_DETECTOR_VERSION}`;
if (currentKeys.has(key)) continue;
const metadata = detectMicrosoftSecurityBaselineMetadata(record.fileName);
const classification: MicrosoftSecurityBaselineClassification = {
version: 1,
id: classificationId(record.id),
baselineId: record.id,
detectorVersion: MICROSOFT_BASELINE_METADATA_DETECTOR_VERSION,
createdAt: classifiedAt,
supersedesClassificationId: latest.get(record.id)?.id,
status: baselineCatalogStatus(metadata),
metadata
};
await this.repository.saveMicrosoftSecurityBaselineClassification(classification);
created += 1;
}
return created;
}
async list(): Promise<MicrosoftSecurityBaselineRecord[]> {
const [records, classifications] = await Promise.all([
this.repository.listMicrosoftSecurityBaselines(),
this.repository.listMicrosoftSecurityBaselineClassifications()
]);
return applyClassifications(records, classifications);
}
async delete(baselineId: string): Promise<MicrosoftSecurityBaselineRecord> {
const record = await this.repository.deleteMicrosoftSecurityBaseline(baselineId);
if (!record) throw new RepositoryNotFoundError('The Microsoft baseline package is not present.');
await this.artifactStore.remove(record.storageKey).catch(() => undefined);
return record;
}
async submit(request: { fileName: string; bytes: Uint8Array; receivedAt: string }): Promise<MicrosoftSecurityBaselineRecord> {
const fileName = request.fileName.trim();
if (!fileName || !/\.zip$/i.test(fileName)) throw new RepositoryValidationError('Microsoft Security Baselines must be uploaded as ZIP packages.');
if (!Number.isFinite(Date.parse(request.receivedAt))) throw new RepositoryValidationError('The baseline import timestamp is invalid.');
if (request.bytes.byteLength < 4 || request.bytes.byteLength > this.maximumBytes) throw new RepositoryValidationError('The baseline package size is invalid.');
if (!zipSignatures.includes(Buffer.from(request.bytes.subarray(0, 4)).toString('hex'))) throw new RepositoryValidationError('The uploaded file is not a valid ZIP container.');
const sha256 = createHash('sha256').update(request.bytes).digest('hex');
const existing = (await this.list()).find(item => item.sha256 === sha256);
if (existing) {
if (this.analysisExecutor) await this.ensureAnalysis(existing);
return existing;
}
const receipt = await this.artifactStore.put({ bytes: request.bytes, expectedSha256: sha256, maximumBytes: this.maximumBytes });
const metadata = detectMicrosoftSecurityBaselineMetadata(fileName);
const record: MicrosoftSecurityBaselineRecord = {
version: 1,
id: `ms-baseline-${sha256.slice(0, 32)}`,
fileName,
sha256,
sizeBytes: receipt.sizeBytes,
storageKey: receipt.storageKey,
importedAt: request.receivedAt,
status: baselineCatalogStatus(metadata),
metadata
};
try {
await this.repository.saveMicrosoftSecurityBaseline(record);
} catch (error) {
await this.artifactStore.remove(receipt.storageKey).catch(() => undefined);
throw error;
}
await this.refreshDetectedMetadata(request.receivedAt);
const stored = (await this.list()).find(item => item.id === record.id) ?? record;
if (this.analysisExecutor) await this.ensureAnalysis(stored);
return stored;
}
async listAnalyses(baselineId?: string): Promise<StoredMicrosoftSecurityBaselineAnalysis[]> {
return this.repository.listMicrosoftSecurityBaselineAnalyses(baselineId);
}
async ensureAnalysis(record: MicrosoftSecurityBaselineRecord): Promise<StoredMicrosoftSecurityBaselineAnalysis> {
if (!this.analysisExecutor) throw new RepositoryValidationError('The bounded Microsoft baseline analyzer is not configured.');
const existing = (await this.repository.listMicrosoftSecurityBaselineAnalyses(record.id))
.find(item => item.analysis.analyzer.version === MICROSOFT_BASELINE_SETTINGS_ANALYZER.version);
if (existing) return existing;
const bytes = await this.artifactStore.read({
storageKey: record.storageKey,
expectedSha256: record.sha256,
maximumBytes: this.maximumBytes
});
const analysis = await this.analysisExecutor.executeMicrosoftBaseline({
bytes,
options: {
baselineId: record.id,
analyzedAt: record.importedAt,
expectedPackageSha256: record.sha256
}
});
const previous = (await this.repository.listMicrosoftSecurityBaselineAnalyses(record.id))
.sort((left, right) => Date.parse(right.analysis.analyzedAt) - Date.parse(left.analysis.analyzedAt) || right.analysis.id.localeCompare(left.analysis.id))[0];
const stored: StoredMicrosoftSecurityBaselineAnalysis = {
version: 1,
analysis,
supersedesAnalysisId: previous?.analysis.id
};
await this.repository.saveMicrosoftSecurityBaselineAnalysis(stored);
return (await this.repository.listMicrosoftSecurityBaselineAnalyses(record.id)).find(item => item.analysis.id === analysis.id) ?? stored;
}
async reprocess(baselineId: string): Promise<StoredMicrosoftSecurityBaselineAnalysis> {
const record = (await this.list()).find(item => item.id === baselineId);
if (!record) throw new RepositoryValidationError('The Microsoft baseline package is not present.');
return this.ensureAnalysis(record);
}
async reprocessPending(): Promise<{ analyzed: number; failed: number }> {
let analyzed = 0;
let failed = 0;
for (const record of await this.list()) {
const current = (await this.repository.listMicrosoftSecurityBaselineAnalyses(record.id))
.some(item => item.analysis.analyzer.version === MICROSOFT_BASELINE_SETTINGS_ANALYZER.version);
if (current) continue;
try {
await this.ensureAnalysis(record);
analyzed += 1;
} catch {
failed += 1;
}
}
return { analyzed, failed };
}
async select(context: MicrosoftSecurityBaselineSelectionContext) {
return selectMicrosoftSecurityBaseline(await this.list(), context);
}
}