Skip to content

Commit c8d08f5

Browse files
Copilotmgifford
andcommitted
Add M-24-08 digital accessibility statement detection and reporting
Co-authored-by: mgifford <116832+mgifford@users.noreply.github.com> Agent-Logs-Url: https://github.com/mgifford/daily-dap/sessions/a17faf0b-84b1-4938-b6b9-062ae933029b
1 parent 1baa91e commit c8d08f5

File tree

5 files changed

+563
-4
lines changed

5 files changed

+563
-4
lines changed

src/cli/run-daily-scan.js

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { buildHistoryIndex } from '../publish/build-history-index.js';
2323
import { writeCommittedSnapshot } from '../publish/archive-writer.js';
2424
import { buildArtifactManifest } from '../publish/artifact-manifest.js';
2525
import { buildFailureReport, writeFailureSnapshot } from '../publish/failure-report.js';
26+
import { checkAccessibilityStatements } from '../scanners/accessibility-statement-checker.js';
2627

2728
function parseArgs(argv) {
2829
const args = {
@@ -326,6 +327,27 @@ function createLiveScannerRunners() {
326327
};
327328
}
328329

330+
/**
331+
* Deterministic mock for accessibility statement checks.
332+
* Approximately two-thirds of domains are assigned a statement based on a
333+
* character-sum hash so results are stable across multiple runs of the same
334+
* URL list.
335+
*
336+
* @param {string} baseUrl
337+
* @returns {Promise<{ has_statement: boolean, statement_url: string|null }>}
338+
*/
339+
function mockAccessibilityStatementCheck(baseUrl) {
340+
let sum = 0;
341+
for (const char of baseUrl) {
342+
sum += char.charCodeAt(0);
343+
}
344+
const hasStatement = sum % 3 !== 0;
345+
return Promise.resolve({
346+
has_statement: hasStatement,
347+
statement_url: hasStatement ? `${baseUrl}/accessibility` : null
348+
});
349+
}
350+
329351
async function loadHistoryRecords(repoRoot, lookbackDays) {
330352
const historyPath = path.join(repoRoot, 'docs', 'reports', 'history.json');
331353
let historyPayload;
@@ -524,6 +546,22 @@ export async function runDailyScan(inputArgs = parseArgs(process.argv)) {
524546

525547
logStageComplete('AGGREGATION');
526548

549+
logStageStart('ACCESSIBILITY_STATEMENTS');
550+
551+
const accessibilityStatementRunner =
552+
args.scanMode === 'mock'
553+
? { runImpl: mockAccessibilityStatementCheck }
554+
: {};
555+
const accessibilityStatements = await checkAccessibilityStatements(
556+
scanExecution.results,
557+
accessibilityStatementRunner
558+
);
559+
logProgress('ACCESSIBILITY_STATEMENTS', 'Accessibility statement checks complete', {
560+
domainsChecked: Object.keys(accessibilityStatements).length
561+
});
562+
563+
logStageComplete('ACCESSIBILITY_STATEMENTS');
564+
527565
logStageStart('HISTORY_LOADING', {
528566
lookbackDays: runtimeConfig.scan.history_lookback_days
529567
});
@@ -557,7 +595,8 @@ export async function runDailyScan(inputArgs = parseArgs(process.argv)) {
557595
historyWindow,
558596
urlResults: scanExecution.results,
559597
performanceImpact,
560-
dotgovLookup
598+
dotgovLookup,
599+
accessibilityStatements
561600
});
562601

563602
report.slow_risk_summary = slowRisk.summary;

src/publish/build-daily-report.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { buildTechSummary } from '../scanners/tech-detector.js';
2+
import { buildAccessibilityStatementSummary } from '../scanners/accessibility-statement-checker.js';
23
import { lookupDomain, hostnameFromUrl } from '../data/dotgov-lookup.js';
34

45
function coerceScore(value) {
@@ -52,7 +53,8 @@ export function buildDailyReport({
5253
historyWindow,
5354
urlResults = [],
5455
performanceImpact = null,
55-
dotgovLookup = null
56+
dotgovLookup = null,
57+
accessibilityStatements = null
5658
}) {
5759
const succeeded = urlResults.filter((result) => result?.scan_status === 'success').length;
5860
const failed = urlResults.filter((result) => result?.scan_status === 'failed').length;
@@ -77,6 +79,9 @@ export function buildDailyReport({
7779

7880
const topUrls = normalizeTopUrls(urlResults, dotgovLookup);
7981
const techSummary = buildTechSummary(urlResults);
82+
techSummary.accessibility_statement_summary = buildAccessibilityStatementSummary(
83+
accessibilityStatements ?? {}
84+
);
8085

8186
const sourceDataDate = urlResults.reduce((latest, result) => {
8287
const candidate = result?.source_date;

src/publish/render-pages.js

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1930,12 +1930,18 @@ function renderTechSummarySection(report) {
19301930
uswds_version_urls = {},
19311931
total_scanned = 0,
19321932
third_party_service_counts = {},
1933-
third_party_service_urls = {}
1933+
third_party_service_urls = {},
1934+
accessibility_statement_summary = null
19341935
} = summary;
19351936
const cmsEntries = Object.entries(cms_counts).sort((a, b) => b[1] - a[1]);
19361937
const thirdPartyEntries = Object.entries(third_party_service_counts).sort((a, b) => b[1] - a[1]);
19371938

1938-
if (cmsEntries.length === 0 && uswds_count === 0 && thirdPartyEntries.length === 0) {
1939+
if (
1940+
cmsEntries.length === 0 &&
1941+
uswds_count === 0 &&
1942+
thirdPartyEntries.length === 0 &&
1943+
!accessibility_statement_summary
1944+
) {
19391945
return '';
19401946
}
19411947

@@ -1999,6 +2005,64 @@ function renderTechSummarySection(report) {
19992005
})()
20002006
: '';
20012007

2008+
const accessibilityStatementSection = (() => {
2009+
const as = accessibility_statement_summary;
2010+
if (!as || as.domains_checked === 0) {
2011+
return '';
2012+
}
2013+
2014+
const { domains_checked, domains_with_statement, statement_rate_percent } = as;
2015+
const withoutStatement = as.domains_without_statement ?? [];
2016+
const statementUrls = as.statement_urls ?? [];
2017+
2018+
let statusClass;
2019+
if (statement_rate_percent >= 80) {
2020+
statusClass = 'score-good';
2021+
} else if (statement_rate_percent >= 50) {
2022+
statusClass = 'score-moderate';
2023+
} else {
2024+
statusClass = 'score-poor';
2025+
}
2026+
2027+
const statementUrlRows = statementUrls
2028+
.map((url) => {
2029+
let hostname;
2030+
try {
2031+
hostname = new URL(url).hostname;
2032+
} catch {
2033+
hostname = url;
2034+
}
2035+
return `<tr>
2036+
<td data-label="Domain">${escapeHtml(hostname)}</td>
2037+
<td data-label="Status"><span class="score-good">&#10003; Found</span></td>
2038+
<td data-label="Statement URL"><a href="${escapeHtml(url)}" target="_blank" rel="noreferrer">${escapeHtml(url)}</a></td>
2039+
</tr>`;
2040+
})
2041+
.join('\n');
2042+
2043+
const missingRows = withoutStatement
2044+
.map((hostname) => `<tr>
2045+
<td data-label="Domain">${escapeHtml(hostname)}</td>
2046+
<td data-label="Status"><span class="score-poor">&#10007; Not found</span></td>
2047+
<td data-label="Statement URL"></td>
2048+
</tr>`)
2049+
.join('\n');
2050+
2051+
return `
2052+
<h3 id="accessibility-statements-heading">Accessibility Statements (M-24-08)${renderAnchorLink('accessibility-statements-heading', 'Accessibility Statements')}</h3>
2053+
<p>OMB Memorandum <a href="https://www.whitehouse.gov/wp-content/uploads/2023/12/M-24-08-Strengthening-Digital-Accessibility-and-the-Management-of-Section-508-of-the-Rehabilitation-Act.pdf" target="_blank" rel="noreferrer">M-24-08</a> requires each federal agency to publish a digital accessibility statement that includes contact information for accessibility issues, known limitations, and a link to the agency Section 508 program page.</p>
2054+
<p><strong class="${statusClass}">${domains_with_statement} of ${domains_checked} domain${domains_checked !== 1 ? 's' : ''} (${statement_rate_percent}%)</strong> checked have a detectable accessibility statement at a standard URL path.</p>
2055+
${wrapTable(`<table>
2056+
<caption>Accessibility statement detection results for ${domains_checked} checked domain${domains_checked !== 1 ? 's' : ''}</caption>
2057+
<thead><tr>
2058+
<th scope="col">Domain</th>
2059+
<th scope="col">Status</th>
2060+
<th scope="col">Statement URL</th>
2061+
</tr></thead>
2062+
<tbody>${statementUrlRows}${missingRows}</tbody>
2063+
</table>`)}`;
2064+
})();
2065+
20022066
return `
20032067
<section aria-labelledby="tech-summary-heading">
20042068
<h2 id="tech-summary-heading">Detected Technologies${renderAnchorLink('tech-summary-heading', 'Detected Technologies')}</h2>
@@ -2011,6 +2075,7 @@ function renderTechSummarySection(report) {
20112075
<p>USWDS detected on <strong>${uswds_count}</strong> of <strong>${total_scanned}</strong> scanned URL${total_scanned !== 1 ? 's' : ''}${total_scanned > 0 ? ` (${Math.round((uswds_count / total_scanned) * 100)}%)` : ''}.</p>
20122076
${uswdsVersionList}
20132077
${thirdPartySection}
2078+
${accessibilityStatementSection}
20142079
</section>`;
20152080
}
20162081

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
/**
2+
* Accessibility Statement Checker
3+
*
4+
* Detects whether federal websites publish a digital accessibility statement
5+
* as required by OMB Memorandum M-24-08 "Strengthening Digital Accessibility
6+
* and the Management of Section 508 of the Rehabilitation Act" (December 2023).
7+
*
8+
* M-24-08 requires each federal agency to publish an accessibility statement
9+
* that includes:
10+
* - Contact information for reporting accessibility problems
11+
* - Known accessibility limitations and alternatives
12+
* - Process for requesting accessible formats or alternatives
13+
* - A reference to the agency formal complaints process
14+
* - A date of last review
15+
* - A link to the agency Section 508 program page
16+
*
17+
* Detection works by probing common accessibility statement URL paths on each
18+
* unique domain in the scan results using lightweight HTTP HEAD requests.
19+
*
20+
* Paths checked (in order):
21+
* /accessibility
22+
* /accessibility-statement
23+
* /accessibility.html
24+
* /accessibility-statement.html
25+
* /about/accessibility
26+
* /section-508
27+
* /508
28+
*/
29+
30+
import https from 'node:https';
31+
import http from 'node:http';
32+
33+
/**
34+
* Common URL paths where federal accessibility statements are published.
35+
* Ordered by prevalence based on observed federal website patterns.
36+
*/
37+
export const ACCESSIBILITY_STATEMENT_PATHS = [
38+
'/accessibility',
39+
'/accessibility-statement',
40+
'/accessibility.html',
41+
'/accessibility-statement.html',
42+
'/about/accessibility',
43+
'/section-508',
44+
'/508'
45+
];
46+
47+
/**
48+
* Make a HEAD request to a URL and return true if the server responds with
49+
* a 2xx or 3xx status (the page exists or redirects to something that does).
50+
*
51+
* @param {string} urlString
52+
* @param {number} [timeoutMs=5000]
53+
* @returns {Promise<boolean>}
54+
*/
55+
function headRequest(urlString, timeoutMs = 5000) {
56+
return new Promise((resolve) => {
57+
try {
58+
const parsed = new URL(urlString);
59+
const client = parsed.protocol === 'https:' ? https : http;
60+
const options = {
61+
method: 'HEAD',
62+
hostname: parsed.hostname,
63+
port: parsed.port ? Number(parsed.port) : undefined,
64+
path: parsed.pathname + parsed.search,
65+
headers: {
66+
'User-Agent': 'daily-dap-accessibility-statement-checker/1.0'
67+
}
68+
};
69+
const req = client.request(options, (res) => {
70+
const code = res.statusCode ?? 0;
71+
// Accept 2xx (success) and 3xx (redirect – the path exists even if moved)
72+
resolve(code >= 200 && code < 400);
73+
});
74+
req.setTimeout(timeoutMs, () => {
75+
req.destroy();
76+
resolve(false);
77+
});
78+
req.on('error', () => resolve(false));
79+
req.end();
80+
} catch {
81+
resolve(false);
82+
}
83+
});
84+
}
85+
86+
/**
87+
* Check whether the website at baseUrl publishes an accessibility statement.
88+
*
89+
* Probes each path in ACCESSIBILITY_STATEMENT_PATHS in order and returns the
90+
* first URL that responds successfully. If none do, returns
91+
* `{ has_statement: false, statement_url: null }`.
92+
*
93+
* In test / mock mode pass `options.runImpl` to replace the live HEAD request
94+
* logic with a custom function:
95+
* `runImpl(baseUrl)` should return `{ has_statement, statement_url }`.
96+
*
97+
* @param {string} baseUrl - Scheme + host of the site (e.g. "https://example.gov")
98+
* @param {{ runImpl?: (baseUrl: string) => Promise<{has_statement: boolean, statement_url: string|null}> }} [options]
99+
* @returns {Promise<{ has_statement: boolean, statement_url: string|null }>}
100+
*/
101+
export async function checkAccessibilityStatement(baseUrl, options = {}) {
102+
const { runImpl } = options;
103+
if (typeof runImpl === 'function') {
104+
return runImpl(baseUrl);
105+
}
106+
107+
const parsed = new URL(baseUrl);
108+
const base = `${parsed.protocol}//${parsed.host}`;
109+
110+
for (const urlPath of ACCESSIBILITY_STATEMENT_PATHS) {
111+
const candidateUrl = `${base}${urlPath}`;
112+
// eslint-disable-next-line no-await-in-loop
113+
const exists = await headRequest(candidateUrl);
114+
if (exists) {
115+
return { has_statement: true, statement_url: candidateUrl };
116+
}
117+
}
118+
119+
return { has_statement: false, statement_url: null };
120+
}
121+
122+
/**
123+
* Check accessibility statements for all unique domains found in the URL results.
124+
*
125+
* Only domains from successfully-scanned URLs are checked. Each unique
126+
* hostname is checked exactly once regardless of how many scanned pages
127+
* belong to that domain.
128+
*
129+
* @param {Array<{ url?: string, scan_status: string }>} urlResults
130+
* @param {{ runImpl?: Function }} [options]
131+
* @returns {Promise<Record<string, { has_statement: boolean, statement_url: string|null }>>}
132+
*/
133+
export async function checkAccessibilityStatements(urlResults, options = {}) {
134+
// Collect unique hostname → baseUrl pairs from successfully-scanned URLs
135+
const domainMap = new Map();
136+
for (const result of urlResults ?? []) {
137+
if (result?.scan_status !== 'success' || !result?.url) {
138+
continue;
139+
}
140+
try {
141+
const parsed = new URL(result.url);
142+
if (!domainMap.has(parsed.host)) {
143+
domainMap.set(parsed.host, `${parsed.protocol}//${parsed.host}`);
144+
}
145+
} catch {
146+
// Skip malformed URLs
147+
}
148+
}
149+
150+
const statements = {};
151+
for (const [hostname, baseUrl] of domainMap) {
152+
// eslint-disable-next-line no-await-in-loop
153+
statements[hostname] = await checkAccessibilityStatement(baseUrl, options);
154+
}
155+
156+
return statements;
157+
}
158+
159+
/**
160+
* Build a summary object from accessibility statement check results.
161+
*
162+
* @param {Record<string, { has_statement: boolean, statement_url: string|null }>} statements
163+
* @returns {{
164+
* domains_checked: number,
165+
* domains_with_statement: number,
166+
* statement_rate_percent: number,
167+
* domains_without_statement: string[],
168+
* statement_urls: string[]
169+
* }}
170+
*/
171+
export function buildAccessibilityStatementSummary(statements) {
172+
const entries = Object.entries(statements ?? {});
173+
const withStatement = entries.filter(([, v]) => v.has_statement);
174+
const withoutStatement = entries
175+
.filter(([, v]) => !v.has_statement)
176+
.map(([hostname]) => hostname)
177+
.sort();
178+
const statementUrls = withStatement
179+
.map(([, v]) => v.statement_url)
180+
.filter(Boolean)
181+
.sort();
182+
const domainsChecked = entries.length;
183+
const domainsWithStatement = withStatement.length;
184+
185+
return {
186+
domains_checked: domainsChecked,
187+
domains_with_statement: domainsWithStatement,
188+
statement_rate_percent:
189+
domainsChecked > 0 ? Math.round((domainsWithStatement / domainsChecked) * 100) : 0,
190+
domains_without_statement: withoutStatement,
191+
statement_urls: statementUrls
192+
};
193+
}

0 commit comments

Comments
 (0)