diff --git a/tools/zeithawk/README.md b/tools/zeithawk/README.md index 30c646c..9bb4338 100644 --- a/tools/zeithawk/README.md +++ b/tools/zeithawk/README.md @@ -23,6 +23,18 @@ review/testing work against systems they're authorized to test. - **Comparer** — line-level diff between two blocks of text. - **History** — every request sent from Repeater or Intruder, stored in `localStorage`, click a row to reload it into Repeater. +- **Recon** — DNS records (A/AAAA/MX/TXT/NS/CNAME/SOA) via DNS-over-HTTPS, + WHOIS/RDAP, and an HTTP-reachability check on common web ports for a + domain or IP — plus ready-to-copy `ping`/`traceroute`/`whois`/`nmap` + commands for the things a browser can't do (see below). +- **Vuln Scan** — a lightweight header/config checker in the spirit of + securityheaders.com or Mozilla Observatory: checks for common security + headers (HSTS, CSP, X-Frame-Options, X-Content-Type-Options, + Referrer-Policy, Permissions-Policy), flags a permissive-plus-credentialed + CORS policy, and probes a fixed list of commonly-exposed sensitive paths + (`.env`, `.git/HEAD`, `wp-config.php.bak`, `id_rsa`, etc). Not a + signature-based scanner like Nessus/OpenVAS/Nikto — see below for what it + can and can't actually prove. ## Constraints (by design — it runs entirely client-side) @@ -42,3 +54,55 @@ mitmproxy). **Authorized testing only.** Only point this at systems you own or have explicit permission to test. + +## Recon's limits (also by design) + +A browser has no API for raw sockets, so three of the things "recon a +target" usually means are flatly impossible from client-side JS, not just +inconvenient: + +- **ICMP ping** — needs a raw socket; there's no ping API in a browser. +- **Traceroute** — needs to send packets with increasing TTL and read back + ICMP responses; same problem, no workaround. +- **A real port scan** — needs raw TCP SYN/connect control across arbitrary + ports; a browser's `fetch()` can only ever make an HTTP request, and only + on ports the browser itself doesn't block outright. + +Recon's "HTTP reachability" table is a limited, honest substitute: it tries +a plain HTTPS/HTTP request on a handful of common web ports and reports +reachable/timeout/closed — useful signal, not a substitute for `nmap`. +For the real thing, Recon prints ready-to-copy `ping`, `traceroute`, +`whois`, and `nmap` commands to run in your own terminal, against targets +you're authorized to test. + +DNS-over-HTTPS (`dns.google`) and RDAP (`rdap.org`, which bootstraps to the +authoritative registry) are both public APIs with no key required. RDAP +lookups can still fail if the target registry's own RDAP server doesn't +send CORS headers for browser access — when that happens, Recon says so +and points at the `whois` command instead. + +## Vuln Scan's limits (also by design) + +The security-header checks can only ever prove a header is **present** — +they can never prove one is **missing**. Browsers only expose a small +CORS-safelisted set of response headers to cross-origin JavaScript +(`Cache-Control`, `Content-Language`, `Content-Length`, `Content-Type`, +`Expires`, `Last-Modified`, `Pragma`); none of the security headers this +tool checks are in that list. A server can only make a header readable by +naming it in its own `Access-Control-Expose-Headers` response header. So +when a check reports "Not readable cross-origin", that means exactly what +it says — unproven either way — not "confirmed missing." This applies to +`Access-Control-Allow-Origin` itself too: if it isn't exposed via +`Access-Control-Expose-Headers`, Vuln Scan can't even read whether CORS is +open, and says so rather than guessing. Every row that reports a real +finding (a header actually read, a CORS policy actually inspected, a path +that actually returned a status code) is something ZeitHawk directly +observed — never inferred from silence. For ground truth on anything +marked "Not readable cross-origin," the read-out includes a ready-to-copy +`curl -sD - -o /dev/null ` command, which sees every header a server +sends, no CORS restriction involved. + +The exposed-path checks need the target to allow cross-origin reads at +all (same CORS requirement as Repeater) to see status codes back; a +target with a locked-down CORS policy will show every path check as +blocked, which is not the same as those paths not existing. diff --git a/tools/zeithawk/index.html b/tools/zeithawk/index.html index 4c08c2e..b28529d 100644 --- a/tools/zeithawk/index.html +++ b/tools/zeithawk/index.html @@ -95,8 +95,6 @@ .app-header{display:flex;flex-direction:column;gap:6px;margin-bottom:24px;} .eyebrow{font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:0.14em;text-transform:uppercase;color:var(--accent);display:flex;align-items:center;gap:8px;} .eyebrow .dot{width:6px;height:6px;border-radius:50%;background:var(--accent);display:inline-block;} - .title-row{display:flex;align-items:center;gap:12px;} - .hawk-icon{height:clamp(34px,5.2vw,48px);width:auto;flex-shrink:0;filter:drop-shadow(0 2px 3px rgba(0,0,0,0.18));} h1.title{font-size:clamp(28px,4.5vw,40px);line-height:1.05;color:var(--ink);} .subtitle{color:var(--ink-soft);font-size:15px;max-width:74ch;line-height:1.5;margin-top:4px;} @@ -265,22 +263,28 @@ .hint{font-size:12px;color:var(--ink-soft);line-height:1.5;margin:0;} [hidden]{display:none!important;} + .whois-row{display:flex;justify-content:space-between;gap:12px;padding:6px 0;border-bottom:1px solid var(--line-soft);font-family:'IBM Plex Mono',monospace;font-size:12.5px;} + .whois-row:last-child{border-bottom:none;} + .whois-label{color:var(--ink-soft);flex:0 0 auto;} + .whois-value{color:var(--ink);text-align:right;word-break:break-word;} + + .cmd-row{display:flex;justify-content:space-between;align-items:center;gap:12px;background:var(--paper);border:1px solid var(--line-soft);border-radius:8px;padding:8px 12px;} + .cmd-row code{background:none;padding:0;font-size:12.5px;} + + .sev-badge{font-family:'IBM Plex Mono',monospace;font-size:10.5px;font-weight:700;letter-spacing:0.04em;padding:2px 8px;border-radius:6px;border:1px solid transparent;text-transform:uppercase;white-space:nowrap;} + .sev-high{background:var(--restricted-soft);color:var(--restricted);border-color:var(--restricted);} + .sev-medium{background:var(--caution-soft);color:var(--caution);border-color:var(--caution);} + .sev-low{background:var(--slate-soft);color:var(--slate);border-color:var(--line);} + .sev-info{background:var(--unknown-soft);color:var(--unknown);border-color:var(--line);} + table.results td .hint{margin-top:3px;} + .footer-note{margin-top:36px;padding-top:18px;border-top:1px solid var(--line);color:var(--unknown);font-size:12px;font-family:'IBM Plex Mono',monospace;text-align:center;line-height:1.6;}
Zeitview · Security Tooling -
- - - - - - - -

