Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion scanner/dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
<input type="file" id="file" accept="application/json,.json" aria-label="Upload findings file" style="margin-left:12px">
</header>
<p id="findings-summary" class="sr-only" role="status" aria-live="polite" aria-atomic="true"></p>
<span id="ext-link-desc" class="sr-only">opens in a new tab</span>
<main id="app" tabindex="-1"></main>

<dialog id="detail" aria-labelledby="dlg-title"></dialog>
Expand Down Expand Up @@ -281,7 +282,8 @@ <h1>Dashboard</h1>
function openDetail(f){
lastFocus = document.activeElement;
const s = String(f.severity||'INFO').toUpperCase();
const refs = (f.references||[]).map(r=>`<a href="${esc(safeUrl(r))}" target="_blank" rel="noopener">${esc(r)}</a>`).join('<br>');
const extIcon = `<svg aria-hidden="true" focusable="false" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-left:4px;vertical-align:-2px"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>`;
const refs = (f.references||[]).map(r=>`<a href="${esc(safeUrl(r))}" target="_blank" rel="noopener" aria-describedby="ext-link-desc">${esc(r)}${extIcon}</a>`).join('<br>');
const owasp = (f.owasp||[]).join(', ');
const cwe = (f.cwe||[]).join(', ');
const d = document.getElementById('detail');
Expand Down
45 changes: 45 additions & 0 deletions tests/test_dashboard_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,3 +302,48 @@ def test_dashboard_search_escape_clears_input():
assert "e.key === 'Escape'" in html
assert "query = '';" in html
assert "render();" in html

def test_dashboard_external_links_have_accessible_visual_indicator():
"""External links opening in new tabs must have both an assistive warning and a visual indicator (WCAG G201)."""
from html.parser import HTMLParser
import re

html = dashboard_index_path().read_text(encoding="utf-8")

# Verify the global description element exists
assert '<span id="ext-link-desc" class="sr-only">opens in a new tab</span>' in html

refs_markup = re.search(
r"const refs\s*=\s*\(f\.references\|\|\[\]\)\.map\(r=>`(?P<markup>.*?)`\)\.join\('<br>'\);",
html,
flags=re.DOTALL,
)
assert refs_markup is not None

class _LinkAttributeParser(HTMLParser):
def __init__(self) -> None:
super().__init__()
self.links = []
self.svgs = []

def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag == "a":
self.links.append(dict(attrs))
if tag == "svg":
self.svgs.append(dict(attrs))

parser = _LinkAttributeParser()
# Inject a dummy href to make the template string parseable HTML
test_html = refs_markup.group("markup").replace('${esc(safeUrl(r))}', 'https://example.com').replace('${esc(r)}', 'Link').replace('${extIcon}', '<svg aria-hidden="true" focusable="false"></svg>')
parser.feed(test_html)

assert len(parser.links) == 1
link = parser.links[0]
assert link.get("target") == "_blank"
assert link.get("rel") == "noopener"
assert link.get("aria-describedby") == "ext-link-desc"

assert len(parser.svgs) >= 1
svg = parser.svgs[0]
assert svg.get("aria-hidden") == "true"
assert svg.get("focusable") == "false"
Loading