1/** 2 * Compliance Scan page â rendered EU/UK governance scan of a site's public 3 * surface (UK GDPR / DPA 2018, PECR, EU AI Act Art. 50, security headers, 4 * accessibility). Authenticated scans (behind login) require Monitor+. 5 * 6 * Backend: POST /api/saas/compliance-scan (async) â poll 7 * GET /api/saas/compliance-scan/{scan_id}. 8 */ 9function _escHtml(s) { 10 return String(s == null ? '' : s).replace(/[&<>"']/g, c => ( 11 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); 12} 13 14const _CSCAN_ICON = { pass: 'â ', warn: 'ð¡', fail: 'ð´', manual: 'ð', info: 'â¹ï¸' }; 15const _CSCAN_ORDER = { fail: 0, warn: 1, manual: 2, pass: 3, info: 4 }; 16 17function _cscanGradeColour(grade) { 18 return ({ A: '#16c784', B: '#7bc86c', C: '#f7b500', D: '#ff8c42', F: '#ef4444' })[grade] || '#9fb0d6'; 19} 20 21async function renderCompliancePage(container) { 22 container.innerHTML = ` 23 <div class="page-header"> 24 <div> 25 <h1 class="page-title">Compliance Scan</h1> 26 <p class="page-subtitle">Rendered EU/UK governance scan of a site's public surface â 27 UK GDPR / DPA 2018, PECR (cookies), EU AI Act Art. 50, security headers, accessibility.</p> 28 </div> 29 </div> 30 31 <div class="card" id="cscan-form-card"> 32 <div class="form-group"> 33 <label class="form-label">Site URL</label> 34 <input id="cscan-url" type="url" class="form-input" placeholder="https://example.com" autocomplete="off"> 35 </div> 36 <div class="form-group" style="max-width:200px"> 37 <label class="form-label">Pages to render (max)</label> 38 <input id="cscan-maxpages" type="number" class="form-input" value="8" min="1" max="25"> 39 </div> 40 41 <details id="cscan-auth" style="margin:8px 0 16px"> 42 <summary style="cursor:pointer;color:var(--accent,#4f7cff);font-weight:600"> 43 ð Authenticated scan (behind login) â Monitor plan and above 44 </summary> 45 <p class="text-muted" style="font-size:.85rem;margin:8px 0"> 46 Supply a login so the scan reaches behind-login high-risk flows a public scan can't see. 47 Credentials are used only for this scan and never stored in the report.</p> 48 <div class="form-group"><input id="cscan-login-url" class="form-input" placeholder="Login page URL (e.g. https://example.com/login)"></div> 49 <div class="form-group"><input id="cscan-login-user" class="form-input" placeholder="Username / email" autocomplete="off"></div> 50 <div class="form-group"><input id="cscan-login-pass" type="password" class="form-input" placeholder="Password" autocomplete="new-password"></div> 51 </details> 52 53 <button id="cscan-run" class="btn btn-primary">Run compliance scan â</button> 54 </div> 55 56 <div class="card" id="cscan-progress" style="display:none"> 57 <div class="flex items-center gap-sm"> 58 <div class="spinner" style="width:18px;height:18px;border:2px solid rgba(255,255,255,.25);border-top-color:#fff;border-radius:50%;display:inline-block;animation:cscanspin .8s linear infinite"></div> 59 <span id="cscan-progress-text">Rendering & scanning the siteâ¦</span> 60 </div> 61 <p class="text-muted" style="font-size:.82rem;margin-top:8px">This renders pages in a real browser, so it takes ~30â90s.</p> 62 </div> 63 <style>@keyframes cscanspin{to{transform:rotate(360deg)}}</style> 64 65 <div id="cscan-results"></div> 66 67 <div class="card" id="cscan-history-card"> 68 <h3 style="margin:0 0 12px">Recent scans</h3> 69 <div id="cscan-history" class="text-muted">Loadingâ¦</div> 70 </div> 71 `; 72 73 document.getElementById('cscan-run').addEventListener('click', _cscanRun); 74 _cscanLoadHistory(); 75 76 function _showProgress(show) { 77 document.getElementById('cscan-progress').style.display = show ? '' : 'none'; 78 document.getElementById('cscan-run').disabled = show; 79 } 80 81 async function _cscanRun() { 82 const url = document.getElementById('cscan-url').value.trim(); 83 if (!url) { NAILUI.toast('Enter a site URL to scan', 'warning'); return; } 84 const maxPages = Math.max(1, Math.min(parseInt(document.getElementById('cscan-maxpages').value) || 8, 25)); 85 const loginUrl = document.getElementById('cscan-login-url').value.trim(); 86 const user = document.getElementById('cscan-login-user').value.trim(); 87 const pass = document.getElementById('cscan-login-pass').value; 88 89 const body = { url, max_pages: maxPages }; 90 if (loginUrl && user && pass) { 91 body.auth = { login: { url: loginUrl, username: user, password: pass } }; 92 } 93 94 document.getElementById('cscan-results').innerHTML = ''; 95 _showProgress(true); 96 try { 97 const resp = await NAILAPI.post('/api/saas/compliance-scan', body); 98 if (!resp || !resp.scan_id) { 99 throw new Error(resp && resp.message ? resp.message : 'Could not start scan'); 100 } 101 _cscanPoll(resp.scan_id); 102 } catch (err) { 103 _showProgress(false); 104 const msg = /upgrade|paid|monitor/i.test(err.message || '') 105 ? 'This scan tier requires a paid plan (authenticated scans need Monitor+).' 106 : (err.message || 'Could not start scan'); 107 NAILUI.toast(msg, 'error', 6000); 108 } 109 } 110 111 function _cscanPoll(scanId) { 112 let attempts = 0; 113 const maxAttempts = 60; // ~5 min at 5s 114 const iv = setInterval(async () => { 115 attempts++; 116 try { 117 const r = await NAILAPI.get('/api/saas/compliance-scan/' + encodeURIComponent(scanId)); 118 document.getElementById('cscan-progress-text').textContent = 119 `Rendering & scanning the site⦠(${r.status})`; 120 if (r.status === 'complete') { 121 clearInterval(iv); _showProgress(false); 122 _cscanRenderResult(r); _cscanLoadHistory(); 123 } else if (r.status === 'failed') { 124 clearInterval(iv); _showProgress(false); 125 NAILUI.toast('Scan failed: ' + (r.error || 'unknown error'), 'error', 6000); 126 } 127 if (attempts >= maxAttempts) { 128 clearInterval(iv); _showProgress(false); 129 NAILUI.toast('Scan is taking longer than expected â check Recent scans shortly.', 'warning', 6000); 130 _cscanLoadHistory(); 131 } 132 } catch (err) { 133 if (attempts >= maxAttempts) { clearInterval(iv); _showProgress(false); } 134 } 135 }, 5000); 136 } 137 138 function _cscanRenderResult(r) { 139 const res = r.result || {}; 140 const findings = res.findings || []; 141 const counts = res.counts || {}; 142 const grade = r.grade || res.grade || 'â'; 143 const score = (r.score != null ? r.score : res.score) || 0; 144 const colour = _cscanGradeColour(grade); 145 findings.sort((a, b) => (_CSCAN_ORDER[a.status] - _CSCAN_ORDER[b.status])); 146 147 const rows = findings.map(f => `
148 <tr> 149 <td>${_CSCAN_ICON[f.status] || ''} ${_escHtml(f.status)}</td> 150 <td><strong>${_escHtml(f.title)}</strong><br><span class="text-muted" style="font-size:.8rem">${_escHtml(f.id)}</span></td> 151 <td>${_escHtml(f.law)}<br><span class="text-muted" style="font-size:.8rem">${_escHtml(f.reference)}</span></td> 152 <td>${_escHtml(f.severity)}</td> 153 <td style="font-size:.85rem">${_escHtml(f.evidence)}${f.recommendation ? `<br><em style="color:var(--accent,#4f7cff)">â ${_escHtml(f.recommendation)}</em>` : ''}</td> 154 </tr>`).join(''); 155 156 document.getElementById('cscan-results').innerHTML = ` 157 <div class="card"> 158 <div class="flex items-center justify-between" style="flex-wrap:wrap;gap:12px"> 159 <div> 160 <h2 style="margin:0">Compliance â ${_escHtml(r.url || res.target || '')}</h2> 161 <p class="text-muted" style="margin:2px 0;font-size:.85rem"> 162 Mode: ${_escHtml(r.mode || 'public')} · ${(res.notes || []).map(_escHtml).join(' ')}</p> 163 </div> 164 <div style="text-align:center"> 165 <div style="font-size:2.4rem;font-weight:800;color:${colour};line-height:1">${score}%</div> 166 <div style="font-weight:700;color:${colour}">Grade ${grade}</div> 167 </div> 168 </div> 169 <div class="flex gap-sm" style="margin:10px 0 4px;font-size:.9rem"> 170 <span>ð´ ${counts.fail || 0} fail</span><span>ð¡ ${counts.warn || 0} warn</span> 171 <span>â ${counts.pass || 0} pass</span><span>ð ${counts.manual || 0} manual</span> 172 </div> 173 <div style="overflow-x:auto"> 174 <table class="table" style="width:100%;margin-top:10px"> 175 <thead><tr><th>Status</th><th>Check</th><th>Law</th><th>Sev</th><th>Evidence / fix</th></tr></thead> 176 <tbody>${rows || '<tr><td colspan="5">No findings.</td></tr>'}</tbody> 177 </table> 178 </div> 179 <p class="text-muted" style="font-size:.78rem;margin-top:10px"> 180 ${_escHtml(res.disclaimer || 'Evidence-based gap assessment of the public surface â not a legal determination.')}</p> 181 </div>`; 182 } 183 184 async function _cscanLoadHistory() { 185 const el = document.getElementById('cscan-history'); 186 if (!el) return; 187 try { 188 const r = await NAILAPI.get('/api/saas/compliance-scans'); 189 const scans = (r && r.scans) || []; 190 if (!scans.length) { el.innerHTML = '<span class="text-muted">No scans yet.</span>'; return; } 191 el.innerHTML = ` 192 <div style="overflow-x:auto"><table class="table" style="width:100%"> 193 <thead><tr><th>URL</th><th>Mode</th><th>Status</th><th>Grade</th><th>When</th></tr></thead> 194 <tbody>${scans.map(s => ` 195 <tr> 196 <td>${_escHtml(s.url)}</td> 197 <td>${_escHtml(s.mode)}</td> 198 <td>${_escHtml(s.status)}</td> 199 <td>${s.grade ? `<strong style="color:${_cscanGradeColour(s.grade)}">${_escHtml(s.grade)}</strong> (${s.score != null ? s.score + '%' : ''})` : 'â'}</td> 200 <td class="text-muted" style="font-size:.82rem">${_escHtml((s.created_at || '').replace('T', ' ').slice(0, 16))}</td> 201 </tr>`).join('')}</tbody> 202 </table></div>`; 203 } catch (err) { 204 el.innerHTML = '<span class="text-muted">Could not load history.</span>'; 205 } 206 } 207} 208 209NAILRouter.registerPage('compliance-scan', renderCompliancePage);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.