Ports all five tools to a single 7.5 MB Windows exe with an embedded WebView2 window, file-based config, single-instance focus, and clean shutdown. Adds build.ps1 to build both targets from one command. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
290 lines
7.8 KiB
Go
290 lines
7.8 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
func Base64Page(w http.ResponseWriter, r *http.Request) {
|
|
body := `
|
|
<h2>Base64 Asset Scanner</h2>
|
|
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;">
|
|
<input id="folder" type="text" placeholder="Folder to scan recursively..." style="flex:1;" />
|
|
<button id="pickFolder">Browse Folder...</button>
|
|
<button id="pickFiles">Pick Files...</button>
|
|
<button id="scan">Scan</button>
|
|
</div>
|
|
<div id="selection" class="hint"></div>
|
|
<div id="status" style="margin-top:8px;"></div>
|
|
<div id="results" style="margin-top:8px;"></div>
|
|
|
|
<style>
|
|
.row-result { display:flex; gap:8px; padding:2px 0; font-family:Consolas, monospace; font-size:12px; }
|
|
.mark { width:14px; flex-shrink:0; font-weight:bold; }
|
|
.mark.ok { color:#3fb950; } .mark.bad { color:#f85149; }
|
|
.file-name { word-break:break-all; }
|
|
.assets { opacity:0.7; margin-left:6px; word-break:break-all; }
|
|
.summary { opacity:0.8; margin-bottom:6px; }
|
|
</style>
|
|
|
|
<script>
|
|
const folderEl = document.getElementById('folder');
|
|
const statusEl = document.getElementById('status');
|
|
const resultsEl = document.getElementById('results');
|
|
const selectionEl = document.getElementById('selection');
|
|
let pickedFiles = [];
|
|
function clearFiles(){ pickedFiles = []; selectionEl.textContent = ''; }
|
|
folderEl.addEventListener('input', clearFiles);
|
|
|
|
document.getElementById('pickFolder').addEventListener('click', async () => {
|
|
const r = await fetch('/api/base64/pickFolder', { method: 'POST' });
|
|
const j = await r.json();
|
|
if (j.path) { folderEl.value = j.path; clearFiles(); }
|
|
});
|
|
document.getElementById('pickFiles').addEventListener('click', async () => {
|
|
const r = await fetch('/api/base64/pickFiles', { method: 'POST' });
|
|
const j = await r.json();
|
|
if (j.paths && j.paths.length) {
|
|
pickedFiles = j.paths;
|
|
folderEl.value = '';
|
|
selectionEl.textContent = pickedFiles.length + ' file(s) selected';
|
|
}
|
|
});
|
|
document.getElementById('scan').addEventListener('click', async () => {
|
|
statusEl.textContent = 'Scanning...';
|
|
resultsEl.innerHTML = '';
|
|
let payload;
|
|
if (pickedFiles.length) payload = { files: pickedFiles };
|
|
else {
|
|
const folder = folderEl.value.trim();
|
|
if (!folder) { statusEl.textContent = 'Pick a folder or files first.'; return; }
|
|
payload = { folder };
|
|
}
|
|
const r = await fetch('/api/base64/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
|
|
const j = await r.json();
|
|
if (j.error) { statusEl.textContent = 'Error: ' + j.error; return; }
|
|
const total = j.results.length;
|
|
const flagged = j.results.filter(r => !r.ok).length;
|
|
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.</div>';
|
|
resultsEl.innerHTML = '';
|
|
for (const rr of j.results) {
|
|
const row = document.createElement('div');
|
|
row.className = 'row-result';
|
|
const mark = document.createElement('span');
|
|
mark.className = 'mark ' + (rr.ok ? 'ok' : 'bad');
|
|
mark.textContent = rr.ok ? '✓' : '✗';
|
|
const name = document.createElement('span');
|
|
name.className = 'file-name';
|
|
name.textContent = rr.file;
|
|
row.appendChild(mark); row.appendChild(name);
|
|
if (!rr.ok) {
|
|
const a = document.createElement('span');
|
|
a.className = 'assets';
|
|
a.textContent = '— ' + rr.assets.join(', ');
|
|
row.appendChild(a);
|
|
}
|
|
resultsEl.appendChild(row);
|
|
}
|
|
});
|
|
</script>
|
|
`
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = w.Write([]byte(Page("/base64", "Base64 Scanner", body)))
|
|
}
|
|
|
|
func Base64PickFolder(w http.ResponseWriter, r *http.Request) {
|
|
p := PickFolder("Select folder to scan", "")
|
|
writeJSON(w, map[string]any{"path": p})
|
|
}
|
|
|
|
func Base64PickFiles(w http.ResponseWriter, r *http.Request) {
|
|
paths := PickFiles(
|
|
"Select HTML files",
|
|
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
|
|
true, "",
|
|
)
|
|
writeJSON(w, map[string]any{"paths": paths})
|
|
}
|
|
|
|
type scanResult struct {
|
|
File string `json:"file"`
|
|
OK bool `json:"ok"`
|
|
Assets []string `json:"assets"`
|
|
}
|
|
|
|
func Base64Scan(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Folder string `json:"folder"`
|
|
Files []string `json:"files"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, map[string]any{"results": []scanResult{}, "error": err.Error()})
|
|
return
|
|
}
|
|
|
|
var targets []string
|
|
var baseDir string
|
|
|
|
if len(req.Files) > 0 {
|
|
for _, f := range req.Files {
|
|
if fileExists(f) {
|
|
targets = append(targets, f)
|
|
}
|
|
}
|
|
if len(targets) > 0 {
|
|
baseDir = commonBaseDir(targets)
|
|
}
|
|
} else if req.Folder != "" && fileExists(req.Folder) {
|
|
targets = collectHTMLFiles(req.Folder)
|
|
baseDir = req.Folder
|
|
} else {
|
|
writeJSON(w, map[string]any{"results": []scanResult{}, "error": "Pick a folder or files first"})
|
|
return
|
|
}
|
|
|
|
results := []scanResult{}
|
|
for _, file := range targets {
|
|
data, err := os.ReadFile(file)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
offenders := findNonBase64Assets(string(data))
|
|
display := file
|
|
if baseDir != "" {
|
|
rel, err := filepath.Rel(baseDir, file)
|
|
if err == nil && rel != "" {
|
|
display = rel
|
|
} else {
|
|
display = filepath.Base(file)
|
|
}
|
|
}
|
|
results = append(results, scanResult{
|
|
File: display,
|
|
OK: len(offenders) == 0,
|
|
Assets: offenders,
|
|
})
|
|
}
|
|
writeJSON(w, map[string]any{"results": results})
|
|
}
|
|
|
|
func commonBaseDir(files []string) string {
|
|
if len(files) == 0 {
|
|
return ""
|
|
}
|
|
if len(files) == 1 {
|
|
return filepath.Dir(files[0])
|
|
}
|
|
split := make([][]string, len(files))
|
|
for i, f := range files {
|
|
split[i] = strings.Split(filepath.Dir(f), string(filepath.Separator))
|
|
}
|
|
first := split[0]
|
|
i := 0
|
|
for i < len(first) {
|
|
match := true
|
|
for _, parts := range split {
|
|
if i >= len(parts) || parts[i] != first[i] {
|
|
match = false
|
|
break
|
|
}
|
|
}
|
|
if !match {
|
|
break
|
|
}
|
|
i++
|
|
}
|
|
return strings.Join(first[:i], string(filepath.Separator))
|
|
}
|
|
|
|
func collectHTMLFiles(root string) []string {
|
|
var out []string
|
|
htmlRx := regexp.MustCompile(`(?i)\.html?$`)
|
|
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if d.IsDir() {
|
|
name := d.Name()
|
|
if name == "node_modules" || name == ".git" {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if htmlRx.MatchString(d.Name()) {
|
|
out = append(out, path)
|
|
}
|
|
return nil
|
|
})
|
|
return out
|
|
}
|
|
|
|
var (
|
|
scriptBlockRx = regexp.MustCompile(`(?is)<script\b[^>]*>.*?</script>`)
|
|
attrRx = regexp.MustCompile(`(?i)\b(?:src|href|poster|data-src)\s*=\s*("([^"]*)"|'([^']*)')`)
|
|
cssUrlRx = regexp.MustCompile(`(?i)url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*))\s*\)`)
|
|
base64DataRx = regexp.MustCompile(`(?i)^data:[^;,]*;base64,`)
|
|
mailTelRx = regexp.MustCompile(`(?i)^(mailto:|tel:)`)
|
|
)
|
|
|
|
func findNonBase64Assets(htmlSrc string) []string {
|
|
markup := scriptBlockRx.ReplaceAllString(htmlSrc, "")
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
add := func(u string) {
|
|
if u == "" || seen[u] {
|
|
return
|
|
}
|
|
seen[u] = true
|
|
out = append(out, u)
|
|
}
|
|
|
|
for _, m := range attrRx.FindAllStringSubmatch(markup, -1) {
|
|
url := m[2]
|
|
if url == "" {
|
|
url = m[3]
|
|
}
|
|
url = strings.TrimSpace(url)
|
|
if url == "" {
|
|
continue
|
|
}
|
|
if isAssetReference(url) && !base64DataRx.MatchString(url) {
|
|
add(url)
|
|
}
|
|
}
|
|
for _, m := range cssUrlRx.FindAllStringSubmatch(markup, -1) {
|
|
url := m[1]
|
|
if url == "" {
|
|
url = m[2]
|
|
}
|
|
if url == "" {
|
|
url = m[3]
|
|
}
|
|
url = strings.TrimSpace(url)
|
|
if url == "" {
|
|
continue
|
|
}
|
|
if !base64DataRx.MatchString(url) {
|
|
add(url)
|
|
}
|
|
}
|
|
if out == nil {
|
|
out = []string{}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func isAssetReference(url string) bool {
|
|
if strings.HasPrefix(url, "#") || strings.HasPrefix(strings.ToLower(url), "javascript:") {
|
|
return false
|
|
}
|
|
if mailTelRx.MatchString(url) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|