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']); }