Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/scanner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
39 changes: 37 additions & 2 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -234,15 +235,27 @@ 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.
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();
Expand All @@ -253,6 +266,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}`);

Expand Down Expand Up @@ -355,10 +374,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 });
Expand Down
38 changes: 36 additions & 2 deletions src/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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}
</style>
</head>
<body>
Expand Down Expand Up @@ -141,7 +146,8 @@ <h1>🦅 en<span>view</span></h1>
app.innerHTML = data.projects.map((p) => `
<section class="project">
<h2>${esc(p.name)}</h2>
<p class="path">${esc(p.path)}</p>
<p class="path">${esc(p.path)} <button data-act="scan-history" data-project="${esc(p.path)}">scan git history</button></p>
<div class="history-panel"></div>
${p.files.map((f) => `
<div class="file">
<div class="file-head">
Expand Down Expand Up @@ -169,6 +175,7 @@ <h2>${esc(p.name)}</h2>
<button data-act="add" class="primary">+ add variable</button>
${!f.hasExample ? '<button data-act="generate-example">generate .env.example</button>' : ""}
${f.inGitRepo && !f.gitIgnored ? '<button data-act="gitignore">add to .gitignore</button>' : ""}
${f.gitTracked ? '<button data-act="untrack" class="danger" title="git rm --cached + gitignore. Does not touch history — rotate the values too.">stop tracking</button>' : ""}
</div>
</div>`).join("")}
</section>`).join("");
Expand Down Expand Up @@ -245,10 +252,37 @@ <h2>${esc(p.name)}</h2>
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) => `<div class="history-finding">
<b>${esc(f.file)}</b> ${f.stillTracked ? '<span class="chip bad">still tracked</span>' : '<span class="chip warn">removed, still in history</span>'}
${f.keys.length ? `<div class="k">${f.keys.map(esc).join(", ")}</div>` : ""}
<p class="note">${esc(f.remedy)}</p>
</div>`).join("")
: '<p class="note">No risky paths found in this repo’s history.</p>';
} 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")) {
Expand Down
77 changes: 77 additions & 0 deletions test/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---");
{
Expand Down Expand Up @@ -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.
Expand Down