fix: reject native-hidden semantic evidence

This commit is contained in:
2026-09-02 02:54:24 +03:00
parent c16c4c57da
commit 3638a0beef
2 changed files with 62 additions and 3 deletions

View File

@@ -145,13 +145,19 @@ const excludedSemanticContainers = new Set(['SCRIPT', 'STYLE', 'TEMPLATE', 'NOSC
const isVisibleSemanticNode = (node, appRoot) => {
if (!node.isConnected || !appRoot.contains(node)) return false;
let element = node.nodeType === 1 ? node : node.parentElement;
const originElement = node.nodeType === 1 ? node : node.parentElement;
let element = originElement;
while (element !== null) {
if (excludedSemanticContainers.has(element.tagName) || element.hasAttribute('hidden') ||
element.hasAttribute('inert') || element.getAttribute('aria-hidden') === 'true' ||
element.hasAttribute('inert') || element.getAttribute('aria-hidden')?.toLowerCase() === 'true' ||
(element.tagName === 'DIALOG' && !element.hasAttribute('open')) ||
element.style?.display === 'none' || element.style?.visibility === 'hidden') {
return false;
}
if (element.tagName === 'DETAILS' && !element.hasAttribute('open') && originElement !== element) {
const summary = [...element.children].find((child) => child.tagName === 'SUMMARY');
if (summary === undefined || (originElement !== summary && !summary.contains(originElement))) return false;
}
if (element === appRoot) return true;
element = element.parentElement;
}

View File

@@ -36,6 +36,14 @@ const clientJavaScript = 'export {};\n';
const clientStylesheet = 'body { margin: 0; }\n';
const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex');
const wrapAppContents = (html: string, opening: string, closing: string): string => {
const appOpening = '<div id="app">';
const appContentStart = html.indexOf(appOpening) + appOpening.length;
const snapshotStart = html.indexOf('<script id="portfolio-inputs"');
const appContentEnd = html.lastIndexOf('</div>', snapshotStart);
return `${html.slice(0, appContentStart)}${opening}${html.slice(appContentStart, appContentEnd)}${closing}${html.slice(appContentEnd)}`;
};
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
@@ -285,10 +293,55 @@ describe('artifact verification', () => {
return `${html.slice(0, snapshotAt).replaceAll('Secure self-hosted platform and lifecycle', 'Removed evidence')}${html.slice(snapshotAt)}`;
},
},
{
name: 'all semantic SSR inside a closed dialog',
mutate: (html: string) => wrapAppContents(html, '<dialog>', '</dialog>'),
},
{
name: 'required evidence outside the summary of closed details',
mutate: (html: string, inputs: Readonly<ProductionInputs>) => {
const evidence = inputs.content.infrastructure.bands[1]!.summary;
return html.replace(evidence, `<details><summary>Infrastructure evidence</summary><span>${evidence}</span></details>`);
},
},
{
name: 'mixed-case true aria-hidden navigation',
mutate: (html: string) => html.replace(
'<nav class="site-navigation"',
'<nav aria-hidden="TRUE" class="site-navigation"',
),
},
])('rejects $name as non-visible semantic evidence', async ({ mutate }) => {
const { directory, html, inputs, contract } = await writeValidArtifacts();
await writeFile(join(directory, 'index.html'), mutate(html));
await writeFile(join(directory, 'index.html'), mutate(html, inputs));
await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/visible semantic|visible h1|visible navigation|visible section|connected/i);
});
it.each([
{
name: 'semantic SSR inside an open dialog',
mutate: (html: string, _inputs: Readonly<ProductionInputs>) =>
wrapAppContents(html, '<dialog open>', '</dialog>'),
},
{
name: 'required evidence inside open details',
mutate: (html: string, inputs: Readonly<ProductionInputs>) => {
const evidence = inputs.content.infrastructure.bands[1]!.summary;
return html.replace(evidence, `<details open><summary>Infrastructure evidence</summary><span>${evidence}</span></details>`);
},
},
{
name: 'required evidence inside the active summary of closed details',
mutate: (html: string, inputs: Readonly<ProductionInputs>) => {
const evidence = inputs.content.infrastructure.bands[1]!.summary;
return html.replace(evidence, `<details><summary>${evidence}</summary><span>Collapsed evidence</span></details>`);
},
},
])('accepts $name as visible semantic evidence', async ({ mutate }) => {
const { directory, html, inputs, contract } = await writeValidArtifacts();
await writeFile(join(directory, 'index.html'), mutate(html, inputs));
await expect(verifyArtifactDirectory(directory, inputs, contract)).resolves.toBeUndefined();
});
});