ZeitHawk

-
+

ZeitHawk

Craft, replay, and fuzz HTTP requests, then decode, hash, and diff what comes back — a lightweight, browser-only toolkit built in the spirit of Burp Suite's core tools: Repeater, Intruder, Decoder, and Comparer.

@@ -512,6 +516,105 @@

History

+ + + + + + @@ -606,6 +709,8 @@

History

{ id:'decoder', glyph:'⌬', label:'Decoder' }, { id:'comparer', glyph:'⇅', label:'Comparer' }, { id:'history', glyph:'◷', label:'History' }, + { id:'recon', glyph:'◎', label:'Recon' }, + { id:'scan', glyph:'⛨', label:'Vuln Scan' }, ]; const tabbar = document.getElementById('tabbar'); TOOLS.forEach(t=>{ @@ -1056,5 +1161,358 @@

History

renderHistory(); }); +/* ------------------------------------------------------------------ * + * RECON + * ------------------------------------------------------------------ */ + +function detectTargetType(t){ + if(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.test(t)) return 'ipv4'; + if(t.indexOf(':') !== -1 && /^[0-9a-fA-F:]+$/.test(t)) return 'ipv6'; + return 'domain'; +} + +async function dohQuery(name, type){ + const url = 'https://dns.google/resolve?name=' + encodeURIComponent(name) + '&type=' + encodeURIComponent(type); + try{ + const res = await fetch(url, { signal: AbortSignal.timeout(6000) }); + if(!res.ok) return { ok:false, error: 'HTTP '+res.status }; + const data = await res.json(); + return { ok:true, data }; + }catch(e){ return { ok:false, error: (e && e.message) || String(e) }; } +} + +function reverseIpv4(ip){ return ip.split('.').reverse().join('.') + '.in-addr.arpa'; } + +async function rdapLookup(target, isIp){ + const url = 'https://rdap.org/' + (isIp ? 'ip/' : 'domain/') + encodeURIComponent(target); + try{ + const res = await fetch(url, { headers:{accept:'application/rdap+json'}, signal: AbortSignal.timeout(8000) }); + if(!res.ok) return { ok:false, error:'HTTP '+res.status }; + const data = await res.json(); + return { ok:true, data }; + }catch(e){ return { ok:false, error: (e && e.message) || String(e) }; } +} + +const RECON_PORTS = [ + {port:80, scheme:'http'}, {port:443, scheme:'https'}, + {port:8080, scheme:'http'}, {port:8443, scheme:'https'}, + {port:8000, scheme:'http'}, {port:3000, scheme:'http'}, +]; + +async function checkPort(host, port, scheme){ + const url = scheme+'://'+host+':'+port+'/'; + const t0 = performance.now(); + try{ + await fetch(url, { mode:'no-cors', cache:'no-store', signal: AbortSignal.timeout(4000) }); + return { port, scheme, status:'reachable', ms: Math.round(performance.now()-t0) }; + }catch(e){ + const ms = Math.round(performance.now()-t0); + const msg = String((e && e.message) || e); + if(e && e.name === 'TimeoutError') return { port, scheme, status:'timeout', ms }; + if(msg.indexOf('UNSAFE_PORT') !== -1) return { port, scheme, status:'blocked by browser', ms }; + return { port, scheme, status:'closed / unreachable', ms }; + } +} + +function buildLocalCommands(target){ + return [ + { label:'DNS — all records', cmd:'dig '+target+' ANY' }, + { label:'Nameservers', cmd:'dig '+target+' NS +short' }, + { label:'Reverse DNS', cmd:'nslookup '+target }, + { label:'WHOIS', cmd:'whois '+target }, + { label:'Ping', cmd:'ping -c 4 '+target }, + { label:'Traceroute', cmd:'traceroute '+target }, + { label:'Full port scan (authorized targets only)', cmd:'nmap -sV -p- '+target }, + ]; +} + +function renderReconCommands(target){ + const wrap = document.getElementById('recon-commands'); + wrap.innerHTML = ''; + buildLocalCommands(target).forEach(c=>{ + const row = document.createElement('div'); + row.className = 'cmd-row'; + row.innerHTML = '
'+escapeHtml(c.label)+'
'+escapeHtml(c.cmd)+'
'; + const btn = document.createElement('button'); + btn.type = 'button'; btn.className = 'btn btn-ghost btn-sm'; btn.textContent = 'Copy'; + btn.addEventListener('click', async ()=>{ + try{ + await navigator.clipboard.writeText(c.cmd); + btn.textContent = 'Copied'; + setTimeout(()=>{ btn.textContent = 'Copy'; }, 1200); + }catch(e){ btn.textContent = 'Select & copy manually'; } + }); + row.appendChild(btn); + wrap.appendChild(row); + }); +} + +function whoisRow(label, value){ + return '
'+escapeHtml(label)+''+escapeHtml(value)+'
'; +} + +function extractRdapSummary(data){ + const rows = []; + if(data.ldhName) rows.push(['Name', data.ldhName]); + if(data.handle) rows.push(['Handle', data.handle]); + if(data.name) rows.push(['Network name', data.name]); + if(data.startAddress) rows.push(['Range start', data.startAddress]); + if(data.endAddress) rows.push(['Range end', data.endAddress]); + if(data.country) rows.push(['Country', data.country]); + if(Array.isArray(data.status) && data.status.length) rows.push(['Status', data.status.join(', ')]); + if(Array.isArray(data.events)){ + data.events.forEach(ev=>{ + if(ev.eventAction && ev.eventDate) rows.push([ev.eventAction, ev.eventDate.slice(0,10)]); + }); + } + if(Array.isArray(data.nameservers) && data.nameservers.length){ + rows.push(['Name servers', data.nameservers.map(ns=>ns.ldhName).filter(Boolean).join(', ')]); + } + if(Array.isArray(data.entities)){ + const registrar = data.entities.find(e=>Array.isArray(e.roles) && e.roles.indexOf('registrar') !== -1); + if(registrar){ + let fn = null; + if(Array.isArray(registrar.vcardArray) && Array.isArray(registrar.vcardArray[1])){ + const fnEntry = registrar.vcardArray[1].find(v=>v[0]==='fn'); + if(fnEntry) fn = fnEntry[3]; + } + rows.push(['Registrar', fn || registrar.handle || '(unnamed)']); + } + } + return rows; +} + +async function runRecon(){ + const target = document.getElementById('recon-target').value.trim(); + if(!target) return; + const type = detectTargetType(target); + const isIp = type !== 'domain'; + + document.getElementById('recon-resolved').innerHTML = 'Looking up…'; + document.getElementById('recon-dns-tbody').innerHTML = 'Loading…'; + document.getElementById('recon-ns').innerHTML = 'Loading…'; + document.getElementById('recon-whois').innerHTML = 'Loading…'; + document.getElementById('recon-ports-tbody').innerHTML = 'Checking…'; + renderReconCommands(target); + + (async ()=>{ + if(!isIp){ + const types = ['A','AAAA','MX','TXT','NS','CNAME','SOA']; + const results = await Promise.all(types.map(t=>dohQuery(target, t))); + const rows = []; + const resolvedIps = []; + results.forEach((r, i)=>{ + const t = types[i]; + if(!r.ok || !r.data.Answer) return; + r.data.Answer.forEach(ans=>{ + rows.push([t, ans.data, ans.TTL]); + if(t === 'A' || t === 'AAAA') resolvedIps.push(ans.data); + }); + }); + document.getElementById('recon-dns-tbody').innerHTML = rows.length + ? rows.map(r=>''+escapeHtml(r[0])+''+escapeHtml(r[1])+''+escapeHtml(r[2])+'').join('') + : 'No records found (or the lookup failed).'; + document.getElementById('recon-resolved').innerHTML = resolvedIps.length + ? resolvedIps.map(ip=>''+escapeHtml(ip)+'').join(' ') + : 'No A/AAAA records found.'; + const nsRows = rows.filter(r=>r[0]==='NS'); + document.getElementById('recon-ns').innerHTML = nsRows.length + ? nsRows.map(r=>''+escapeHtml(r[1])+'').join(' ') + : 'No NS records found.'; + } else { + document.getElementById('recon-resolved').innerHTML = ''+escapeHtml(target)+''; + if(type === 'ipv4'){ + const ptr = await dohQuery(reverseIpv4(target), 'PTR'); + document.getElementById('recon-dns-tbody').innerHTML = (ptr.ok && ptr.data.Answer && ptr.data.Answer.length) + ? ptr.data.Answer.map(a=>'PTR'+escapeHtml(a.data)+''+escapeHtml(a.TTL)+'').join('') + : 'No reverse DNS (PTR) record found.'; + } else { + document.getElementById('recon-dns-tbody').innerHTML = 'Reverse DNS lookup for IPv6 isn\'t implemented here.'; + } + document.getElementById('recon-ns').innerHTML = 'Not applicable for an IP target.'; + } + })(); + + (async ()=>{ + const r = await rdapLookup(target, isIp); + const el = document.getElementById('recon-whois'); + if(!r.ok){ + el.innerHTML = 'Couldn\'t load RDAP/WHOIS data ('+escapeHtml(r.error)+') — the registry may not allow browser access. Try the whois command below instead.'; + return; + } + const rows = extractRdapSummary(r.data); + const summary = rows.length ? rows.map(([k,v])=>whoisRow(k,v)).join('') : 'No summary fields found — see raw response below.'; + const raw = '
Raw RDAP response
'+escapeHtml(JSON.stringify(r.data, null, 2))+'
'; + el.innerHTML = summary + raw; + })(); + + (async ()=>{ + const results = await Promise.all(RECON_PORTS.map(p=>checkPort(target, p.port, p.scheme))); + document.getElementById('recon-ports-tbody').innerHTML = results.map(r=>{ + const dot = r.status==='reachable' ? 'dot-ok' : (r.status==='timeout' ? 'dot-caution' : 'dot-restricted'); + return ''+r.port+''+r.scheme+''+escapeHtml(r.status)+''+r.ms+' ms'; + }).join(''); + })(); +} + +document.getElementById('recon-run').addEventListener('click', runRecon); +document.getElementById('recon-target').addEventListener('keydown', (e)=>{ if(e.key==='Enter') runRecon(); }); +document.getElementById('recon-target').value = 'example.com'; + +/* ------------------------------------------------------------------ * + * VULN SCAN + * ------------------------------------------------------------------ */ + +// Browsers only expose a small safelisted set of response headers to +// cross-origin JS (Cache-Control, Content-Type, etc.) — none of the +// security headers below are in it. A fetch can only ever PROVE a header +// is present (if the target opted in via Access-Control-Expose-Headers, +// or happens to be same-origin); it can never prove one is absent, so +// these checks report "present" or "unknown", never a false "missing". +const SECURITY_HEADER_CHECKS = [ + { header:'strict-transport-security', label:'Strict-Transport-Security (HSTS)', + note:'If actually missing: a plain-HTTP link or a user\'s first visit can be intercepted before the redirect to HTTPS ever happens.' }, + { header:'content-security-policy', label:'Content-Security-Policy', altCsp:'frame-ancestors', + note:'If actually missing: one missed output-encoding bug becomes a full XSS with no second line of defense.' }, + { header:'x-frame-options', label:'X-Frame-Options / frame-ancestors', + note:'If actually missing from both: the page can be framed by another site for clickjacking.' }, + { header:'x-content-type-options', label:'X-Content-Type-Options', + note:'If actually missing: some browsers may MIME-sniff a response into a more dangerous content type.' }, + { header:'referrer-policy', label:'Referrer-Policy', + note:'If actually missing: full URLs (including sensitive query params) may leak to third parties via Referer.' }, + { header:'permissions-policy', label:'Permissions-Policy', + note:'If actually missing: no explicit restriction on powerful browser features (camera, geolocation, etc.).' }, +]; + +const INFO_HEADER_CHECKS = [ + { header:'server', label:'Server' }, + { header:'x-powered-by', label:'X-Powered-By' }, +]; + +function findMetaCsp(html){ + const m = /]*http-equiv=["']content-security-policy["'][^>]*content=["']([^"']+)["']/i.exec(html || ''); + return m ? m[1] : null; +} + +const PATH_CHECKS = [ + { path:'/.env', label:'Environment file', severity:'high' }, + { path:'/.git/HEAD', label:'Git repository exposed', severity:'high' }, + { path:'/.git/config', label:'Git repository exposed', severity:'high' }, + { path:'/wp-config.php.bak', label:'WordPress config backup', severity:'high' }, + { path:'/id_rsa', label:'Private SSH key', severity:'high' }, + { path:'/backup.zip', label:'Backup archive', severity:'medium' }, + { path:'/config.php.bak', label:'Config backup file', severity:'medium' }, + { path:'/phpinfo.php', label:'phpinfo() disclosure', severity:'medium' }, + { path:'/server-status', label:'Apache server-status', severity:'medium' }, + { path:'/.htaccess', label:'.htaccess exposed', severity:'medium' }, + { path:'/.DS_Store', label:'.DS_Store exposed', severity:'low' }, + { path:'/.well-known/security.txt', label:'security.txt (good practice)', severity:'info' }, +]; + +async function runVulnScan(){ + const raw = document.getElementById('scan-target').value.trim(); + if(!raw) return; + const url = /^https?:\/\//i.test(raw) ? raw : 'https://' + raw; + let origin; + try{ origin = new URL(url).origin; } + catch(e){ + document.getElementById('scan-summary').innerHTML = 'That doesn\'t look like a valid URL.'; + return; + } + + document.getElementById('scan-summary').innerHTML = 'Scanning…'; + document.getElementById('scan-headers-tbody').innerHTML = 'Loading…'; + document.getElementById('scan-cors').innerHTML = 'Loading…'; + document.getElementById('scan-paths-tbody').innerHTML = 'Checking…'; + + const findings = []; + + let mainRes = null, mainErr = null, bodyText = ''; + try{ + mainRes = await fetch(url, { mode:'cors', signal: AbortSignal.timeout(8000) }); + bodyText = await mainRes.text(); + }catch(e){ mainErr = (e && e.message) || String(e); } + + if(!mainRes){ + document.getElementById('scan-headers-tbody').innerHTML = 'Couldn\'t reach the target: '+escapeHtml(mainErr||'unknown error')+' — likely CORS or a network/DNS error.'; + document.getElementById('scan-cors').innerHTML = 'No response to inspect.'; + } else { + const h = mainRes.headers; + const metaCsp = findMetaCsp(bodyText); + const rows = SECURITY_HEADER_CHECKS.map(chk=>{ + const raw = h.get(chk.header); + const present = !!raw || (chk.header==='content-security-policy' && !!metaCsp) + || (chk.altCsp && !!h.get('content-security-policy') && h.get('content-security-policy').indexOf(chk.altCsp)!==-1); + return { label: chk.label, present, note: chk.note, value: raw || (chk.header==='content-security-policy' ? metaCsp : null) }; + }); + document.getElementById('scan-headers-tbody').innerHTML = rows.map(r=>{ + const dot = r.present ? 'dot-ok' : 'dot-caution'; + const sevCell = r.present ? 'VERIFIED' : 'UNKNOWN'; + const status = r.present ? 'Present' : 'Not readable cross-origin'; + return ''+escapeHtml(r.label)+'
'+escapeHtml(r.note)+'
'+ + ''+status+''+ + ''+sevCell+''; + }).join(''); + document.getElementById('scan-headers-tbody').innerHTML += + 'Browsers only expose a small safelist of response headers to cross-origin JS — "Not readable cross-origin" means unproven either way, not confirmed missing. For ground truth: curl -sD - -o /dev/null '+escapeHtml(url)+''; + + const banners = INFO_HEADER_CHECKS.map(c=>{ const v = h.get(c.header); return v ? (c.label+': '+v) : null; }).filter(Boolean); + if(banners.length){ + document.getElementById('scan-headers-tbody').innerHTML += ''+escapeHtml(banners.join(' · '))+' — banner headers disclose software/version info, worth trimming.'; + } + + const acao = h.get('access-control-allow-origin'); + const acac = h.get('access-control-allow-credentials'); + if(!acao){ + document.getElementById('scan-cors').innerHTML = 'Access-Control-Allow-Origin isn\'t readable cross-origin either (same restriction as above) — check with curl -sD - -o /dev/null '+escapeHtml(url)+'.'; + } else if(acao === '*'){ + document.getElementById('scan-cors').innerHTML = + '
Access-Control-Allow-Origin*
'+ + '

Open to any origin — fine for a public, unauthenticated API; worth a second look if this endpoint ever returns anything sensitive.

'; + } else { + const flagged = acac === 'true'; + document.getElementById('scan-cors').innerHTML = + '
Access-Control-Allow-Origin'+escapeHtml(acao)+'
'+ + '
Access-Control-Allow-Credentials'+escapeHtml(acac||'(not set)')+'
'+ + (flagged ? '

Reflects a specific origin and allows credentials — if it reflects any Origin unconditionally (test by re-requesting from a different origin), that lets any site read authenticated responses cross-origin.

' : ''); + if(flagged) findings.push({ severity:'high', label:'CORS reflects an origin with credentials allowed' }); + } + } + + const pathResults = await Promise.all(PATH_CHECKS.map(async pc=>{ + try{ + const res = await fetch(origin + pc.path, { mode:'cors', signal: AbortSignal.timeout(5000) }); + return Object.assign({}, pc, { status: res.status }); + }catch(e){ return Object.assign({}, pc, { status: null }); } + })); + const checkedCount = pathResults.filter(r=>r.status !== null).length; + const flaggedPaths = pathResults.filter(r=>r.status === 200 || r.status === 401 || r.status === 403); + if(checkedCount === 0){ + document.getElementById('scan-paths-tbody').innerHTML = 'All path checks were blocked (CORS or network) — this check needs the target to allow cross-origin reads, same as Repeater.'; + } else if(flaggedPaths.length === 0){ + document.getElementById('scan-paths-tbody').innerHTML = 'None of the checked paths responded with 200/401/403.'; + } else { + document.getElementById('scan-paths-tbody').innerHTML = flaggedPaths.map(r=>{ + const isOpen = r.status === 200; + const sev = isOpen ? r.severity : 'info'; + if(isOpen && r.severity !== 'info') findings.push({ severity:r.severity, label:r.label+' exposed at '+r.path }); + const resultText = isOpen ? ('Present (HTTP '+r.status+')') : ('Exists, access restricted (HTTP '+r.status+')'); + return ''+escapeHtml(r.path)+''+escapeHtml(r.label)+''+escapeHtml(resultText)+''+sev.toUpperCase()+''; + }).join(''); + } + + const counts = { high:0, medium:0, low:0 }; + findings.forEach(f=>{ if(counts[f.severity] !== undefined) counts[f.severity]++; }); + document.getElementById('scan-summary').innerHTML = + ''+counts.high+' high'+ + ''+counts.medium+' medium'+ + ''+counts.low+' low'+ + (findings.length===0 ? 'No findings from the checks above.' : ''); +} + +document.getElementById('scan-run').addEventListener('click', runVulnScan); +document.getElementById('scan-target').addEventListener('keydown', (e)=>{ if(e.key==='Enter') runVulnScan(); }); +document.getElementById('scan-target').value = 'https://example.com'; + })();