From a35acb3ea9e0b2f694fc7f25cfc7b75393e6cf93 Mon Sep 17 00:00:00 2001 From: MrSphay Date: Sat, 2 May 2026 01:01:57 +0200 Subject: [PATCH] Harden app for release readiness --- .gitea/workflows/build-windows.yml | 21 ++++++++++++++++++++- README.md | 25 +++++++++++++++++++++++-- blueprint.md | 25 +++++++++++++++++++++++-- electron/main.ts | 21 ++++++++++++++++++++- index.html | 6 +++++- src/App.tsx | 9 +++++++-- src/env.ts | 21 ++++++++++++++++++--- 7 files changed, 116 insertions(+), 12 deletions(-) diff --git a/.gitea/workflows/build-windows.yml b/.gitea/workflows/build-windows.yml index e4cadb7..6f4ec75 100644 --- a/.gitea/workflows/build-windows.yml +++ b/.gitea/workflows/build-windows.yml @@ -42,6 +42,9 @@ jobs: - name: Install dependencies run: npm install + - name: Audit production dependencies + run: npm audit --omit=dev --audit-level=high + - name: Build Windows installer run: npm run dist:win @@ -58,7 +61,8 @@ jobs: - name: Publish to Gitea package registry shell: bash run: | - package_version="$(node -p "require('./package.json').version")-${GITHUB_SHA::7}" + app_version="$(node -p "require('./package.json').version")" + package_version="${app_version}-${GITHUB_SHA::7}" for artifact in release/*; do [ -f "$artifact" ] || continue @@ -68,3 +72,18 @@ jobs: --upload-file "$artifact" \ "https://git.wilkensxl.de/api/packages/MrSphay/generic/envhelper/${package_version}/${file_name}" done + + latest_url="https://git.wilkensxl.de/api/packages/MrSphay/generic/envhelper/latest" + curl --silent --show-error --user "MrSphay:${REGISTRY_TOKEN}" --request DELETE "${latest_url}" || true + + mkdir -p package-latest + cp "release/EnvHelper-${app_version}-setup-x64.exe" "package-latest/EnvHelper-setup-x64.exe" + cp "release/EnvHelper-${app_version}-portable-x64.exe" "package-latest/EnvHelper-portable-x64.exe" + + for artifact in package-latest/*; do + file_name="$(basename "$artifact")" + curl --fail-with-body \ + --user "MrSphay:${REGISTRY_TOKEN}" \ + --upload-file "$artifact" \ + "${latest_url}/${file_name}" + done diff --git a/README.md b/README.md index a747d8c..9047215 100644 --- a/README.md +++ b/README.md @@ -152,14 +152,23 @@ Manual defaults can always be added. They override automatically detected defaul ## Downloads and Artifacts -The Windows build produces two executable artifacts: +The current Windows build can be downloaded directly from the Gitea Generic Package registry: + +| Variant | Download | +| --- | --- | +| Installer | [EnvHelper-setup-x64.exe](https://git.wilkensxl.de/api/packages/MrSphay/generic/envhelper/latest/EnvHelper-setup-x64.exe) | +| Portable | [EnvHelper-portable-x64.exe](https://git.wilkensxl.de/api/packages/MrSphay/generic/envhelper/latest/EnvHelper-portable-x64.exe) | + +Private package downloads may require an active Gitea session or a token with package read access. + +Each build also produces versioned executable artifacts: ```text EnvHelper-0.1.0-setup-x64.exe EnvHelper-0.1.0-portable-x64.exe ``` -The files are published by the Gitea Runner as an Actions artifact and as a Generic Package. +The files are published by the Gitea Runner as an Actions artifact, as an immutable `version-sha` Generic Package, and as the moving `latest` Generic Package used by the links above.

-----------------------------------------------------

@@ -237,6 +246,18 @@ The generated output is committed as `README.md` so Gitea can render it directly EnvHelper generates values locally in the renderer using Web Crypto. It is a helper for `.env` templates and is not a replacement for a central secret manager in production infrastructure. +Security posture: + +| Area | State | +| --- | --- | +| Secret generation | Uses `crypto.getRandomValues` and `crypto.randomUUID` | +| Renderer isolation | Electron `contextIsolation` and sandbox are enabled | +| Node access | `nodeIntegration` is disabled in the renderer | +| Navigation | New windows and renderer navigation are blocked | +| Content policy | The app ships with a restrictive Content Security Policy | +| Default storage | Sensitive manual defaults such as passwords, tokens, and API keys are not persisted | +| External services | No `.env` input or generated secret is sent to external services | + ### Windows Defender and SmartScreen Windows may block or delay apps from unknown publishers. This is usually caused by missing reputation or by the absence of a trusted code-signing certificate. diff --git a/blueprint.md b/blueprint.md index beac2d0..51c9c29 100644 --- a/blueprint.md +++ b/blueprint.md @@ -121,14 +121,23 @@ Manual defaults can always be added. They override automatically detected defaul {{ template:section-line }} ## Downloads and Artifacts -The Windows build produces two executable artifacts: +The current Windows build can be downloaded directly from the Gitea Generic Package registry: + +| Variant | Download | +| --- | --- | +| Installer | [EnvHelper-setup-x64.exe](https://git.wilkensxl.de/api/packages/MrSphay/generic/envhelper/latest/EnvHelper-setup-x64.exe) | +| Portable | [EnvHelper-portable-x64.exe](https://git.wilkensxl.de/api/packages/MrSphay/generic/envhelper/latest/EnvHelper-portable-x64.exe) | + +Private package downloads may require an active Gitea session or a token with package read access. + +Each build also produces versioned executable artifacts: ```text EnvHelper-0.1.0-setup-x64.exe EnvHelper-0.1.0-portable-x64.exe ``` -The files are published by the Gitea Runner as an Actions artifact and as a Generic Package. +The files are published by the Gitea Runner as an Actions artifact, as an immutable `version-sha` Generic Package, and as the moving `latest` Generic Package used by the links above. {{ template:section-line }} ## Development @@ -202,6 +211,18 @@ The generated output is committed as `README.md` so Gitea can render it directly EnvHelper generates values locally in the renderer using Web Crypto. It is a helper for `.env` templates and is not a replacement for a central secret manager in production infrastructure. +Security posture: + +| Area | State | +| --- | --- | +| Secret generation | Uses `crypto.getRandomValues` and `crypto.randomUUID` | +| Renderer isolation | Electron `contextIsolation` and sandbox are enabled | +| Node access | `nodeIntegration` is disabled in the renderer | +| Navigation | New windows and renderer navigation are blocked | +| Content policy | The app ships with a restrictive Content Security Policy | +| Default storage | Sensitive manual defaults such as passwords, tokens, and API keys are not persisted | +| External services | No `.env` input or generated secret is sent to external services | + ### Windows Defender and SmartScreen Windows may block or delay apps from unknown publishers. This is usually caused by missing reputation or by the absence of a trusted code-signing certificate. diff --git a/electron/main.ts b/electron/main.ts index 90f7c15..73024b8 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -6,6 +6,8 @@ import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +app.setAppUserModelId("de.wilkensxl.envhelper"); + async function createWindow() { const win = new BrowserWindow({ width: 1220, @@ -19,11 +21,24 @@ async function createWindow() { webPreferences: { preload: path.join(__dirname, "preload.js"), contextIsolation: true, - nodeIntegration: false + nodeIntegration: false, + sandbox: true, + webSecurity: true, + allowRunningInsecureContent: false, + devTools: !app.isPackaged } }); Menu.setApplicationMenu(null); + win.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + win.webContents.on("will-navigate", (event, url) => { + const currentUrl = win.webContents.getURL(); + if (!currentUrl || url === currentUrl || (!app.isPackaged && url.startsWith("http://127.0.0.1:5173"))) { + return; + } + + event.preventDefault(); + }); if (!app.isPackaged) { await win.loadURL("http://127.0.0.1:5173"); @@ -56,6 +71,10 @@ ipcMain.handle("envhelper:open-file", async (event) => { }); ipcMain.handle("envhelper:save-file", async (event, content: string) => { + if (typeof content !== "string") { + throw new TypeError("Save content must be a string."); + } + const owner = BrowserWindow.fromWebContents(event.sender); const options = { defaultPath: ".env", diff --git a/index.html b/index.html index 5ba5ac3..a568121 100644 --- a/index.html +++ b/index.html @@ -1,7 +1,11 @@ - + + EnvHelper diff --git a/src/App.tsx b/src/App.tsx index b18663d..ee74527 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -260,12 +260,17 @@ function readDefaults(): EnvDefault[] { try { const parsed = JSON.parse(stored) as EnvDefault[]; - return Array.isArray(parsed) ? parsed : initialDefaults; + return Array.isArray(parsed) ? parsed.filter((entry) => entry.key && typeof entry.value === "string" && canPersistDefault(entry.key)) : initialDefaults; } catch { return initialDefaults; } } +function canPersistDefault(key: string): boolean { + const signal = normalizeKey(key); + return !["PASSWORD", "SECRET", "TOKEN", "PRIVATE_KEY", "API_KEY", "ACCESS_KEY"].some((marker) => signal.includes(marker)); +} + function readIgnoredAutoDefaults(): string[] { const stored = localStorage.getItem("envhelper-ignored-auto-defaults"); if (!stored) { @@ -518,7 +523,7 @@ export default function App() { }, [language]); useEffect(() => { - localStorage.setItem("envhelper-defaults", JSON.stringify(manualDefaults)); + localStorage.setItem("envhelper-defaults", JSON.stringify(manualDefaults.filter((entry) => canPersistDefault(entry.key)))); }, [manualDefaults]); useEffect(() => { diff --git a/src/env.ts b/src/env.ts index 4328fae..350bbe2 100644 --- a/src/env.ts +++ b/src/env.ts @@ -359,13 +359,28 @@ function randomBytes(length: number): Uint8Array { function randomInteger(min: number, max: number): number { const range = max - min + 1; const array = new Uint32Array(1); - crypto.getRandomValues(array); + const limit = Math.floor(0x100000000 / range) * range; + + do { + crypto.getRandomValues(array); + } while (array[0] >= limit); + return min + (array[0] % range); } function randomString(length: number, alphabet: string): string { - const bytes = randomBytes(length); - return [...bytes].map((byte) => alphabet[byte % alphabet.length]).join(""); + let value = ""; + const limit = Math.floor(256 / alphabet.length) * alphabet.length; + + while (value.length < length) { + for (const byte of randomBytes(length - value.length)) { + if (byte < limit) { + value += alphabet[byte % alphabet.length]; + } + } + } + + return value; } function bytesToBase64(bytes: Uint8Array): string {