Assessor_2.0/scripts/gpo-role-impact.ts

213 lines
14 KiB
TypeScript

/**
* GPO target-role impact measurement — prints NUMBERS ONLY.
*
* Question: how many current GPO Fail findings would change if every GPO were
* compared only with the Microsoft baseline templates of its target role, and
* how many GPOs are not linked at all?
*
* Privacy contract (read before running):
* - Reads only the one project whose ID you pass. No project list is printed
* unless you run `--list` yourself.
* - Everything stays in memory. No file is written.
* - Output is a fixed set of counters. No GPO names, GUIDs, paths, setting
* names, values, SIDs, OU names or tenant identifiers are printed.
* - Any error is reported only as its error type, never with its message.
* - Read-only: it calls GET endpoints of the local backend and runs one SELECT
* against the canonical local PostgreSQL container. It changes nothing.
*
* Usage (from the repository root, backend running on 127.0.0.1:4173):
* npx vite-node scripts/gpo-role-impact.ts --list
* npx vite-node scripts/gpo-role-impact.ts project-xxxxxxxx
*/
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { policyComparisonDisposition } from '../src/domain/policy-comparison-disposition';
import { baselineTemplateContext } from '../src/evaluation/baseline-template-context';
import { policyWorkVerdictPriority } from '../src/evaluation/policy-work-results';
import { readVerifiedZipFiles, inventoryZipArchive } from '../server/gpo/zip-inventory';
const API = 'http://127.0.0.1:4173/api/v1';
const CONTAINER = 'assessor-2_0-postgres-1';
const DEFAULT_DC_POLICY = '{6AC1786C-016F-11D2-945F-00C04FB984F9}';
type Role = 'Domain Controller' | 'all' | 'unlinked' | 'unknown';
async function get<T>(path: string): Promise<T> {
const response = await fetch(API + path);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<T>;
}
function sql(query: string): string[] {
return execFileSync('docker', ['exec', CONTAINER, 'psql', '-U', 'assessor', '-d', 'assessor', '-At', '-c', query],
{ encoding: 'utf8', maxBuffer: 512 * 1024 * 1024 }).split('\n').filter(Boolean);
}
function artifactRoot(): string {
const line = readFileSync('.env.postgres', 'utf8').split(/\r?\n/).find(item => item.startsWith('ASSESSOR_ARTIFACT_ROOT='));
if (!line) throw new Error('artifact root not configured');
return line.slice('ASSESSOR_ARTIFACT_ROOT='.length).trim();
}
function decode(bytes: Uint8Array): string {
if (bytes[0] === 0xff && bytes[1] === 0xfe) return new TextDecoder('utf-16le').decode(bytes);
if (bytes[0] === 0xfe && bytes[1] === 0xff) return new TextDecoder('utf-16be').decode(bytes);
return new TextDecoder('utf-8').decode(bytes);
}
/** Role only from unambiguous evidence in GPReport.xml; everything else stays unknown. */
function roleFromReport(report: string): { role: Role; wmiFilter: boolean } {
const text = report.replace(/<(\/?)[A-Za-z0-9]+:/g, '<$1');
const guid = /<Identifier[^>]*>\s*<Identifier[^>]*>([^<]+)</.exec(text)?.[1] ?? /<Identifier>([^<]+)<\/Identifier>/.exec(text)?.[1] ?? '';
const links = [...text.matchAll(/<LinksTo>([\s\S]*?)<\/LinksTo>/g)].map(match => ({
path: /<SOMPath>([^<]*)<\/SOMPath>/.exec(match[1])?.[1] ?? '',
enabled: !/<Enabled>\s*false\s*<\/Enabled>/i.test(match[1])
})).filter(link => link.enabled);
const wmiFilter = /<FilterName>[^<]+<\/FilterName>/.test(text);
if (guid.trim().toUpperCase() === DEFAULT_DC_POLICY) return { role: 'Domain Controller', wmiFilter };
if (!links.length) return { role: 'unlinked', wmiFilter };
if (links.every(link => /\/Domain Controllers$/i.test(link.path.trim()))) return { role: 'Domain Controller', wmiFilter };
// A link to the domain itself (no OU segment) applies to every role.
if (links.some(link => !link.path.includes('/'))) return { role: 'all', wmiFilter };
return { role: 'unknown', wmiFilter };
}
async function list(): Promise<void> {
// Shown only on your own screen; do not paste this list.
const projects = await get<Array<{ id: string; name: string }>>('/projects');
for (const project of projects) console.log(project.id, ' ', project.name);
}
async function measure(projectId: string): Promise<void> {
if (!/^project-[a-z0-9]+$/.test(projectId)) throw new Error('invalid project id');
const overview = await get<{ current: { evaluationRunIds: string[] } }>(`/projects/${projectId}/policy-analysis-overview`);
const runIds = new Set(overview.current.evaluationRunIds);
const evaluations = (await get<{ records: any[] }>(`/projects/${projectId}/policy-evaluations`)).records.filter(item => runIds.has(item.run.id));
const aggregations = (await get<{ records: any[] } | any[]>(`/projects/${projectId}/policy-snapshot-aggregations`));
const aggregationRecords: any[] = Array.isArray(aggregations) ? aggregations : aggregations.records;
// Backup instance -> backup folder, backup folder -> role from its GPReport.xml.
const instanceFolder = new Map<string, string>();
for (const line of sql(`select json_build_object('id', instance_id, 'data', data)::text from assessor_gpo_backup_instances where project_id = '${projectId}'`)) {
const item = JSON.parse(line);
const folder = JSON.stringify(item.data).match(/\{[0-9A-Fa-f-]{36}\}/)?.[0];
if (folder) instanceFolder.set(item.id, folder.toUpperCase());
}
const folderRole = new Map<string, { role: Role; wmiFilter: boolean }>();
const root = artifactRoot();
for (const line of sql(`select storage_key || '|' || coalesce(relative_path, '') from assessor_import_artifacts where project_id = '${projectId}' and storage_kind = 'backend-controlled'`)) {
const [key, relativePath] = line.split('|');
if (!/^[0-9a-f-]{36}$/i.test(key)) continue;
let bytes: Uint8Array;
try { bytes = readFileSync(join(root, 'objects', `${key}.bin`)); } catch { continue; }
const reports: Array<{ path: string; bytes: Uint8Array }> = [];
if (bytes[0] === 0x50 && bytes[1] === 0x4b) {
const paths = inventoryZipArchive(bytes).entries.filter(entry => entry.kind === 'file' && /(^|\/)gpreport\.xml$/i.test(entry.relativePath)).map(entry => entry.relativePath);
if (paths.length) for (const file of readVerifiedZipFiles(bytes, paths, undefined, 256 * 1024 * 1024)) reports.push({ path: file.entry.relativePath, bytes: file.bytes });
} else if (/(^|[\\/])gpreport\.xml$/i.test(relativePath)) reports.push({ path: relativePath, bytes });
for (const report of reports) {
const folder = report.path.match(/\{[0-9A-Fa-f-]{36}\}/)?.[0];
if (folder) folderRole.set(folder.toUpperCase(), roleFromReport(decode(report.bytes)));
}
}
const templateRoles = new Map<string, string | undefined>();
for (const baselineId of new Set(evaluations.flatMap(item => item.results.map((result: any) => result.baselineId)))) {
const response = await get<{ records?: any[] }>(`/reference-data/microsoft-security-baselines/${encodeURIComponent(String(baselineId))}/analysis`);
for (const record of response.records ?? [response]) for (const setting of (record.analysis ?? record).settings ?? []) {
templateRoles.set(setting.id, baselineTemplateContext([...new Set<string>((setting.template?.names ?? []).map((name: any) => name.value))])?.role);
}
}
const statusOf = (comparison: any) => policyComparisonDisposition(comparison) === 'not-configured' ? 'Not configured'
: policyComparisonDisposition(comparison) === 'misconfigured' ? 'Fail'
: comparison.baselineVariantAssessment?.decision === 'all-pass' && comparison.classification === 'configured_match' ? 'Pass' : 'Review required';
const rank: Record<string, number> = { ...policyWorkVerdictPriority, 'No effect': -1, 'Not applicable': -2 };
const top = (items: string[]) => items.reduce((left, right) => (rank[right] ?? 0) > (rank[left] ?? 0) ? right : left);
// Per setting: comparison relations grouped by recognized template role (upper bound of role dependence).
const relationsByTemplateRole = new Map<string, Map<string, Set<string>>>();
const settings = new Map<string, { roles: Set<Role>; current: string[]; simulated: string[] }>();
let variants = 0, variantsWithoutRole = 0;
for (const evaluation of evaluations) {
const aggregation = aggregationRecords.find(item => item.run.id === evaluation.run.aggregationRunId);
const observationRoles = new Map<string, Set<Role>>();
for (const state of aggregation?.states ?? []) for (const observation of state.observationIds) {
const roles = observationRoles.get(observation) ?? new Set<Role>();
for (const instance of state.backupInstanceIds) roles.add(folderRole.get(instanceFolder.get(instance) ?? '')?.role ?? 'unknown');
observationRoles.set(observation, roles);
}
for (const comparison of evaluation.results) {
const key = JSON.stringify([evaluation.run.aggregationRunId, comparison.identity.scope, comparison.identity.id]);
const roles = new Set<Role>(comparison.observationIds.flatMap((id: string) => [...(observationRoles.get(id) ?? [])]));
const row = settings.get(key) ?? { roles: new Set<Role>(), current: [], simulated: [] };
roles.forEach(role => row.roles.add(role));
const current = statusOf(comparison);
let simulated = current;
const byRole = relationsByTemplateRole.get(key) ?? new Map<string, Set<string>>();
for (const variant of comparison.baselineVariantAssessment?.variants ?? []) {
variants++;
const templateRole = templateRoles.get(variant.settingId);
if (!templateRole) variantsWithoutRole++;
const relations = byRole.get(templateRole ?? 'unrecognized') ?? new Set<string>();
relations.add(variant.comparator.relation);
byRole.set(templateRole ?? 'unrecognized', relations);
}
relationsByTemplateRole.set(key, byRole);
if (current !== 'Not configured' && roles.size === 1 && roles.has('unlinked')) simulated = 'No effect';
else if (current !== 'Not configured' && roles.size === 1 && roles.has('Domain Controller') && comparison.baselineVariantAssessment) {
// Unknown template roles are kept: an unrecognized template could still apply.
const kept = comparison.baselineVariantAssessment.variants.filter((variant: any) => {
const role = templateRoles.get(variant.settingId);
return role === undefined || role === 'Domain Controller';
});
simulated = !kept.length ? 'Not applicable'
: kept.every((variant: any) => variant.comparator.relation === 'match') ? 'Pass'
: kept.every((variant: any) => variant.comparator.relation === 'different') ? 'Fail' : 'Review required';
}
row.current.push(current);
row.simulated.push(simulated);
settings.set(key, row);
}
}
const rows = [...settings.entries()].map(([key, row]) => {
const real = row.simulated.filter(status => status !== 'Not applicable');
// A Fail is role-dependent when every template of at least one role matches (or it has
// matching templates of an unrecognized role): the verdict could change with the target role.
const byRole = relationsByTemplateRole.get(key) ?? new Map<string, Set<string>>();
const roleMatches = [...byRole.entries()].filter(([role, relations]) => role !== 'unrecognized'
&& relations.size === 1 && relations.has('match')).length;
const unrecognizedMatch = byRole.get('unrecognized')?.has('match') ?? false;
const dependence = roleMatches ? 'role-dependent (a recognized role matches)'
: unrecognizedMatch ? 'possibly role-dependent (only unrecognized templates match)'
: 'role-independent (no template matches)';
return { roles: row.roles, current: top(row.current), simulated: real.length ? top(real) : 'Not applicable', dependence };
});
const count = (values: string[]) => values.reduce<Record<string, number>>((result, value) => (result[value] = (result[value] ?? 0) + 1, result), {});
const changes = count(rows.filter(row => row.current !== row.simulated).map(row => `${row.current} -> ${row.simulated}`));
const gpoRoles = count([...new Set(instanceFolder.values())].map(folder => folderRole.get(folder)?.role ?? 'no GPReport.xml'));
const failRoles = count(rows.filter(row => row.current === 'Fail').map(row => [...row.roles].sort().join('+') || 'no customer source'));
console.log('=== GPO role impact (numbers only) ===');
console.log('GPO backups by derived role ', JSON.stringify(gpoRoles));
console.log('GPO backups with a WMI filter ', [...folderRole.values()].filter(item => item.wmiFilter).length);
console.log('Settings (rolled up) ', rows.length);
console.log('Current verdicts ', JSON.stringify(count(rows.map(row => row.current))));
console.log('Verdicts with target role ', JSON.stringify(count(rows.map(row => row.simulated))));
console.log('Changed settings ', JSON.stringify(changes));
console.log('Current Fail by contributing roles ', JSON.stringify(failRoles));
console.log('Baseline variants without template role', `${variantsWithoutRole} of ${variants}`);
console.log('Current Fail by role dependence ', JSON.stringify(count(rows.filter(row => row.current === 'Fail').map(row => row.dependence))));
}
const argument = process.argv[2];
if (!argument) console.log('Usage: npx vite-node scripts/gpo-role-impact.ts --list | <project-id>');
(argument === '--list' ? list() : argument ? measure(argument) : Promise.resolve())
.catch((error: unknown) => {
// Never print an error message: it could contain source content.
console.log('Stopped:', error instanceof Error ? error.constructor.name : typeof error);
process.exitCode = 1;
});