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 {