{
  "exported_at": "2026-09-30T10:02:05.468687Z",
  "kind": "repo",
  "target": "ivjames/qa-ksink-site@bug-lab vs main",
  "run_id": "c51130a58f654aba9f61a381291cc7ae",
  "status": "done",
  "stats": {
    "pages": 10,
    "files": 25,
    "changed_files": 10,
    "findings_by_severity": {
      "serious": 9,
      "moderate": 11,
      "critical": 1,
      "minor": 1
    },
    "duration_secs": 89.25,
    "tokens": {
      "input": 49708,
      "output": 7834,
      "cache_read": 9457,
      "cache_write": 1351
    },
    "tokens_by_model": {
      "claude-haiku-4-5": {
        "input": 9507,
        "output": 828,
        "cache_read": 0,
        "cache_write": 0
      },
      "claude-sonnet-5": {
        "input": 40201,
        "output": 7006,
        "cache_read": 9457,
        "cache_write": 1351
      }
    },
    "estimated_cost_usd": 0.2472
  },
  "findings": [
    {
      "type": "finding",
      "id": 2865,
      "url": "backend/app/main.py",
      "pipeline": "code",
      "tier": 0,
      "rule": "code/sql-interpolation",
      "severity": "serious",
      "title": "SQL built by string interpolation",
      "detail": "The SQL text is assembled with interpolation/concatenation instead of bound parameters; injection risk if any value is user input.",
      "evidence": {
        "source": "backend/app/main.py",
        "line": 175,
        "snippet": "rows = conn.execute("
      }
    },
    {
      "type": "finding",
      "id": 2866,
      "url": "backend/app/main.py",
      "pipeline": "code",
      "tier": 0,
      "rule": "code/sql-interpolation",
      "severity": "serious",
      "title": "SQL built by string interpolation",
      "detail": "The SQL text is assembled with interpolation/concatenation instead of bound parameters; injection risk if any value is user input.",
      "evidence": {
        "source": "backend/app/main.py",
        "line": 191,
        "snippet": "rows = conn.execute("
      }
    },
    {
      "type": "finding",
      "id": 2867,
      "url": "backend/app/main.py",
      "pipeline": "code",
      "tier": 0,
      "rule": "code/sql-interpolation",
      "severity": "serious",
      "title": "SQL built by string interpolation",
      "detail": "The SQL text is assembled with interpolation/concatenation instead of bound parameters; injection risk if any value is user input.",
      "evidence": {
        "source": "backend/app/main.py",
        "line": 316,
        "snippet": "rows = conn.execute(f\"SELECT * FROM orders {where} ORDER BY id DESC\", params).fetchall()"
      }
    },
    {
      "type": "finding",
      "id": 2868,
      "url": "frontend/src/main.ts",
      "pipeline": "code",
      "tier": 0,
      "rule": "code/inner-html",
      "severity": "moderate",
      "title": "Raw HTML injection surface",
      "detail": "Assigning markup strings into the DOM is an XSS surface; verify every interpolated value is escaped.",
      "evidence": {
        "source": "frontend/src/main.ts",
        "line": 41,
        "snippet": "root.innerHTML = `"
      }
    },
    {
      "type": "finding",
      "id": 2869,
      "url": "frontend/src/main.ts",
      "pipeline": "code",
      "tier": 0,
      "rule": "code/inner-html",
      "severity": "moderate",
      "title": "Raw HTML injection surface",
      "detail": "Assigning markup strings into the DOM is an XSS surface; verify every interpolated value is escaped.",
      "evidence": {
        "source": "frontend/src/main.ts",
        "line": 67,
        "snippet": "navElement.innerHTML = ROUTES.filter((route) => !route.adminOnly || session !== null)"
      }
    },
    {
      "type": "finding",
      "id": 2870,
      "url": "frontend/src/main.ts",
      "pipeline": "code",
      "tier": 0,
      "rule": "code/inner-html",
      "severity": "moderate",
      "title": "Raw HTML injection surface",
      "detail": "Assigning markup strings into the DOM is an XSS surface; verify every interpolated value is escaped.",
      "evidence": {
        "source": "frontend/src/main.ts",
        "line": 82,
        "snippet": "sessionArea.innerHTML = session"
      }
    },
    {
      "type": "finding",
      "id": 2871,
      "url": "frontend/src/pages/dashboard.ts",
      "pipeline": "code",
      "tier": 0,
      "rule": "code/inner-html",
      "severity": "moderate",
      "title": "Raw HTML injection surface",
      "detail": "Assigning markup strings into the DOM is an XSS surface; verify every interpolated value is escaped.",
      "evidence": {
        "source": "frontend/src/pages/dashboard.ts",
        "line": 4,
        "snippet": "container.innerHTML = `"
      }
    },
    {
      "type": "finding",
      "id": 2872,
      "url": "frontend/src/pages/products.ts",
      "pipeline": "code",
      "tier": 0,
      "rule": "code/inner-html",
      "severity": "moderate",
      "title": "Raw HTML injection surface",
      "detail": "Assigning markup strings into the DOM is an XSS surface; verify every interpolated value is escaped.",
      "evidence": {
        "source": "frontend/src/pages/products.ts",
        "line": 36,
        "snippet": "container.innerHTML = `"
      }
    },
    {
      "type": "finding",
      "id": 2873,
      "url": "frontend/src/pages/products.ts",
      "pipeline": "code",
      "tier": 0,
      "rule": "code/inner-html",
      "severity": "moderate",
      "title": "Raw HTML injection surface",
      "detail": "Assigning markup strings into the DOM is an XSS surface; verify every interpolated value is escaped.",
      "evidence": {
        "source": "frontend/src/pages/products.ts",
        "line": 120,
        "snippet": "body.innerHTML = items"
      }
    },
    {
      "type": "finding",
      "id": 2874,
      "url": "frontend/src/ui.ts",
      "pipeline": "code",
      "tier": 0,
      "rule": "code/inner-html",
      "severity": "moderate",
      "title": "Raw HTML injection surface",
      "detail": "Assigning markup strings into the DOM is an XSS surface; verify every interpolated value is escaped.",
      "evidence": {
        "source": "frontend/src/ui.ts",
        "line": 43,
        "snippet": "overlay.innerHTML = `"
      }
    },
    {
      "type": "finding",
      "id": 2875,
      "url": "backend/app/auth.py",
      "pipeline": "security",
      "tier": 2,
      "rule": "security/authn",
      "severity": "critical",
      "title": "Unknown token falls back to admin user instead of 401",
      "detail": "When the token role doesn't match any DEMO_USERS entry, the function returns DEMO_USERS[0] (likely the admin user) instead of raising 401. Any authenticated-looking request with an unrecognized 'Bearer ' token gains admin privileges, enabling full privilege escalation past require_role checks.",
      "evidence": {
        "selector": "backend/app/auth.py:19",
        "snippet": "    return DEMO_USERS[0]",
        "note": "Base raised HTTPException(status_code=401, detail=\"Unknown token\") instead."
      }
    },
    {
      "type": "finding",
      "id": 2876,
      "url": "backend/app/auth.py",
      "pipeline": "security",
      "tier": 2,
      "rule": "security/authn",
      "severity": "serious",
      "title": "Prefix check weakened to accept any 'Bearer ' token",
      "detail": "The startswith check now only requires 'Bearer ' rather than the full TOKEN_PREFIX ('Bearer demo-token-'), so malformed tokens like 'Bearer admin' pass the initial check and, combined with the fallback bug, are granted admin access.",
      "evidence": {
        "selector": "backend/app/auth.py:13",
        "snippet": "    if not authorization or not authorization.startswith(\"Bearer \"):",
        "note": "Base checked startswith(TOKEN_PREFIX) which enforces the demo-token- format."
      }
    },
    {
      "type": "finding",
      "id": 2877,
      "url": "frontend/src/main.ts",
      "pipeline": "code",
      "tier": 2,
      "rule": "code/regression",
      "severity": "serious",
      "title": "Admin nav gate checks session existence, not role",
      "detail": "The filter for adminOnly routes was changed from checking session?.user.role === 'admin' to session !== null, so any authenticated non-admin user now sees and can click the Admin nav link. Restore the role check.",
      "evidence": {
        "selector": "frontend/src/main.ts:67",
        "snippet": "navElement.innerHTML = ROUTES.filter((route) => !route.adminOnly || session !== null)",
        "note": "base checked session?.user.role === 'admin'"
      }
    },
    {
      "type": "finding",
      "id": 2878,
      "url": "frontend/src/main.ts",
      "pipeline": "code",
      "tier": 2,
      "rule": "code/regression",
      "severity": "minor",
      "title": "Removed aria-label from main navigation",
      "detail": "The nav element's aria-label=\"Main navigation\" was dropped, reducing accessibility for screen reader users navigating the page.",
      "evidence": {
        "selector": "frontend/src/main.ts:50",
        "snippet": "<nav></nav>",
        "note": "base had <nav aria-label=\"Main navigation\"></nav>"
      }
    },
    {
      "type": "finding",
      "id": 2879,
      "url": "frontend/src/main.ts",
      "pipeline": "security",
      "tier": 2,
      "rule": "security/xss",
      "severity": "serious",
      "title": "Unsanitized route data injected via innerHTML in nav rendering",
      "detail": "route.label and route.hash are interpolated directly into innerHTML without escaping. While current ROUTES are static, this pattern is unsafe and flagged by Tier-0 as an XSS vector; any future dynamic route data (e.g., from server config) would allow script injection. Use escapeHtml or safe DOM construction.",
      "evidence": {
        "selector": "frontend/src/main.ts:69-71",
        "snippet": "(route) => `\\n          <button type=\"button\" data-testid=\"${route.navTestId}\" data-hash=\"${route.hash}\"\\n            ${route.hash === active.hash ? 'aria-current=\"page\"' : ''}>${route.label}</button>`"
      }
    },
    {
      "type": "finding",
      "id": 2880,
      "url": "frontend/src/pages/products.ts",
      "pipeline": "security",
      "tier": 2,
      "rule": "security/authorization",
      "severity": "serious",
      "title": "Viewer role granted edit/create UI controls",
      "detail": "hasRole('editor','admin','viewer') now grants canEdit to viewers, exposing 'New product' and 'Edit' buttons and allowing PATCH/POST calls to /products for unauthorized users. Revert to hasRole('editor','admin').",
      "evidence": {
        "selector": "frontend/src/pages/products.ts:33",
        "snippet": "const canEdit = hasRole('editor', 'admin', 'viewer');",
        "note": "base was hasRole('editor', 'admin')"
      }
    },
    {
      "type": "finding",
      "id": 2881,
      "url": "frontend/src/pages/products.ts",
      "pipeline": "code",
      "tier": 2,
      "rule": "code/regression",
      "severity": "moderate",
      "title": "Status line off-by-one (N+1 bug)",
      "detail": "Status text reports items.length + 1, which will always overstate the loaded product count by one, misleading users and breaking any test/assertion on the count.",
      "evidence": {
        "selector": "frontend/src/pages/products.ts:108",
        "snippet": "if (statusLine) statusLine.textContent = `Loaded ${items.length + 1} products`;",
        "note": "base used `Loaded ${items.length} products`"
      }
    },
    {
      "type": "finding",
      "id": 2882,
      "url": "frontend/src/pages/products.ts",
      "pipeline": "code",
      "tier": 2,
      "rule": "code/regression",
      "severity": "moderate",
      "title": "Search input lost accessible label",
      "detail": "The search input was wrapped in a <label>Search...</label> providing an accessible name; now it's a bare input with only a placeholder, which is not a substitute for a label and is an accessibility regression (WCAG 1.3.1, 3.3.2).",
      "evidence": {
        "selector": "frontend/src/pages/products.ts:39",
        "snippet": "<input data-testid=\"grid-search\" placeholder=\"Search\">",
        "note": "base was <label>Search<input data-testid=\"grid-search\"></label>"
      }
    },
    {
      "type": "finding",
      "id": 2883,
      "url": "frontend/index.html",
      "pipeline": "code",
      "tier": 2,
      "rule": "code/regression",
      "severity": "moderate",
      "title": "Removed lang attribute from html element",
      "detail": "The <html> tag no longer declares a language, which breaks accessibility for screen readers and affects SEO/translation tools that rely on the lang attribute. Restore lang=\"en\" to the html element.",
      "evidence": {
        "selector": "frontend/index.html:2",
        "snippet": "<html>",
        "note": "Base had <html lang=\"en\">"
      }
    },
    {
      "type": "finding",
      "id": 2884,
      "url": "frontend/src/ui.ts",
      "pipeline": "code",
      "tier": 2,
      "rule": "code/regression",
      "severity": "serious",
      "title": "Focus trap removed from modal keydown handler",
      "detail": "The Tab-key handling that constrained focus within the modal (wrapping from last to first focusable element and vice versa with Shift+Tab) was deleted from onKeydown. Now pressing Tab inside an open modal allows focus to escape to background page elements, breaking the modal's accessibility contract (WAI-ARIA dialog pattern) and letting sighted/keyboard/screen-reader users interact with hidden background content while the modal is supposedly modal.",
      "evidence": {
        "selector": "frontend/src/ui.ts:66",
        "snippet": "function onKeydown(event: KeyboardEvent): void {\n    if (event.key === 'Escape') {\n      event.preventDefault();\n      close();\n      return;\n    }\n  }",
        "note": "Base version checked event.key === 'Tab', computed focusable elements, and wrapped focus between first/last elements on Tab/Shift+Tab; this logic is now entirely absent."
      }
    },
    {
      "type": "finding",
      "id": 2885,
      "url": "frontend/src/styles.css",
      "pipeline": "code",
      "tier": 2,
      "rule": "code/regression",
      "severity": "serious",
      "title": "Nav buttons shrunk below WCAG touch target minimum",
      "detail": "The new `nav button` rule overrides the base button padding/min-height with padding:2px 6px, min-height:0, font-size:11px, reducing the clickable area well below the 24x24 CSS px minimum (WCAG 2.5.5/2.5.8) and below the 32px min-height used elsewhere. This makes nav buttons hard to tap on touch devices and inconsistent with the rest of the UI. Remove this override or ensure min-height >= 24px (ideally 32px like other buttons).",
      "evidence": {
        "selector": "frontend/src/styles.css:62-66",
        "snippet": "nav button {\n  padding: 2px 6px;\n  min-height: 0;\n  font-size: 11px;\n}",
        "note": "Base branch had no nav-specific button override; nav buttons inherited the standard button rule with padding 0.55rem 0.8rem and min-height:32px."
      }
    },
    {
      "type": "finding",
      "id": 2886,
      "url": "frontend/src/styles.css",
      "pipeline": "code",
      "tier": 2,
      "rule": "code/regression",
      "severity": "moderate",
      "title": "Status text color fails WCAG AA contrast on white background",
      "detail": "The new rule sets color:#b3b9c6 for grid-status, async-status, and build-info elements. Against a white/light background (#ffffff or #f5f7fb) this color yields a contrast ratio well under the 4.5:1 (or 3:1 for large text) AA requirement, making these status indicators unreadable for many users. Use a darker color (e.g. matching the base #172033 or a comparable mid-gray with sufficient contrast).",
      "evidence": {
        "selector": "frontend/src/styles.css:68-72",
        "snippet": "[data-testid='grid-status'],\n[data-testid='async-status'],\n[data-testid='build-info'] {\n  color: #b3b9c6;\n}",
        "note": "Base branch had no such rule; these elements previously inherited the default text color #172033, which has adequate contrast."
      }
    }
  ],
  "errors": []
}