84 lines
5.7 KiB
TypeScript
84 lines
5.7 KiB
TypeScript
import type { JsonValue } from '../domain/canonical-model';
|
|
import type { DecodedSourceDocument, ImportDiagnostic } from '../domain/import-intermediate';
|
|
import { ImportError } from '../shared/errors';
|
|
import { isJsonObject } from './embedded-report-json';
|
|
import { assignedReportObjects, readStaticReportObject, StaticReportLiteralError, type StaticReportBindingMatcher } from './static-report-literal';
|
|
import type { PurpleKnightIndicator } from './purple-knight-report-context';
|
|
import { protectHtmlAttributeMarkup } from './html-attribute-markup';
|
|
|
|
const MAX_INDICATORS = 250_000;
|
|
// Inert source syntax, never a dependency on the browser's global object.
|
|
const SCRIPT_GLOBAL = 'win' + 'dow';
|
|
const ASSIGNMENT = new RegExp(`^${SCRIPT_GLOBAL}\\s*(?:\\.\\s*(reportJSON)|\\[\\s*["'](Category_\\d+)["']\\s*\\]\\s*\\[\\s*["']([\\w-]+)["']\\s*\\])\\s*=\\s*(?!=|>)`);
|
|
const reportBinding: StaticReportBindingMatcher = (script, position) => {
|
|
if (!script.startsWith(SCRIPT_GLOBAL, position)) return undefined;
|
|
let before = position - 1;
|
|
while (before >= 0 && /\s/.test(script[before])) before--;
|
|
if (script[before] === '.') return undefined;
|
|
const match = ASSIGNMENT.exec(script.slice(position));
|
|
return match ? { start: position + match[0].length, binding: match[1] ?? `${match[2]}[${JSON.stringify(match[3])}]` } : undefined;
|
|
};
|
|
interface PurplePayload {
|
|
root?: Record<string, JsonValue>;
|
|
indicators: PurpleKnightIndicator[];
|
|
categoryNames: Map<number, string>;
|
|
recognized: boolean;
|
|
diagnostics: ImportDiagnostic[];
|
|
}
|
|
const cache = new WeakMap<DecodedSourceDocument, PurplePayload>();
|
|
|
|
function invalid(code: string, message: string): never {
|
|
throw new ImportError(message, [{ severity: 'error', stage: 'extraction', code, message }]);
|
|
}
|
|
|
|
export function purpleKnightPayload(document: DecodedSourceDocument): PurplePayload {
|
|
const cached = cache.get(document);
|
|
if (cached) return cached;
|
|
const result: PurplePayload = { indicators: [], categoryNames: new Map(), recognized: false, diagnostics: [] };
|
|
if (document.format !== 'HTML') return result;
|
|
const bindings = new Set<string>();
|
|
const names = new Set<string>();
|
|
try {
|
|
// Respect quoted attributes, comments and other raw-text elements without
|
|
// altering the inert script bodies or their native JSON string values.
|
|
const markup = protectHtmlAttributeMarkup(document.rawText);
|
|
const scripts = /<!--[\s\S]*?-->|<(script|style|textarea|title|xmp|iframe|noembed|noframes)\b[^>]*>([\s\S]*?)(?:<\/\1\s*>|$)/gi;
|
|
for (const script of markup.matchAll(scripts)) {
|
|
if (script[1]?.toLowerCase() !== 'script') continue;
|
|
for (const { start, binding } of assignedReportObjects(script[2], reportBinding)) {
|
|
if (binding !== 'reportJSON' && !binding.startsWith('Category_')) continue;
|
|
result.recognized = true;
|
|
if (bindings.has(binding)) invalid('purple-knight-binding-duplicate', 'PurpleKnight contains conflicting report assignments.');
|
|
bindings.add(binding);
|
|
const { value } = readStaticReportObject(script[2], start);
|
|
if (binding === 'reportJSON') {
|
|
if (!Array.isArray(value.reportResultsList) || value.reportResultsList.some(scope => !isJsonObject(scope) || !isJsonObject(scope.ReportResults))) invalid('purple-knight-payload-invalid', 'PurpleKnight report summary is malformed.');
|
|
result.root = value;
|
|
} else {
|
|
if (result.indicators.length >= MAX_INDICATORS) invalid('purple-knight-indicator-limit', 'PurpleKnight indicator limit exceeded; no results were silently truncated.');
|
|
if (!isJsonObject(value.ResIndicator) || !isJsonObject(value.ExecutionResult)) invalid('purple-knight-indicator-invalid', 'A PurpleKnight indicator is malformed.');
|
|
const name = value.ResIndicator.ShortName;
|
|
if (typeof name !== 'string' || !name.trim()) invalid('purple-knight-id-missing', 'A PurpleKnight indicator has no native rule ID.');
|
|
if (names.has(name)) result.diagnostics.push({ severity: 'warning', code: 'purple-knight-id-duplicate', stage: 'extraction', message: 'PurpleKnight contains repeated native rule IDs. All occurrences were retained.' });
|
|
names.add(name);
|
|
if (value.IndicatorReportObjects != null && (!Array.isArray(value.IndicatorReportObjects) || value.IndicatorReportObjects.some(row => !Array.isArray(row)))) invalid('purple-knight-detail-invalid', 'A PurpleKnight result table is malformed.');
|
|
const identity = JSON.parse(binding.slice(binding.indexOf('[') + 1, -1)) as string;
|
|
result.indicators.push({ value, path: `$.${binding}`, identity });
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (!(error instanceof StaticReportLiteralError)) throw error;
|
|
if (result.recognized) invalid('purple-knight-payload-invalid', 'PurpleKnight data could not be read completely. Only bounded static report objects are supported.');
|
|
}
|
|
if (result.recognized && !result.root) invalid('purple-knight-summary-missing', 'PurpleKnight indicator data has no readable report summary.');
|
|
if (result.root) {
|
|
const scopes = result.root.reportResultsList as JsonValue[];
|
|
if (!result.indicators.length && (!scopes.length || scopes.some(scope => !isJsonObject(scope) || !isJsonObject(scope.ReportResults) || scope.ReportResults.TotalCount !== 0))) invalid('purple-knight-results-missing', 'PurpleKnight announces results but no indicators could be read.');
|
|
const config = isJsonObject(result.root.GeneralConfig) ? result.root.GeneralConfig : {};
|
|
for (const category of Array.isArray(config.Categories) ? config.Categories : []) if (isJsonObject(category) && typeof category.ID === 'number' && typeof category.Name === 'string') result.categoryNames.set(category.ID, category.Name);
|
|
}
|
|
cache.set(document, result);
|
|
return result;
|
|
}
|