diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
index f474174cd..b7a450455 100644
--- a/.github/workflows/build-and-test.yml
+++ b/.github/workflows/build-and-test.yml
@@ -9,6 +9,9 @@ on:
- main
workflow_dispatch:
+permissions:
+ contents: read
+
jobs:
secret-scan:
name: Secret Scan
diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml
index 7a483144f..f6053db9f 100644
--- a/.github/workflows/create-release.yml
+++ b/.github/workflows/create-release.yml
@@ -18,6 +18,9 @@ on:
type: boolean
default: false
+permissions:
+ contents: read
+
concurrency:
group: release-${{ github.event.inputs.version || github.ref || github.run_id }}
cancel-in-progress: false
diff --git a/.github/workflows/deploy-demo-server.yml b/.github/workflows/deploy-demo-server.yml
index 7d9f2884c..b2052a939 100644
--- a/.github/workflows/deploy-demo-server.yml
+++ b/.github/workflows/deploy-demo-server.yml
@@ -5,6 +5,9 @@ on:
# schedule:
# - cron: '0 0 * * *' # Nightly at midnight
+permissions:
+ contents: read
+
jobs:
deploy:
runs-on: ubuntu-latest
diff --git a/.github/workflows/eval-model-matrix.yml b/.github/workflows/eval-model-matrix.yml
index 9fcf5737e..1884d2c1d 100644
--- a/.github/workflows/eval-model-matrix.yml
+++ b/.github/workflows/eval-model-matrix.yml
@@ -19,6 +19,9 @@ on:
description: Pulse API base URL (e.g. http://127.0.0.1:7655)
required: true
+permissions:
+ contents: read
+
jobs:
eval:
name: Model Matrix Eval
diff --git a/.github/workflows/helm-ci.yml b/.github/workflows/helm-ci.yml
index 533857202..87ae1f66f 100644
--- a/.github/workflows/helm-ci.yml
+++ b/.github/workflows/helm-ci.yml
@@ -16,6 +16,9 @@ on:
- "README.md"
workflow_dispatch: {}
+permissions:
+ contents: read
+
jobs:
lint:
name: Lint and Render Chart
diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml
index cfa6476de..a14a557fe 100644
--- a/.github/workflows/test-e2e.yml
+++ b/.github/workflows/test-e2e.yml
@@ -22,6 +22,9 @@ on:
- '.github/workflows/test-e2e.yml'
workflow_dispatch:
+permissions:
+ contents: read
+
jobs:
@@ -29,6 +32,8 @@ jobs:
name: Playwright Core E2E
runs-on: ubuntu-latest
timeout-minutes: 45
+ permissions:
+ contents: read
# E2E tests are smoke tests - they run but don't block merges
# This reduces friction from flaky tests while maintaining visibility
continue-on-error: true
diff --git a/.github/workflows/test-updates.yml b/.github/workflows/test-updates.yml
index 00e748d88..7bddc8fc3 100644
--- a/.github/workflows/test-updates.yml
+++ b/.github/workflows/test-updates.yml
@@ -23,6 +23,9 @@ on:
- 'tests/integration/**'
workflow_dispatch: # Allow manual triggering
+permissions:
+ contents: read
+
jobs:
@@ -30,6 +33,9 @@ jobs:
name: Update Flow Integration Tests
runs-on: ubuntu-latest
timeout-minutes: 30
+ permissions:
+ contents: read
+ issues: write
steps:
- name: Checkout code
diff --git a/.github/workflows/update-demo-server.yml b/.github/workflows/update-demo-server.yml
index b87a38814..545dd5907 100644
--- a/.github/workflows/update-demo-server.yml
+++ b/.github/workflows/update-demo-server.yml
@@ -10,6 +10,9 @@ on:
required: true
type: string
+permissions:
+ contents: read
+
jobs:
update-demo:
# Only run for stable releases (not pre-releases) or manual dispatch
diff --git a/frontend-modern/src/components/Dashboard/ThresholdSlider.tsx b/frontend-modern/src/components/Dashboard/ThresholdSlider.tsx
index 3f884b828..11d6357f6 100644
--- a/frontend-modern/src/components/Dashboard/ThresholdSlider.tsx
+++ b/frontend-modern/src/components/Dashboard/ThresholdSlider.tsx
@@ -125,7 +125,7 @@ export function ThresholdSlider(props: ThresholdSliderProps) {
- {props.type === 'temperature' ? `${props.value}${getTemperatureSymbol().replace('°', '°')}` : `${props.value}%`}
+ {props.type === 'temperature' ? `${props.value}${getTemperatureSymbol()}` : `${props.value}%`}
diff --git a/frontend-modern/src/components/SetupWizard/SetupWizard.tsx b/frontend-modern/src/components/SetupWizard/SetupWizard.tsx
index f49130273..582f9e16a 100644
--- a/frontend-modern/src/components/SetupWizard/SetupWizard.tsx
+++ b/frontend-modern/src/components/SetupWizard/SetupWizard.tsx
@@ -43,14 +43,13 @@ export const SetupWizard: Component = (props) => {
const raw = sessionStorage.getItem(STORAGE_KEYS.SETUP_CREDENTIALS);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial;
- if (!parsed.username || !parsed.password || !parsed.apiToken) {
+ if (!parsed.username || !parsed.apiToken) {
sessionStorage.removeItem(STORAGE_KEYS.SETUP_CREDENTIALS);
return null;
}
return {
...defaultWizardState,
username: parsed.username,
- password: parsed.password,
apiToken: parsed.apiToken,
};
} catch (_err) {
diff --git a/frontend-modern/src/components/SetupWizard/steps/CompleteStep.tsx b/frontend-modern/src/components/SetupWizard/steps/CompleteStep.tsx
index 1b3958344..9bcc5a38f 100644
--- a/frontend-modern/src/components/SetupWizard/steps/CompleteStep.tsx
+++ b/frontend-modern/src/components/SetupWizard/steps/CompleteStep.tsx
@@ -143,6 +143,9 @@ export const CompleteStep: Component = (props) => {
const handleCopy = async (type: 'password' | 'token' | 'install', value?: string) => {
const copyValue = value || (type === 'password' ? props.state.password : props.state.apiToken);
+ if (!copyValue) {
+ return;
+ }
const success = await copyToClipboard(copyValue);
if (success) {
setCopied(type);
@@ -165,6 +168,9 @@ export const CompleteStep: Component = (props) => {
const downloadCredentials = () => {
const baseUrl = getPulseBaseUrl();
+ const passwordSection = props.state.password
+ ? `Password: ${props.state.password}\n`
+ : 'Password: not stored after reload; use the password you chose during setup or reset it in Settings.\n';
const content = `Pulse Credentials
==================
Generated: ${new Date().toISOString()}
@@ -173,7 +179,7 @@ Web Login:
----------
URL: ${baseUrl}
Username: ${props.state.username}
-Password: ${props.state.password}
+${passwordSection}
API Token:
----------
diff --git a/frontend-modern/src/components/SetupWizard/steps/SecurityStep.tsx b/frontend-modern/src/components/SetupWizard/steps/SecurityStep.tsx
index 9fe67c2d5..1359be171 100644
--- a/frontend-modern/src/components/SetupWizard/steps/SecurityStep.tsx
+++ b/frontend-modern/src/components/SetupWizard/steps/SecurityStep.tsx
@@ -83,7 +83,6 @@ export const SecurityStep: Component = (props) => {
STORAGE_KEYS.SETUP_CREDENTIALS,
JSON.stringify({
username: username(),
- password: finalPassword,
apiToken: token,
createdAt: new Date().toISOString(),
}),
diff --git a/frontend-modern/src/components/shared/Tooltip.tsx b/frontend-modern/src/components/shared/Tooltip.tsx
index ddd04ee4c..d4b860edf 100644
--- a/frontend-modern/src/components/shared/Tooltip.tsx
+++ b/frontend-modern/src/components/shared/Tooltip.tsx
@@ -17,18 +17,6 @@ interface TooltipProps extends TooltipOptions {
visible: boolean;
}
-// Sanitize tooltip content to prevent XSS
-function sanitizeContent(content: string): string {
- // Remove any HTML tags and encode special characters
- return content
- .replace(/<[^>]*>/g, '') // Remove HTML tags
- .replace(/&/g, '&') // Encode ampersands
- .replace(//g, '>') // Encode greater than
- .replace(/"/g, '"') // Encode quotes
- .replace(/'/g, '''); // Encode apostrophes
-}
-
const Tooltip: Component = (props) => {
let tooltipRef: HTMLDivElement | undefined;
const [position, setPosition] = createSignal({ left: 0, top: 0 });
@@ -75,7 +63,7 @@ const Tooltip: Component = (props) => {
opacity: props.visible ? '1' : '0',
transition: 'opacity 120ms ease-out',
}}
- textContent={sanitizeContent(props.content)}
+ textContent={props.content}
/>
diff --git a/frontend-modern/src/utils/logger.ts b/frontend-modern/src/utils/logger.ts
index 4c09db316..0d646c2f9 100644
--- a/frontend-modern/src/utils/logger.ts
+++ b/frontend-modern/src/utils/logger.ts
@@ -3,7 +3,7 @@ const isDev = import.meta.env.DEV;
export const logger = {
debug: (message: string, data?: unknown) => {
- if (isDev) console.log(`[DEBUG] ${message}`, data || '');
+ if (isDev) console.log('[DEBUG]', message, data ?? '');
},
info: (message: string, data?: unknown) => {
@@ -14,16 +14,16 @@ export const logger = {
message.includes('error') ||
message.includes('failed')
) {
- console.log(`[INFO] ${message}`, data || '');
+ console.log('[INFO]', message, data ?? '');
}
},
warn: (message: string, data?: unknown) => {
- console.warn(`[WARN] ${message}`, data || '');
+ console.warn('[WARN]', message, data ?? '');
},
error: (message: string, error?: unknown) => {
- console.error(`[ERROR] ${message}`, error || '');
+ console.error('[ERROR]', message, error ?? '');
},
};
diff --git a/frontend-modern/src/utils/nodes.ts b/frontend-modern/src/utils/nodes.ts
index e04c601d4..b9d6da2eb 100644
--- a/frontend-modern/src/utils/nodes.ts
+++ b/frontend-modern/src/utils/nodes.ts
@@ -5,7 +5,7 @@ type DisplayableNode = Pick &
const sanitize = (value: string): string => value.trim().toLowerCase().replace(/[^a-z0-9]/g, '');
-const escapeRegExp = (value: string): string => value.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\$&');
+const escapeRegExp = (value: string): string => value.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
const extractHostname = (value: string): string => {
if (!value) return '';
diff --git a/tests/integration/scripts/pretest.mjs b/tests/integration/scripts/pretest.mjs
index 5f1263fc6..9cfdcfb84 100644
--- a/tests/integration/scripts/pretest.mjs
+++ b/tests/integration/scripts/pretest.mjs
@@ -1,5 +1,6 @@
import { spawn } from 'node:child_process';
import http from 'node:http';
+import https from 'node:https';
// Add signal handlers to debug unexpected termination
const signals = ['SIGTERM', 'SIGINT', 'SIGHUP', 'SIGPIPE', 'SIGQUIT'];
@@ -60,10 +61,17 @@ const waitForHealth = async (healthURL, timeoutMs = 120_000) => {
console.log(`[pretest] Waiting for ${healthURL} to become healthy...`);
const startedAt = Date.now();
let attempt = 0;
+ const target = new URL(healthURL);
+ const client = target.protocol === 'https:' ? https : http;
+ const allowInsecureTLS = truthy(process.env.PULSE_E2E_INSECURE_TLS) && target.protocol === 'https:';
+ const agent = allowInsecureTLS ? new https.Agent({ rejectUnauthorized: false }) : undefined;
const checkHealth = () => {
return new Promise((resolve) => {
- const req = http.get(healthURL, (res) => {
+ const req = client.get({
+ ...target,
+ agent,
+ }, (res) => {
res.resume(); // Consume response data to free up memory
resolve(res.statusCode >= 200 && res.statusCode < 300);
});
@@ -91,11 +99,6 @@ const waitForHealth = async (healthURL, timeoutMs = 120_000) => {
throw new Error(`Timed out waiting for ${healthURL} after ${attempt} attempts`);
};
-
-if (truthy(process.env.PULSE_E2E_INSECURE_TLS)) {
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
-}
-
if (!shouldSkipPlaywrightInstall) {
await run(npxCmd, ['playwright', 'install', 'chromium']);
}