167 lines
7.1 KiB
TypeScript
167 lines
7.1 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
parseSecurityTemplateInf,
|
|
reduceSecurityTemplate
|
|
} from '../../server/gpo/security-template-parser';
|
|
import type { VerifiedZipFile } from '../../server/gpo/zip-inventory';
|
|
import { ImportError } from '../../src/shared/errors';
|
|
|
|
function verifiedFile(content: string | Uint8Array): VerifiedZipFile {
|
|
const bytes = typeof content === 'string' ? new TextEncoder().encode(content) : content;
|
|
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
return {
|
|
entry: {
|
|
index: 1,
|
|
relativePath: 'synthetic/DomainSysvol/GPO/Machine/Microsoft/Windows NT/SecEdit/GptTmpl.inf',
|
|
kind: 'file',
|
|
compressionMethod: 8,
|
|
compressedBytes: bytes.byteLength,
|
|
uncompressedBytes: bytes.byteLength,
|
|
crc32: '00000000',
|
|
sha256
|
|
},
|
|
bytes
|
|
};
|
|
}
|
|
|
|
function context() {
|
|
return { backupInstanceId: 'backup-synthetic', sourceArtifactId: 'source-synthetic', artifactId: 'entry-synthetic' };
|
|
}
|
|
|
|
describe('Security Template parser and reducer', () => {
|
|
it('keeps section semantics separate and emits typed observations with line provenance', () => {
|
|
const document = parseSecurityTemplateInf(verifiedFile([
|
|
'[Unicode]',
|
|
'Unicode=yes',
|
|
'[System Access]',
|
|
'MinimumPasswordLength = 14',
|
|
'[Privilege Rights]',
|
|
'SeDenyNetworkLogonRight = *S-1-5-32-546,*S-1-5-32-544',
|
|
'[Registry Values]',
|
|
'MACHINE\\Software\\Synthetic\\Setting=4,1',
|
|
'[Version]',
|
|
'signature="$CHICAGO$"'
|
|
].join('\r\n')), 'computer');
|
|
|
|
expect(document).toMatchObject({ status: 'complete', scope: 'computer' });
|
|
expect(reduceSecurityTemplate(document, context())).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
identity: expect.objectContaining({ section: 'System Access', key: 'MinimumPasswordLength' }),
|
|
values: [{ kind: 'scalar', raw: '14' }],
|
|
sourceLines: [4],
|
|
effectiveState: 'unknown'
|
|
}),
|
|
expect.objectContaining({
|
|
identity: expect.objectContaining({ section: 'Privilege Rights', key: 'SeDenyNetworkLogonRight' }),
|
|
values: [{ kind: 'principal-set', principals: ['*S-1-5-32-546', '*S-1-5-32-544'] }]
|
|
}),
|
|
expect.objectContaining({
|
|
identity: expect.objectContaining({ section: 'Registry Values' }),
|
|
values: [{ kind: 'registry', typeCode: 4, rawData: '1' }]
|
|
})
|
|
]));
|
|
});
|
|
|
|
it('marks differing duplicate instructions as conflicted instead of choosing a winner', () => {
|
|
const document = parseSecurityTemplateInf(verifiedFile([
|
|
'[System Access]',
|
|
'PasswordComplexity=1',
|
|
'passwordcomplexity=0'
|
|
].join('\n')), 'computer');
|
|
expect(reduceSecurityTemplate(document, context())).toEqual([
|
|
expect.objectContaining({
|
|
state: 'conflicted',
|
|
values: [{ kind: 'scalar', raw: '1' }, { kind: 'scalar', raw: '0' }],
|
|
sourceLines: [2, 3],
|
|
diagnosticCodes: ['gpo-security-template-value-conflict']
|
|
})
|
|
]);
|
|
});
|
|
|
|
it('retains supported observations but marks documents with unknown sections partial', () => {
|
|
const document = parseSecurityTemplateInf(verifiedFile([
|
|
'[System Access]',
|
|
'MinimumPasswordLength=14',
|
|
'[File Security]',
|
|
'synthetic-path=synthetic-acl'
|
|
].join('\n')), 'computer');
|
|
expect(document).toMatchObject({
|
|
status: 'partial',
|
|
diagnosticCodes: ['gpo-security-template-section-unsupported']
|
|
});
|
|
expect(reduceSecurityTemplate(document, context())).toHaveLength(1);
|
|
});
|
|
|
|
it('skips unsupported section syntax before validating supported key/value entries', () => {
|
|
const document = parseSecurityTemplateInf(verifiedFile([
|
|
'[System Access]',
|
|
'MinimumPasswordLength=14',
|
|
'[Service General Setting]',
|
|
'"SyntheticService",2,""',
|
|
'"SyntheticDisabledService",4,""',
|
|
'[Privilege Rights]',
|
|
'SeNetworkLogonRight=*S-1-5-32-544',
|
|
'SeEnableDelegationPrivilege=',
|
|
'[Registry Values]',
|
|
'MACHINE\\Software\\Synthetic\\Mode=4,1',
|
|
'[Kerberos Policy]',
|
|
'MaxTicketAge=10'
|
|
].join('\r\n')), 'computer');
|
|
expect(document.status).toBe('partial');
|
|
expect(document.diagnosticCodes).toEqual(['gpo-security-template-section-unsupported']);
|
|
const observations = reduceSecurityTemplate(document, context());
|
|
expect(observations).toHaveLength(5);
|
|
expect(observations.map(item => item.sourceLines)).toEqual([[2], [7], [8], [10], [12]]);
|
|
expect(observations[2].values).toEqual([{ kind: 'principal-set', principals: [] }]);
|
|
expect(observations.every(item => item.sourceSha256 === document.sourceSha256)).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
['missing separator', '[System Access]\nmalformed entry', 'gpo-security-template-entry-invalid'],
|
|
['empty key', '[System Access]\n =1', 'gpo-security-template-entry-invalid'],
|
|
['oversized key', `[System Access]\n${'x'.repeat(1025)}=1`, 'gpo-security-template-entry-invalid'],
|
|
['invalid registry type', '[Registry Values]\nMACHINE\\Synthetic=not-a-type', 'gpo-security-template-registry-value-invalid']
|
|
])('retains valid instructions around a defective entry: %s', (_name, malformed, diagnostic) => {
|
|
const document = parseSecurityTemplateInf(verifiedFile([
|
|
'[System Access]', 'MinimumPasswordLength=14', malformed,
|
|
'[Privilege Rights]', 'SeNetworkLogonRight=*S-1-5-32-544'
|
|
].join('\n')), 'computer');
|
|
expect(document.status).toBe('partial');
|
|
expect(document.diagnosticCodes).toEqual([diagnostic]);
|
|
expect(document.instructions).toHaveLength(2);
|
|
expect(reduceSecurityTemplate(document, context()).map(item => item.sourceLines)).toEqual([[2], [6]]);
|
|
});
|
|
|
|
it('reports partial with no observations when the only supported entry is malformed', () => {
|
|
const document = parseSecurityTemplateInf(verifiedFile('[Registry Values]\nMACHINE\\Synthetic=not-a-type'), 'computer');
|
|
expect(document).toMatchObject({
|
|
status: 'partial',
|
|
instructions: [],
|
|
diagnosticCodes: ['gpo-security-template-registry-value-invalid']
|
|
});
|
|
expect(reduceSecurityTemplate(document, context())).toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
['unclosed section', '[System Access'],
|
|
['empty section', '[]'],
|
|
['oversized section', `[${'x'.repeat(257)}]`],
|
|
['field limit', `Mode=${'x'.repeat(4096)}`]
|
|
])('keeps structural ambiguity and resource violations invalid: %s', (_name, malformed) => {
|
|
const document = parseSecurityTemplateInf(verifiedFile([
|
|
'[System Access]', 'MinimumPasswordLength=14', malformed, 'OtherSetting=1'
|
|
].join('\n')), 'computer');
|
|
expect(document.status).toBe('invalid');
|
|
expect(document.instructions).toEqual([]);
|
|
expect(reduceSecurityTemplate(document, context())).toEqual([]);
|
|
});
|
|
|
|
it('accepts BOM-marked UTF-16LE and rejects undecodable bytes', () => {
|
|
const encoded = Buffer.from('\ufeff[System Access]\r\nMinimumPasswordLength=14', 'utf16le');
|
|
expect(parseSecurityTemplateInf(verifiedFile(encoded), 'computer').status).toBe('complete');
|
|
expect(() => parseSecurityTemplateInf(verifiedFile(new Uint8Array([0xff, 0xfe, 0x00])), 'computer'))
|
|
.toThrowError(ImportError);
|
|
});
|
|
});
|