From f2d6d2b995af2451b3cb92b693d316e8f15e327e Mon Sep 17 00:00:00 2001 From: MrPickering Date: Fri, 31 Jul 2026 15:43:41 -0700 Subject: [PATCH 1/2] UI: stop tracking a file and surface git history findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixing a git-tracked or historically-leaked secret meant dropping to a terminal even though the UI already detected it. Adds a "stop tracking" button (git rm --cached + gitignore) for TRACKED files, and a per-project "scan git history" button that surfaces the deep history scan the CLI's protect/history commands already do but the UI never called — including secrets in files deleted long ago, which the fast per-file chip can't see. Co-Authored-By: Claude Sonnet 5 Session-Id: 25b49055-f165-4d21-bec2-c530592f3ffe Agent: claude-code --- src/scanner.js | 8 +++++ src/server.js | 33 +++++++++++++++++++- src/ui/index.html | 38 +++++++++++++++++++++-- test/run.mjs | 77 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 153 insertions(+), 3 deletions(-) diff --git a/src/scanner.js b/src/scanner.js index de57751..d21e111 100644 --- a/src/scanner.js +++ b/src/scanner.js @@ -349,6 +349,14 @@ export function addGitignoreEntry(projectPath, fileName) { return true; } +// Removes a file from git's index without touching the working copy, then makes sure it can't +// be re-added by accident. Does NOT touch history — the remedy text callers show must say so. +export function untrackFile(projectPath, filePath) { + execFileSync('git', ['rm', '--cached', '--ignore-unmatch', filePath], { cwd: projectPath, stdio: 'pipe' }); + const gitignoreAdded = addGitignoreEntry(projectPath, path.basename(filePath)); + return { gitignoreAdded }; +} + function groupByProject(files) { const projects = new Map(); for (const file of files) { diff --git a/src/server.js b/src/server.js index 0365146..6fd7d5b 100644 --- a/src/server.js +++ b/src/server.js @@ -26,7 +26,8 @@ import { randomBytes, timingSafeEqual } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { scanDirectories, addGitignoreEntry, detectSensitiveKeys } from './scanner.js'; +import { scanDirectories, addGitignoreEntry, detectSensitiveKeys, untrackFile } from './scanner.js'; +import { scanHistories, historyFindings } from './history.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -239,10 +240,18 @@ export function createEnviewServer({ roots, port = 4174, token, maxDepth = 4 }) // Only files discovered by a scan may be touched. A path arriving in a request is checked // against this set rather than sanitised — an allowlist cannot be escaped by clever encoding. let allowed = new Set(); + // Same principle for project directories: the history endpoint shells out to `git` inside a + // client-supplied path, so that path must be one this scan actually found, not merely resolved. + // Includes the configured roots themselves, not just discovered project paths — a repo whose + // only .env was deleted long ago has no current file to be discovered by, but its history is + // exactly what this endpoint exists to find (mirrors the CLI's `history` command, which unions + // discovered project paths with the roots for the same reason). + let allowedProjects = new Set(); const refresh = () => { const projects = scanDirectories(roots, { maxDepth }); allowed = new Set(projects.flatMap((p) => p.files.map((f) => path.resolve(f.filePath)))); + allowedProjects = new Set([...projects.map((p) => p.path), ...roots].map((p) => path.resolve(p))); return projects; }; refresh(); @@ -253,6 +262,12 @@ export function createEnviewServer({ roots, port = 4174, token, maxDepth = 4 }) return allowed.has(resolved) ? resolved : null; }; + const resolveAllowedProject = (projectPath) => { + if (!projectPath) return null; + const resolved = path.resolve(String(projectPath)); + return allowedProjects.has(resolved) ? resolved : null; + }; + const server = createServer(async (request, response) => { const url = new URL(request.url, `http://127.0.0.1:${port}`); @@ -355,10 +370,26 @@ export function createEnviewServer({ roots, port = 4174, token, maxDepth = 4 }) json(response, 200, { ok: true, added }); return; } + if (body.action === 'untrack') { + const result = untrackFile(path.dirname(filePath), filePath); + json(response, 200, { ok: true, untracked: true, ...result }); + return; + } json(response, 400, { error: `unknown action ${body.action}` }); return; } + // Deliberately on-demand and scoped to one project, not folded into /api/projects: this + // walks `git log --all`, the explicitly slow path (see history.js) — fine for one repo on + // a click, not fine for every repo on every page load. + if (url.pathname === '/api/history' && request.method === 'GET') { + const projectPath = resolveAllowedProject(url.searchParams.get('path')); + if (!projectPath) { json(response, 404, { error: 'unknown project' }); return; } + const histories = scanHistories([projectPath], { detectSensitive: detectSensitiveKeys }); + json(response, 200, { findings: historyFindings(histories) }); + return; + } + json(response, 404, { error: 'not found' }); } catch (error) { json(response, 500, { error: error.message }); diff --git a/src/ui/index.html b/src/ui/index.html index d145fa7..f2f6bf7 100644 --- a/src/ui/index.html +++ b/src/ui/index.html @@ -55,6 +55,11 @@ background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:10px 16px;font:12px var(--mono);pointer-events:none;z-index:50} #toast.show{opacity:1;transform:translateX(-50%) translateY(0)} .banner{max-width:1240px;margin:0 auto 18px;padding:12px 16px;border:1px solid rgba(251,191,36,.3);background:rgba(251,191,36,.06);border-radius:10px;color:#e8d9ae;font-size:12.5px} +.path{display:flex;align-items:center;gap:10px;flex-wrap:wrap} +.history-panel{margin:0 0 14px} +.history-panel:empty{display:none} +.history-finding{padding:10px 0;border-bottom:1px solid var(--line)} +.history-finding:last-child{border-bottom:0} @@ -141,7 +146,8 @@

