diff --git a/scanner/dashboard/console.html b/scanner/dashboard/console.html
index 7ec262af..9a6df66e 100644
--- a/scanner/dashboard/console.html
+++ b/scanner/dashboard/console.html
@@ -103,7 +103,7 @@
AppGuardrail Console
if(!r.ok)throw new Error("Request failed ("+r.status+").");
return r.json();
}
-function pill(n,color){return n>0?`${n}`:`0`;}
+function pill(n,color){return n>0?`${esc(n)}`:`0`;}
function scrollDetailIntoView(element){
if(window.matchMedia("(prefers-reduced-motion: reduce)").matches){
element.scrollIntoView();
@@ -125,15 +125,15 @@ AppGuardrail Console
["New since last scan",latest.new_blocking||0],
["Critical",c.CRITICAL||0],
["Scans stored",scans.length],
- ].map(([l,n])=>``).join("");
+ ].map(([l,n])=>``).join("");
const ord=[...scans].reverse();
const max=Math.max(1,...ord.map(s=>s.deploy_blocking||0));
$("#trend").innerHTML=ord.map(s=>{const h=Math.round(6+((s.deploy_blocking||0)/max)*54);
const col=(s.deploy_blocking||0)>0?"var(--crit)":"var(--ok)";
return ``;}).join("")||'No scans yet.';
- $("#history tbody").innerHTML=scans.map(s=>`
+ $("#history tbody").innerHTML=scans.map(s=>`
| ${esc(s.created_at)} | ${esc(s.repo||"—")} | ${esc((s.commit||"—").slice(0,10))} |
- ${s.total} | ${pill(s.deploy_blocking,"var(--crit)")} | ${pill(s.new_blocking,"var(--high)")} |
`).join("")||'| No scans. POST to /api/v1/scans from CI. |
';
+ ${esc(s.total)} | ${pill(s.deploy_blocking,"var(--crit)")} | ${pill(s.new_blocking,"var(--high)")} | `).join("")||'| No scans. POST to /api/v1/scans from CI. |
';
document.querySelectorAll("tr.scan").forEach(tr=>{
tr.onclick=()=>detail(tr.dataset.id,tr);
tr.addEventListener('keydown', e => { if(e.key === 'Enter' || e.key === ' ') { e.preventDefault(); detail(tr.dataset.id,tr); } });
diff --git a/tests/test_console_dashboard_xss_behavior.py b/tests/test_console_dashboard_xss_behavior.py
new file mode 100644
index 00000000..45dec8e7
--- /dev/null
+++ b/tests/test_console_dashboard_xss_behavior.py
@@ -0,0 +1,112 @@
+"""Executable DOM-sink regression for the standalone AppGuardrail console."""
+
+import re
+import subprocess
+
+from pathlib import Path
+
+
+CONSOLE_PATH = (
+ Path(__file__).resolve().parents[1] / "scanner" / "dashboard" / "console.html"
+)
+
+
+def _load_script_prefix() -> str:
+ """Return the shipped console script through `load` without detail boot code."""
+ html = CONSOLE_PATH.read_text(encoding="utf-8")
+ match = re.search(r"", html, re.IGNORECASE)
+ assert match is not None
+ prefix, separator, _ = match.group("script").partition(
+ "async function detail(id,tr){"
+ )
+ assert separator
+ return prefix
+
+
+def test_untrusted_scan_summary_values_are_escaped_before_innerhtml() -> None:
+ """Run the real `load` renderer and keep hostile scan metadata inert at HTML sinks."""
+ script = _load_script_prefix()
+ harness = r"""
+const elements = new Map();
+function element(name) {
+ return {
+ name,
+ innerHTML: '',
+ textContent: '',
+ classList: {
+ add() {},
+ remove() {},
+ contains() { return false; }
+ }
+ };
+}
+for (const selector of ['#msg','#app','#logout','#conn','#stats','#trend','#history tbody','#detail']) {
+ elements.set(selector, element(selector));
+}
+const document = {
+ querySelector(selector) { return elements.get(selector) || element(selector); },
+ querySelectorAll() { return []; },
+ addEventListener() {},
+ activeElement: null
+};
+const sessionStorage = { getItem() { return null; } };
+const window = { matchMedia() { return {matches: true}; } };
+const payload = {
+ id: '7" autofocus onfocus="globalThis.pwned=1',
+ created_at: '',
+ repo: '
',
+ commit: 'deadbeef',
+ total: '
',
+ deploy_blocking: 2,
+ new_blocking: 1,
+ severity_counts: {CRITICAL: ''}
+};
+async function fetch() {
+ return {
+ status: 200,
+ ok: true,
+ async json() { return {scans: [payload]}; }
+ };
+}
+"""
+ assertions = r"""
+(async () => {
+ await load();
+ const stats = elements.get('#stats').innerHTML;
+ const trend = elements.get('#trend').innerHTML;
+ const history = elements.get('#history tbody').innerHTML;
+ const combined = stats + trend + history;
+
+ for (const raw of [
+ payload.created_at,
+ payload.repo,
+ payload.total,
+ payload.severity_counts.CRITICAL,
+ `data-id="${payload.id}"`
+ ]) {
+ if (combined.includes(raw)) throw new Error(`raw hostile value reached innerHTML: ${raw}`);
+ }
+ if (!stats.includes('<svg onload="globalThis.pwned=5"></svg>')) {
+ throw new Error('critical summary payload was not HTML-escaped');
+ }
+ if (!trend.includes('<svg onload="globalThis.pwned=2"></svg>')) {
+ throw new Error('trend timestamp payload was not HTML-escaped');
+ }
+ if (!history.includes('<img src=x onerror="globalThis.pwned=4">')) {
+ throw new Error('scan total payload was not HTML-escaped');
+ }
+ if (!history.includes('data-id="7" autofocus onfocus="globalThis.pwned=1"')) {
+ throw new Error('scan id did not remain inside the quoted data-id attribute');
+ }
+ if (globalThis.pwned !== undefined) throw new Error('hostile payload executed');
+})().catch(error => { console.error(error); process.exit(1); });
+"""
+
+ completed = subprocess.run(
+ ["node", "-e", harness + script + assertions],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+
+ assert completed.returncode == 0, completed.stderr