109 lines
8.0 KiB
TypeScript
109 lines
8.0 KiB
TypeScript
import type { JsonValue } from '../domain/canonical-model';
|
|
import type { ImportDiagnostic } from '../domain/import-intermediate';
|
|
import type { EnvironmentObservationCandidate, ScannerReportSectionCandidate } from '../domain/source-extraction';
|
|
import { isJsonObject } from './embedded-report-json';
|
|
import { purpleKnightMetricObservation } from './purple-knight-summary-source-extractor';
|
|
|
|
export interface PurpleKnightIndicator {
|
|
value: Record<string, JsonValue>;
|
|
path: string;
|
|
identity: string;
|
|
}
|
|
|
|
export function purpleKnightStatus(indicator: Record<string, JsonValue>, execution: Record<string, JsonValue>): string {
|
|
if (indicator.IsFailedToRun === true) return 'Failed to run';
|
|
if (indicator.IsAborted === true) return 'Canceled';
|
|
if (indicator.Selected === false) return 'Not selected';
|
|
if (indicator.IsEvaluated === false) return 'Not Relevant';
|
|
if (indicator.IsFailed === true && typeof indicator.ExposureType === 'string' && /IOE/i.test(indicator.ExposureType)) return 'IOE Found';
|
|
if (indicator.IsFailed === true && typeof indicator.ExposureType === 'string' && /IOC/i.test(indicator.ExposureType)) return 'IOC Found';
|
|
const status = execution.Status ?? indicator.State;
|
|
return typeof status === 'string' ? status.replace(/([a-z])([A-Z])/g, '$1 $2') : '';
|
|
}
|
|
|
|
const METRICS: Record<string, string> = {
|
|
TotalScore: 'Security posture score', TotalCount: 'Total indicators', TotalEvaluatedCount: 'Evaluated',
|
|
PassedCount: 'Passed', IOEsCount: 'IOEs found', IOCsCount: 'IOCs found', FailedToRunCount: 'Failed to run',
|
|
AbortedCount: 'Canceled', UnselectedCount: 'Not selected', NotRelevant: 'Not relevant',
|
|
TestExecutionTime: 'Scan duration', TotalSelectedCount: 'Selected indicators'
|
|
};
|
|
|
|
function countDiagnostic(value: JsonValue, actual: number, path: string): ImportDiagnostic[] {
|
|
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) return [{
|
|
severity: 'warning', code: 'purple-knight-count-invalid', stage: 'extraction',
|
|
message: 'A declared PurpleKnight count is invalid.', details: { objectPath: path }
|
|
}];
|
|
return value === actual ? [] : [{
|
|
severity: 'warning', code: 'purple-knight-count-mismatch', stage: 'extraction',
|
|
message: `PurpleKnight declares ${value} results; ${actual} were independently counted.`,
|
|
details: { objectPath: path, declared: value, actual }
|
|
}];
|
|
}
|
|
|
|
export function purpleKnightReportContext(fileName: string, root: Record<string, JsonValue>, indicators: PurpleKnightIndicator[]) {
|
|
const sectionKey = 'purple-knight-assessment-summary';
|
|
const rootPath = '$.reportJSON';
|
|
// Keep all report metadata, including future nested keys, separate from findings.
|
|
const reportSections: ScannerReportSectionCandidate[] = [{
|
|
version: 1, sectionKey, label: 'PurpleKnight assessment summary', ordinal: 0, state: 'observed',
|
|
tables: [{ name: 'Report metadata', headers: ['Property', 'Original value'], rows: Object.entries(root) }],
|
|
source: { fileName, objectPath: rootPath }, attributes: { kind: 'purple-knight-report-context' }
|
|
}];
|
|
const environmentObservations: EnvironmentObservationCandidate[] = Object.entries(root).map(([key, value]) => ({
|
|
version: 1, sectionKey, subject: { type: 'purple-knight-report', identity: rootPath },
|
|
property: `purple_knight.source.${key}`, label: key, originalValue: value, normalizedValue: value,
|
|
valueType: value !== null && typeof value === 'object' ? 'object' : typeof value === 'number' ? 'number' : typeof value === 'boolean' ? 'boolean' : 'string',
|
|
state: value === null || value === '' ? 'empty' : 'observed',
|
|
source: { fileName, objectPath: `${rootPath}.${key}` }, attributes: { sourceField: key }
|
|
}));
|
|
const diagnostics: ImportDiagnostic[] = [];
|
|
const metric = (key: string, value: JsonValue | undefined, path: string) => {
|
|
if (value !== undefined) environmentObservations.push(purpleKnightMetricObservation(key, value, { fileName, objectPath: path }));
|
|
};
|
|
metric('Tool version', root.AppVersion, `${rootPath}.AppVersion`);
|
|
metric('Tool edition', root.Edition, `${rootPath}.Edition`);
|
|
const scopes = Array.isArray(root.reportResultsList) ? root.reportResultsList : [];
|
|
const byIdentity = new Map(indicators.map(item => [item.identity, item]));
|
|
for (const [index, scope] of scopes.entries()) {
|
|
if (!isJsonObject(scope) || !isJsonObject(scope.ReportResults)) continue;
|
|
const path = `${rootPath}.reportResultsList[${index}]`;
|
|
const prefix = scope.ADType === 'AD' ? 'AD' : /^(?:AAD|Entra)$/i.test(String(scope.ADType)) ? 'Entra' : 'Other';
|
|
const results = scope.ReportResults;
|
|
metric('Generated date', results.GeneratedOn, `${path}.ReportResults.GeneratedOn`);
|
|
for (const [key, label] of Object.entries(METRICS)) metric(key === 'TotalSelectedCount' && scopes.length === 1 ? 'Selected indicators' : `${prefix}: ${label}`, results[key], `${path}.ReportResults.${key}`);
|
|
metric(`${prefix}: Forest name`, scope.ForestName, `${path}.ForestName`);
|
|
if (Array.isArray(scope.SelectedDomains)) {
|
|
environmentObservations.push({
|
|
...purpleKnightMetricObservation(`${prefix}: Number of domains`, scope.SelectedDomains, { fileName, objectPath: `${path}.SelectedDomains` }),
|
|
normalizedValue: scope.SelectedDomains.length, valueType: 'number', attributes: { derived: true, derivation: 'array-length' }
|
|
});
|
|
}
|
|
metric(`${prefix}: Domains`, scope.SelectedDomains, `${path}.SelectedDomains`);
|
|
metric('Requested by', scope.UserName, `${path}.UserName`);
|
|
const referenced = new Set<string>();
|
|
for (const [categoryIndex, entry] of (Array.isArray(scope.ReportCategories) ? scope.ReportCategories : []).entries()) {
|
|
if (!isJsonObject(entry)) continue;
|
|
const category = isJsonObject(entry.Category) ? entry.Category : {};
|
|
if (typeof category.Name === 'string') metric(`${prefix} category score: ${category.Name}`, category.Score, `${path}.ReportCategories[${categoryIndex}].Category.Score`);
|
|
if (Array.isArray(entry.Indicators)) for (const identity of entry.Indicators) if (typeof identity === 'string') referenced.add(identity);
|
|
}
|
|
const scoped = scopes.length === 1 ? indicators : [...referenced].flatMap(id => byIdentity.has(id) ? [byIdentity.get(id)!] : []);
|
|
const counts: Record<string, number> = { TotalCount: scoped.length, PassedCount: 0, IOEsCount: 0, IOCsCount: 0, FailedToRunCount: 0, AbortedCount: 0, UnselectedCount: 0, NotRelevant: 0, TotalEvaluatedCount: 0 };
|
|
const statusKeys: Record<string, string> = { Pass: 'PassedCount', Passed: 'PassedCount', 'IOE Found': 'IOEsCount', 'IOC Found': 'IOCsCount', 'Failed to run': 'FailedToRunCount', Canceled: 'AbortedCount', 'Not selected': 'UnselectedCount', 'Not Relevant': 'NotRelevant' };
|
|
for (const item of scoped) {
|
|
const indicator = isJsonObject(item.value.ResIndicator) ? item.value.ResIndicator : {};
|
|
const status = purpleKnightStatus(indicator, isJsonObject(item.value.ExecutionResult) ? item.value.ExecutionResult : {});
|
|
if (Object.hasOwn(statusKeys, status)) counts[statusKeys[status]]++;
|
|
if (indicator.IsEvaluated === true) counts.TotalEvaluatedCount++;
|
|
}
|
|
for (const [key, actual] of Object.entries(counts)) if (key in results) diagnostics.push(...countDiagnostic(results[key], actual, `${path}.ReportResults.${key}`));
|
|
for (const id of referenced) if (!byIdentity.has(id)) diagnostics.push({ severity: 'warning', code: 'purple-knight-reference-missing', stage: 'extraction', message: 'A report category references a missing indicator.', details: { objectPath: `${path}.ReportCategories` } });
|
|
}
|
|
for (const item of indicators) {
|
|
const rows = Array.isArray(item.value.IndicatorReportObjects) ? item.value.IndicatorReportObjects : [];
|
|
if ('TotalResultsCount' in item.value) diagnostics.push(...countDiagnostic(item.value.TotalResultsCount, rows.length, `${item.path}.TotalResultsCount`));
|
|
if (isJsonObject(root.IndicatorsTotalCount) && item.identity in root.IndicatorsTotalCount) diagnostics.push(...countDiagnostic(root.IndicatorsTotalCount[item.identity], rows.length, `${rootPath}.IndicatorsTotalCount[${JSON.stringify(item.identity)}]`));
|
|
}
|
|
return { reportSections, environmentObservations, diagnostics };
|
|
}
|