🦅 enview

app.innerHTML = data.projects.map((p) => `

${esc(p.name)}

-

${esc(p.path)}

+

${esc(p.path)}

+
${p.files.map((f) => `
@@ -169,6 +175,7 @@

${esc(p.name)}

${!f.hasExample ? '' : ""} ${f.inGitRepo && !f.gitIgnored ? '' : ""} + ${f.gitTracked ? '' : ""}
`).join("")}
`).join(""); @@ -245,10 +252,37 @@

${esc(p.name)}

const act = button.dataset.act; const row = button.closest(".row"); const foot = button.closest(".file-foot"); - const file = (row || foot).dataset.file; + // scan-history is scoped to the whole project, not a file — neither row nor foot applies. + const file = (row || foot)?.dataset.file; const key = row?.dataset.key; try { + if (act === "scan-history") { + const panel = button.closest(".project").querySelector(".history-panel"); + button.disabled = true; + button.textContent = "scanning…"; + try { + const { findings } = await api(`/api/history?path=${encodeURIComponent(button.dataset.project)}`); + panel.innerHTML = findings.length + ? findings.map((f) => `
+ ${esc(f.file)} ${f.stillTracked ? 'still tracked' : 'removed, still in history'} + ${f.keys.length ? `
${f.keys.map(esc).join(", ")}
` : ""} +

${esc(f.remedy)}

