213 lines
14 KiB
TypeScript
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;
|
|
});
|