191 lines
9.2 KiB
TypeScript
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);
|
|
}
|
|
}
|