+
`).join("") + : '

No risky paths found in this repo’s history.

'; + } finally { + button.disabled = false; + button.textContent = "scan git history"; + } + return; + } + if (act === "untrack") { + if (!confirm(`Untrack ${file.split(/[\\/]/).pop()} from git?\n\nRuns "git rm --cached" and adds it to .gitignore. This does NOT remove it from git history — rotate any real secrets in this file regardless.`)) return; + const result = await api("/api/action", { method: "POST", body: JSON.stringify({ file, action: "untrack" }) }); + toast(result.gitignoreAdded ? "untracked + added to .gitignore" : "untracked"); + load(); + return; + } if (act === "reveal") { const cell = row.querySelector(".v"); if (cell.classList.contains("shown")) { diff --git a/test/run.mjs b/test/run.mjs index 43284eb..0972bf7 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -134,6 +134,46 @@ check(".env.example keeps keys", /^API_KEY=$/m.test(example), example); check(".env.example strips every value", !example.includes("sk-test") && !example.includes("hello world"), example); check(".env.example keeps comments", example.includes("# a comment that must survive")); +// ---------------------------------------------------------------- untrack +console.log("\n--- untrack ---"); +{ + const { execFileSync } = await import("node:child_process"); + const repo = mkdtempSync(join(tmpdir(), "enview-untrack-")); + const run = (...args) => execFileSync("git", args, { cwd: repo, stdio: "pipe" }); + run("init", "-q"); + run("config", "user.email", "t@t.test"); + run("config", "user.name", "t"); + writeFileSync(join(repo, ".env"), "SECRET=still-tracked-value\n"); // gitleaks:allow — synthetic fixture + run("add", "-A"); + run("commit", "-qm", "oops, committed a secret"); + + const isTracked = () => { + try { execFileSync("git", ["ls-files", "--error-unmatch", ".env"], { cwd: repo, stdio: "pipe" }); return true; } + catch { return false; } + }; + check("fixture starts tracked", isTracked()); + + const inRepo = await startEnviewUi({ roots: [repo], port: 4184, maxDepth: 2 }); + const untrackCall = (path, opts = {}) => fetch(`http://127.0.0.1:4184${path}`, { + ...opts, + headers: { "x-enview-token": inRepo.token, "content-type": "application/json", ...(opts.headers || {}) }, + }); + + const res = await (await untrackCall("/api/action", { + method: "POST", + body: JSON.stringify({ file: join(repo, ".env"), action: "untrack" }), + })).json(); + check("untrack reports ok", res.ok === true, JSON.stringify(res)); + check("untrack adds to .gitignore", res.gitignoreAdded === true); + check("file no longer tracked by git", !isTracked()); + check(".gitignore contains the filename", readFileSync(join(repo, ".gitignore"), "utf-8").includes(".env")); + check("working copy content is untouched", readFileSync(join(repo, ".env"), "utf-8") === "SECRET=still-tracked-value\n"); + check("no backup written — git rm --cached never touches file contents", !readdirSync(repo).some((f) => f.startsWith(".env.bak."))); + + inRepo.server.close(); + rmSync(repo, { recursive: true, force: true }); +} + // ---------------------------------------------------------------- git history console.log("\n--- git history ---"); { @@ -163,6 +203,43 @@ console.log("\n--- git history ---"); rmSync(repo, { recursive: true, force: true }); } +// ---------------------------------------------------------------- history endpoint +// Same fixture shape as the CLI-level history scan above, but exercised through the UI's own +// HTTP endpoint — the thing the browser button actually calls. +console.log("\n--- history endpoint ---"); +{ + const { execFileSync } = await import("node:child_process"); + const repo = mkdtempSync(join(tmpdir(), "enview-history-api-")); + const run = (...args) => execFileSync("git", args, { cwd: repo, stdio: "pipe" }); + run("init", "-q"); + run("config", "user.email", "t@t.test"); + run("config", "user.name", "t"); + writeFileSync(join(repo, ".env"), "OPENAI_API_KEY=sk-endpoint-secret-value\n"); // gitleaks:allow — synthetic fixture + run("add", "-A"); + run("commit", "-qm", "oops"); + rmSync(join(repo, ".env")); + writeFileSync(join(repo, ".gitignore"), ".env\n"); + run("add", "-A"); + run("commit", "-qm", "remove env"); + + const inRepo = await startEnviewUi({ roots: [repo], port: 4185, maxDepth: 2 }); + const historyCall = (path) => fetch(`http://127.0.0.1:4185${path}`, { headers: { "x-enview-token": inRepo.token } }); + + const badRes = await historyCall(`/api/history?path=${encodeURIComponent(dir)}`); + check("path outside this server's scan allowlist is rejected", badRes.status === 404, `got ${badRes.status}`); + + const body = await (await historyCall(`/api/history?path=${encodeURIComponent(repo)}`)).json(); + const found = body.findings.find((f) => f.file === ".env"); + check("history endpoint finds the deleted secret", !!found, JSON.stringify(body)); + check("reports it is no longer tracked", found?.stillTracked === false); + check("recovers the key name", !!found?.keys.includes("OPENAI_API_KEY"), JSON.stringify(found)); + check("remedy text present", typeof found?.remedy === "string" && found.remedy.length > 0); + check("never returns the historical value", !JSON.stringify(body).includes("sk-endpoint-secret-value")); + + inRepo.server.close(); + rmSync(repo, { recursive: true, force: true }); +} + // ---------------------------------------------------------------- gitignore detection // This is the check that was silently wrong on Windows for every file. It agrees with git or // it is worthless, so the test asserts both directions. From 71156d7c0822ebeb491172d3417bae851dd1aec7 Mon Sep 17 00:00:00 2001 From: MrPickering Date: Tue, 25 Aug 2026 16:53:43 -0700 Subject: [PATCH 2/2] Declare the default port as a const the port checker can see The 4174 allocation in ops-index/engines.json was reported as DRIFT because a destructuring default is invisible to check-ports' deliberately narrow matchers. Behavior unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VBQ1D1iapVwHLc3Gzbi7xY Session-Id: 5ff81103-ff56-4481-a0d8-dae58e784c41 Agent: claude-code --- src/server.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/server.js b/src/server.js index 6fd7d5b..085f8ea 100644 --- a/src/server.js +++ b/src/server.js @@ -235,7 +235,11 @@ function collectBody(request, limit = 256 * 1024) { }); } -export function createEnviewServer({ roots, port = 4174, token, maxDepth = 4 }) { +// Allocated in ops-index/engines.json; a destructuring default is invisible to the port +// checker's matchers, so the allocation is declared as a const it can see. +const DEFAULT_PORT = 4174; + +export function createEnviewServer({ roots, port = DEFAULT_PORT, token, maxDepth = 4 }) { const authToken = token || randomBytes(24).toString('hex'); // Only files discovered by a scan may be touched. A path arriving in a request is checked // against this set rather than sanitised — an allowlist cannot be escaped by clever encoding.