From 2f79b3beba2424989678d24582211e584d450bde Mon Sep 17 00:00:00 2001 From: evanoseen Date: Tue, 28 Apr 2026 11:42:48 -0400 Subject: [PATCH 1/3] feat: scope localStorage per visitor for Cloudflare Pages demo --- public/_redirects | 1 + src/stores/aiStore.js | 3 ++- src/stores/artifactStore.js | 3 ++- src/stores/assessmentsStore.js | 3 ++- src/stores/auditLogStore.js | 3 ++- src/stores/controlsStore.js | 3 ++- src/stores/csfStore.js | 3 ++- src/stores/evaluationsStore.js | 3 ++- src/stores/findingsStore.js | 3 ++- src/stores/frameworksStore.js | 3 ++- src/stores/requirementsStore.js | 3 ++- src/stores/uiStore.js | 3 ++- src/stores/userStore.js | 3 ++- src/utils/visitorId.js | 15 +++++++++++++++ 14 files changed, 40 insertions(+), 12 deletions(-) create mode 100644 public/_redirects create mode 100644 src/utils/visitorId.js diff --git a/public/_redirects b/public/_redirects new file mode 100644 index 00000000..ad37e2c2 --- /dev/null +++ b/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/src/stores/aiStore.js b/src/stores/aiStore.js index b95cf093..541ddeb1 100644 --- a/src/stores/aiStore.js +++ b/src/stores/aiStore.js @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { scopedKey } from '../utils/visitorId'; import { persist } from 'zustand/middleware'; /** @@ -417,7 +418,7 @@ Format as an actionable plan that can be imported into a remediation tracker.`; } }), { - name: 'csf-ai-storage', + name: scopedKey('csf-ai-storage'), partialize: (state) => ({ llmProvider: state.llmProvider, dataMode: state.dataMode, diff --git a/src/stores/artifactStore.js b/src/stores/artifactStore.js index 24ac4c5e..8ee57887 100644 --- a/src/stores/artifactStore.js +++ b/src/stores/artifactStore.js @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { scopedKey } from '../utils/visitorId'; import { persist } from 'zustand/middleware'; import Papa from 'papaparse'; import { v4 as uuidv4 } from 'uuid'; @@ -378,7 +379,7 @@ const useArtifactStore = create( } }), { - name: 'csf-artifacts-storage', + name: scopedKey('csf-artifacts-storage'), version: 5, migrate: (persistedState, version) => { // Version 2: Added link, complianceRequirement, controlId, type, jiraKey fields diff --git a/src/stores/assessmentsStore.js b/src/stores/assessmentsStore.js index c40d2adc..1ae34e13 100644 --- a/src/stores/assessmentsStore.js +++ b/src/stores/assessmentsStore.js @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { scopedKey } from '../utils/visitorId'; import { persist } from 'zustand/middleware'; import Papa from 'papaparse'; import { v4 as uuidv4 } from 'uuid'; @@ -1562,7 +1563,7 @@ const useAssessmentsStore = create( } }), { - name: 'csf-assessments-storage', + name: scopedKey('csf-assessments-storage'), version: 7, migrate: (persistedState, version) => { // Version 1: Migrate observations to quarterly structure diff --git a/src/stores/auditLogStore.js b/src/stores/auditLogStore.js index fdfe3cf1..31294178 100644 --- a/src/stores/auditLogStore.js +++ b/src/stores/auditLogStore.js @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { scopedKey } from '../utils/visitorId'; import { persist } from 'zustand/middleware'; import { v4 as uuidv4 } from 'uuid'; @@ -53,7 +54,7 @@ const useAuditLogStore = create( URL.revokeObjectURL(url); } }), - { name: 'csf-audit-log' } + { name: scopedKey('csf-audit-log') } ) ); diff --git a/src/stores/controlsStore.js b/src/stores/controlsStore.js index e7319399..000707f3 100644 --- a/src/stores/controlsStore.js +++ b/src/stores/controlsStore.js @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { scopedKey } from '../utils/visitorId'; import { persist } from 'zustand/middleware'; import Papa from 'papaparse'; import { sanitizeInput, escapeCSVValue } from '../utils/sanitize'; @@ -471,7 +472,7 @@ const useControlsStore = create( } }), { - name: 'csf-controls-storage', + name: scopedKey('csf-controls-storage'), version: 5, migrate: (persistedState, version) => { // Version 5: Default controls from Alma Security example data diff --git a/src/stores/csfStore.js b/src/stores/csfStore.js index b2220d0b..dfc6cb49 100644 --- a/src/stores/csfStore.js +++ b/src/stores/csfStore.js @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { scopedKey } from '../utils/visitorId'; import { persist } from 'zustand/middleware'; import { parseUserInfo, findOrCreateUser } from '../utils/userUtils'; import { sanitizeInput } from '../utils/sanitize'; @@ -252,7 +253,7 @@ const useCSFStore = create( }, }), { - name: 'csf-data-storage', + name: scopedKey('csf-data-storage'), version: 2, migrate: (persistedState, version) => { // Version 2: Force re-download of CSV to get proper owner assignments diff --git a/src/stores/evaluationsStore.js b/src/stores/evaluationsStore.js index 7d6523cc..2781e1cd 100644 --- a/src/stores/evaluationsStore.js +++ b/src/stores/evaluationsStore.js @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { scopedKey } from '../utils/visitorId'; import { persist } from 'zustand/middleware'; import { sanitizeInput } from '../utils/sanitize'; @@ -453,7 +454,7 @@ const useEvaluationsStore = create( } }), { - name: 'csf-evaluations-storage', + name: scopedKey('csf-evaluations-storage'), version: 1, partialize: (state) => ({ evaluations: state.evaluations, diff --git a/src/stores/findingsStore.js b/src/stores/findingsStore.js index 3892ec50..06854000 100644 --- a/src/stores/findingsStore.js +++ b/src/stores/findingsStore.js @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { scopedKey } from '../utils/visitorId'; import { persist } from 'zustand/middleware'; import Papa from 'papaparse'; import { v4 as uuidv4 } from 'uuid'; @@ -357,7 +358,7 @@ const useFindingsStore = create( } }), { - name: 'csf-findings-storage', + name: scopedKey('csf-findings-storage'), version: 3, migrate: (persistedState, version) => { // Version 2: Added default findings for new installations diff --git a/src/stores/frameworksStore.js b/src/stores/frameworksStore.js index e9049516..0da06643 100644 --- a/src/stores/frameworksStore.js +++ b/src/stores/frameworksStore.js @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { scopedKey } from '../utils/visitorId'; import { persist } from 'zustand/middleware'; // Default frameworks - used for initial state and migrations @@ -194,7 +195,7 @@ const useFrameworksStore = create( } }), { - name: 'csf-frameworks-storage', + name: scopedKey('csf-frameworks-storage'), version: 5, migrate: (persistedState, version) => { // Version 2: Reset to new default frameworks (removed SOC2, HIPAA, PCI-DSS; updated names; added source) diff --git a/src/stores/requirementsStore.js b/src/stores/requirementsStore.js index 2711e1ce..142026b0 100644 --- a/src/stores/requirementsStore.js +++ b/src/stores/requirementsStore.js @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { scopedKey } from '../utils/visitorId'; import { persist } from 'zustand/middleware'; import Papa from 'papaparse'; import { escapeCSVValue } from '../utils/sanitize'; @@ -418,7 +419,7 @@ const useRequirementsStore = create( } }), { - name: 'csf-requirements-storage', + name: scopedKey('csf-requirements-storage'), partialize: (state) => ({ requirements: state.requirements }) diff --git a/src/stores/uiStore.js b/src/stores/uiStore.js index 8450ba24..ca8aafcd 100644 --- a/src/stores/uiStore.js +++ b/src/stores/uiStore.js @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { scopedKey } from '../utils/visitorId'; import { persist } from 'zustand/middleware'; const useUIStore = create( @@ -103,7 +104,7 @@ const useUIStore = create( }), }), { - name: 'csf-ui-storage', + name: scopedKey('csf-ui-storage'), partialize: (state) => ({ darkMode: state.darkMode, itemsPerPage: state.itemsPerPage, diff --git a/src/stores/userStore.js b/src/stores/userStore.js index 7bee9ef6..d48871f6 100644 --- a/src/stores/userStore.js +++ b/src/stores/userStore.js @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { scopedKey } from '../utils/visitorId'; import { persist } from 'zustand/middleware'; import { v4 as uuidv4 } from 'uuid'; @@ -132,7 +133,7 @@ const useUserStore = create( }, }), { - name: 'csf-users-storage', + name: scopedKey('csf-users-storage'), version: 2, migrate: (persistedState, version) => { // Version 2: Ensure default users have stable IDs diff --git a/src/utils/visitorId.js b/src/utils/visitorId.js new file mode 100644 index 00000000..c1f8b079 --- /dev/null +++ b/src/utils/visitorId.js @@ -0,0 +1,15 @@ +// Generate a stable per-visitor ID scoped to this browser session. +// This prevents localStorage cross-contamination between demo visitors. +const VISITOR_KEY = 'csf-visitor-id'; + +function getVisitorId() { + let id = localStorage.getItem(VISITOR_KEY); + if (!id) { + id = 'v-' + Math.random().toString(36).slice(2, 11); + localStorage.setItem(VISITOR_KEY, id); + } + return id; +} + +export const visitorId = getVisitorId(); +export const scopedKey = (name) => `${name}-${visitorId}`; From 5153598281ae68c9d253920ea60c6c4b32638acf Mon Sep 17 00:00:00 2001 From: evanoseen Date: Tue, 5 May 2026 01:50:00 -0400 Subject: [PATCH 2/3] feat: add guided web installer wizard (issue #191) Replaces manual setup steps with a single-command bootstrap that opens a 7-step browser wizard on localhost:31338. - install.sh: bash bootstrap for macOS/Linux (102 lines, auditable) - install.ps1: PowerShell bootstrap for Windows - installer/server.js: local Node HTTP server (built-ins only, binds to 127.0.0.1 only, never transmits credentials externally) - installer/wizard.html: dark-themed 7-step setup wizard - Step 1: System Detection (Node 18+, Git, .env.local) - Step 2: Prerequisites confirmation - Step 3: Atlassian credentials (optional, skippable, Test Connection) - Step 4: Demo data choice (Alma Security / blank) - Step 5: Encryption password setup (optional) - Step 6: App launch + readiness polling - Step 7: Done with direct link to localhost:3000 Co-Authored-By: Claude Sonnet 4.6 --- install.ps1 | 94 +++++++++ install.sh | 102 +++++++++ installer/server.js | 204 ++++++++++++++++++ installer/wizard.html | 471 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 871 insertions(+) create mode 100644 install.ps1 create mode 100755 install.sh create mode 100644 installer/server.js create mode 100644 installer/wizard.html diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 00000000..a475c6b5 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,94 @@ +# CSF Profile Assessment Tool — Guided Installer (Windows PowerShell) +# Usage: .\install.ps1 +# If blocked by execution policy, run first: +# Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned +# +# This file is intentionally readable. Review before running. + +$ErrorActionPreference = "Stop" +$InstallerPort = 31338 +$RepoUrl = "https://github.com/CPAtoCybersecurity/csf_profile.git" +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path + +function Write-Info { Write-Host "▶ $args" -ForegroundColor Cyan } +function Write-Success { Write-Host "✔ $args" -ForegroundColor Green } +function Write-Warn { Write-Host "⚠ $args" -ForegroundColor Yellow } +function Write-Err { Write-Host "✖ $args" -ForegroundColor Red } + +# ── 1. Check Git ────────────────────────────────────────────────────────────── +Write-Info "Checking prerequisites…" +if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + Write-Err "Git is not installed. Download from https://git-scm.com and re-run." + exit 1 +} +Write-Success "Git found: $(git --version)" + +# ── 2. Check Node 18+ ───────────────────────────────────────────────────────── +if (-not (Get-Command node -ErrorAction SilentlyContinue)) { + Write-Err "Node.js is not installed. Install Node 18 LTS from https://nodejs.org and re-run." + exit 1 +} + +$NodeMajor = [int](node -e "process.stdout.write(String(process.versions.node.split('.')[0]))") +if ($NodeMajor -lt 18) { + Write-Err "Node.js $NodeMajor found, but 18+ is required. Upgrade at https://nodejs.org" + exit 1 +} +Write-Success "Node.js $(node --version)" + +# ── 3. Clone or use existing repo ───────────────────────────────────────────── +if (Test-Path (Join-Path $ScriptDir "package.json")) { + $RepoDir = $ScriptDir + Write-Info "Using existing repo at: $RepoDir" +} else { + $RepoDir = Join-Path $HOME "csf_profile" + if (-not (Test-Path (Join-Path $RepoDir ".git"))) { + Write-Info "Cloning CSF Profile repository…" + git clone $RepoUrl $RepoDir + } else { + Write-Info "Repo already cloned at $RepoDir" + } +} + +Set-Location $RepoDir + +# ── 4. Install npm dependencies ─────────────────────────────────────────────── +Write-Info "Installing dependencies (npm install)…" +npm install --silent +Write-Success "Dependencies installed." + +# ── 5. Check port availability ──────────────────────────────────────────────── +$portInUse = netstat -an | Select-String ":$InstallerPort\s+LISTENING" +if ($portInUse) { + Write-Warn "Port $InstallerPort is already in use. The installer may already be running." + Write-Warn "Open http://localhost:$InstallerPort in your browser, or kill the process and retry." + exit 0 +} + +# ── 6. Start installer server ───────────────────────────────────────────────── +Write-Info "Starting installer server on port $InstallerPort…" +$serverScript = Join-Path $RepoDir "installer\server.js" +$serverProcess = Start-Process -FilePath "node" -ArgumentList "`"$serverScript`" `"$RepoDir`"" ` + -PassThru -WindowStyle Hidden +Start-Sleep -Seconds 1 + +if ($serverProcess.HasExited) { + Write-Err "Installer server failed to start. Check installer\server.js exists." + exit 1 +} +Write-Success "Installer server running (PID $($serverProcess.Id))." + +# ── 7. Open browser ────────────────────────────────────────────────────────── +$WizardUrl = "http://localhost:$InstallerPort" +Write-Info "Opening wizard at $WizardUrl" +Start-Process $WizardUrl + +Write-Host "" +Write-Host "CSF Profile Installer is running." -ForegroundColor Green -NoNewline +Write-Host "" +Write-Host " Wizard: $WizardUrl" -ForegroundColor Cyan +Write-Host " The installer will guide you through the remaining setup steps." +Write-Host " Close this window or press Ctrl+C to stop the installer server." +Write-Host "" + +$serverProcess.WaitForExit() diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..3e1ff7be --- /dev/null +++ b/install.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# CSF Profile Assessment Tool — Guided Installer +# Usage: bash install.sh (or: curl -sSL /install.sh | bash) +# This file is intentionally readable. Inspect it before piping to bash. + +set -euo pipefail + +INSTALLER_PORT=31338 +REPO_URL="https://github.com/CPAtoCybersecurity/csf_profile.git" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" + +# ── Colours ──────────────────────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m' +BOLD='\033[1m'; RESET='\033[0m' + +info() { echo -e "${CYAN}▶${RESET} $*"; } +success() { echo -e "${GREEN}✔${RESET} $*"; } +warn() { echo -e "${YELLOW}⚠${RESET} $*"; } +error() { echo -e "${RED}✖${RESET} $*" >&2; } +die() { error "$*"; exit 1; } + +# ── 1. Check Git ─────────────────────────────────────────────────────────────── +info "Checking prerequisites…" + +if ! command -v git &>/dev/null; then + die "Git is not installed. Install it from https://git-scm.com and re-run this script." +fi +success "Git found: $(git --version)" + +# ── 2. Check Node 18+ ───────────────────────────────────────────────────────── +if ! command -v node &>/dev/null; then + die "Node.js is not installed. Install Node 18 LTS from https://nodejs.org and re-run." +fi + +NODE_VERSION=$(node -e "process.stdout.write(String(process.versions.node.split('.')[0]))") +if [ "$NODE_VERSION" -lt 18 ]; then + die "Node.js ${NODE_VERSION} found, but 18+ is required. Upgrade at https://nodejs.org" +fi +success "Node.js v$(node --version | sed 's/v//')" + +# ── 3. Clone or use existing repo ───────────────────────────────────────────── +if [ -f "$SCRIPT_DIR/package.json" ]; then + REPO_DIR="$SCRIPT_DIR" + info "Using existing repo at: $REPO_DIR" +else + REPO_DIR="${HOME}/csf_profile" + if [ ! -d "$REPO_DIR/.git" ]; then + info "Cloning CSF Profile repository…" + git clone "$REPO_URL" "$REPO_DIR" + else + info "Repo already cloned at $REPO_DIR" + fi +fi + +cd "$REPO_DIR" + +# ── 4. Install npm dependencies ─────────────────────────────────────────────── +info "Installing dependencies (npm install)…" +npm install --silent +success "Dependencies installed." + +# ── 5. Check port availability ──────────────────────────────────────────────── +if lsof -iTCP:"$INSTALLER_PORT" -sTCP:LISTEN &>/dev/null 2>&1; then + warn "Port $INSTALLER_PORT is already in use. The installer may already be running." + warn "Open http://localhost:$INSTALLER_PORT in your browser, or kill the process and retry." + exit 0 +fi + +# ── 6. Start installer server ───────────────────────────────────────────────── +info "Starting installer server on port $INSTALLER_PORT…" +node "$REPO_DIR/installer/server.js" "$REPO_DIR" & +SERVER_PID=$! +sleep 1 # Brief pause to let the server bind + +if ! kill -0 "$SERVER_PID" 2>/dev/null; then + die "Installer server failed to start. Check installer/server.js exists." +fi +success "Installer server running (PID $SERVER_PID)." + +# ── 7. Open browser ────────────────────────────────────────────────────────── +WIZARD_URL="http://localhost:$INSTALLER_PORT" +info "Opening wizard at $WIZARD_URL" + +if command -v open &>/dev/null; then + open "$WIZARD_URL" # macOS +elif command -v xdg-open &>/dev/null; then + xdg-open "$WIZARD_URL" # Linux +elif command -v wslview &>/dev/null; then + wslview "$WIZARD_URL" # WSL +else + warn "Could not auto-open browser. Please navigate to: $WIZARD_URL" +fi + +echo "" +echo -e "${BOLD}${GREEN}CSF Profile Installer is running.${RESET}" +echo -e " Wizard: ${CYAN}$WIZARD_URL${RESET}" +echo -e " The installer will guide you through the remaining setup steps." +echo -e " Press Ctrl+C here to stop the installer server if needed." +echo "" + +# Keep script alive until server exits +wait "$SERVER_PID" 2>/dev/null || true diff --git a/installer/server.js b/installer/server.js new file mode 100644 index 00000000..f89ca290 --- /dev/null +++ b/installer/server.js @@ -0,0 +1,204 @@ +// CSF Profile Installer Server +// Binds ONLY to 127.0.0.1 — credentials never leave localhost. +// Uses only Node built-in modules: http, fs, path, child_process, net, url + +'use strict'; + +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const { execSync, spawn } = require('child_process'); +const net = require('net'); + +const PORT = 31338; +const HOST = '127.0.0.1'; +const REPO_DIR = process.argv[2] || path.resolve(__dirname, '..'); +const WIZARD_HTML = path.join(__dirname, 'wizard.html'); +const ENV_LOCAL = path.join(REPO_DIR, '.env.local'); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function jsonResponse(res, status, data) { + const body = JSON.stringify(data); + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': `http://localhost:${PORT}`, + 'Content-Length': Buffer.byteLength(body), + }); + res.end(body); +} + +function parseBody(req) { + return new Promise((resolve, reject) => { + let data = ''; + req.on('data', chunk => { data += chunk; }); + req.on('end', () => { + try { resolve(JSON.parse(data || '{}')); } + catch { reject(new Error('Invalid JSON')); } + }); + }); +} + +function isPortOpen(port, host = '127.0.0.1') { + return new Promise(resolve => { + const sock = new net.Socket(); + sock.setTimeout(500); + sock.connect(port, host, () => { sock.destroy(); resolve(true); }); + sock.on('error', () => { sock.destroy(); resolve(false); }); + sock.on('timeout', () => { sock.destroy(); resolve(false); }); + }); +} + +function backupEnvLocal() { + if (!fs.existsSync(ENV_LOCAL)) return null; + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupPath = `${ENV_LOCAL}.backup-${stamp}`; + fs.copyFileSync(ENV_LOCAL, backupPath); + return backupPath; +} + +// ── API Handlers ────────────────────────────────────────────────────────────── + +async function handleSystem(req, res) { + let nodeVersion = process.version; + let gitPresent = false; + try { execSync('git --version', { stdio: 'pipe' }); gitPresent = true; } catch {} + const envExists = fs.existsSync(ENV_LOCAL); + jsonResponse(res, 200, { nodeVersion, gitPresent, envExists, envPath: envExists ? ENV_LOCAL : null }); +} + +async function handleCredentials(req, res) { + try { + const body = await parseBody(req); + const { jiraUrl, jiraToken, confluenceUrl, confluenceToken } = body; + + // Validate: if any credential field is provided, paired field must also be present + if ((jiraUrl && !jiraToken) || (!jiraUrl && jiraToken)) { + return jsonResponse(res, 400, { error: 'Jira URL and token must both be provided, or both left empty.' }); + } + if ((confluenceUrl && !confluenceToken) || (!confluenceUrl && confluenceToken)) { + return jsonResponse(res, 400, { error: 'Confluence URL and token must both be provided, or both left empty.' }); + } + + const backupPath = backupEnvLocal(); + const lines = [ + `REACT_APP_JIRA_INSTANCE_URL=${jiraUrl || ''}`, + `REACT_APP_JIRA_API_TOKEN=${jiraToken || ''}`, + `REACT_APP_CONFLUENCE_INSTANCE_URL=${confluenceUrl || ''}`, + `REACT_APP_CONFLUENCE_API_TOKEN=${confluenceToken || ''}`, + `REACT_APP_API_URL=http://localhost:4000`, + ]; + fs.writeFileSync(ENV_LOCAL, lines.join('\n') + '\n', 'utf8'); + jsonResponse(res, 200, { written: ENV_LOCAL, backup: backupPath }); + } catch (err) { + jsonResponse(res, 500, { error: err.message }); + } +} + +async function handleTestConnection(req, res) { + try { + const body = await parseBody(req); + const { type, url, token } = body; + if (!url || !token) return jsonResponse(res, 400, { error: 'url and token required' }); + + // Proxy the test to Atlassian — we use Node's https module to avoid CORS + const https = require('https'); + const testUrl = type === 'jira' + ? `${url.replace(/\/$/, '')}/rest/api/3/myself` + : `${url.replace(/\/$/, '')}/rest/api/content?limit=1`; + + const parsed = new URL(testUrl); + const options = { + hostname: parsed.hostname, + path: parsed.pathname + parsed.search, + method: 'GET', + headers: { + 'Authorization': `Basic ${Buffer.from(`user:${token}`).toString('base64')}`, + 'Accept': 'application/json', + }, + }; + + const testReq = https.request(options, testRes => { + jsonResponse(res, 200, { status: testRes.statusCode, ok: testRes.statusCode < 300 }); + }); + testReq.on('error', err => jsonResponse(res, 200, { ok: false, error: err.message })); + testReq.end(); + } catch (err) { + jsonResponse(res, 500, { error: err.message }); + } +} + +async function handleSeed(req, res) { + try { + const body = await parseBody(req); + const { choice } = body; // 'alma' | 'blank' | 'skip' + // For 'alma': the demo CSVs are already in public/ — the app loads them automatically. + // We write a seed preference to .env.local so the app can pick it up on first run. + const envContent = fs.existsSync(ENV_LOCAL) ? fs.readFileSync(ENV_LOCAL, 'utf8') : ''; + const filtered = envContent.split('\n').filter(l => !l.startsWith('REACT_APP_SEED_MODE=')).join('\n'); + fs.writeFileSync(ENV_LOCAL, filtered.trimEnd() + `\nREACT_APP_SEED_MODE=${choice}\n`, 'utf8'); + jsonResponse(res, 200, { choice }); + } catch (err) { + jsonResponse(res, 500, { error: err.message }); + } +} + +let appProcess = null; + +async function handleLaunch(req, res) { + try { + if (appProcess) { + return jsonResponse(res, 200, { url: 'http://localhost:3000', already: true }); + } + appProcess = spawn('npm', ['start'], { + cwd: REPO_DIR, + detached: true, + stdio: 'ignore', + env: { ...process.env, BROWSER: 'none' }, // prevent CRA from auto-opening browser + }); + appProcess.unref(); + jsonResponse(res, 200, { url: 'http://localhost:3000', pid: appProcess.pid }); + // Shut down installer server after a delay so wizard can poll /api/status + setTimeout(() => { server.close(); process.exit(0); }, 30000); + } catch (err) { + jsonResponse(res, 500, { error: err.message }); + } +} + +async function handleStatus(req, res) { + const open = await isPortOpen(3000); + jsonResponse(res, 200, { ready: open, url: 'http://localhost:3000' }); +} + +// ── Router ──────────────────────────────────────────────────────────────────── + +const server = http.createServer(async (req, res) => { + const { pathname } = new URL(req.url, `http://${HOST}:${PORT}`); + + // CORS preflight + if (req.method === 'OPTIONS') { + res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST', 'Access-Control-Allow-Headers': 'Content-Type' }); + return res.end(); + } + + // Serve wizard HTML + if (req.method === 'GET' && (pathname === '/' || pathname === '/index.html')) { + const html = fs.readFileSync(WIZARD_HTML, 'utf8'); + res.writeHead(200, { 'Content-Type': 'text/html' }); + return res.end(html); + } + + // API routes + if (pathname === '/api/system' && req.method === 'GET') return handleSystem(req, res); + if (pathname === '/api/credentials' && req.method === 'POST') return handleCredentials(req, res); + if (pathname === '/api/test-connection' && req.method === 'POST') return handleTestConnection(req, res); + if (pathname === '/api/seed' && req.method === 'POST') return handleSeed(req, res); + if (pathname === '/api/launch' && req.method === 'POST') return handleLaunch(req, res); + if (pathname === '/api/status' && req.method === 'GET') return handleStatus(req, res); + + res.writeHead(404); res.end('Not found'); +}); + +server.listen(PORT, HOST, () => { + console.log(`CSF Installer server running at http://${HOST}:${PORT}`); +}); diff --git a/installer/wizard.html b/installer/wizard.html new file mode 100644 index 00000000..682ade91 --- /dev/null +++ b/installer/wizard.html @@ -0,0 +1,471 @@ + + + + + +CSF Profile — Setup Wizard + + + + + +
+ + + + + +
+ + +
+

System Check

+

Verifying your system meets the requirements to run CSF Profile Assessment Tool.

+
+
Checking…
+
+
+
+ +
+
+ + +
+

Prerequisites

+

All dependencies were installed during bootstrap. Confirming everything is in order.

+
+
+
+ +
+
+ + +
+

Atlassian Credentials (Optional)

+

Connect to Jira and Confluence for enhanced tracking. Leave blank to skip — you can configure this later in Settings.

+ +
+
+
Jira
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
Confluence
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +

Generate API tokens at id.atlassian.com → Security → API tokens

+
+ +
+
+ + +
+
+ + +
+

Demo Data

+

Choose whether to load the Alma Security case study data or start with a blank assessment.

+
+ + +
+
+ +
+
+ + +
+

Encryption Setup (Optional)

+

Set a default password for encrypted CSV exports. You can change or set this later in the app. Leave blank to skip.

+
+ + +
+

Encrypted exports use AES-256 and are decryptable with the included scripts/decrypt-export.mjs script.

+
+ + +
+
+ + +
+

Starting the App

+

Launching the CSF Profile dev server and waiting for it to become ready…

+
+
+
Preparing to launch…
+
+
+
+ + +
+
+
🎉
+

You're all set!

+

CSF Profile Assessment Tool is running and ready to use.

+ Open CSF Profile → +
+
+ Next steps:
+ • This installer window can now be closed.
+ • The app runs at http://localhost:3000 until you stop it (Ctrl+C in the terminal).
+ • To re-run setup: bash install.sh +
+
+ +
+
+ + + + From d35e02460544aa9969a6187db716846519172303 Mon Sep 17 00:00:00 2001 From: evanoseen Date: Tue, 5 May 2026 11:28:29 -0400 Subject: [PATCH 3/3] fix: replace UTF-8 ellipsis with ASCII in install.sh Bash 3.2 on macOS (arm64) treats the multi-byte UTF-8 bytes of the horizontal ellipsis (U+2026, \xE2\x80\xA6) as part of variable names when they immediately follow a $VAR reference. This caused "INSTALLER_PORT?: unbound variable" at line 70 despite the variable being set. Replaced all four instances with ASCII "..." to fix. Co-Authored-By: Claude Sonnet 4.6 --- install.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/install.sh b/install.sh index 3e1ff7be..be1c61f1 100755 --- a/install.sh +++ b/install.sh @@ -20,7 +20,7 @@ error() { echo -e "${RED}✖${RESET} $*" >&2; } die() { error "$*"; exit 1; } # ── 1. Check Git ─────────────────────────────────────────────────────────────── -info "Checking prerequisites…" +info "Checking prerequisites..." if ! command -v git &>/dev/null; then die "Git is not installed. Install it from https://git-scm.com and re-run this script." @@ -45,7 +45,7 @@ if [ -f "$SCRIPT_DIR/package.json" ]; then else REPO_DIR="${HOME}/csf_profile" if [ ! -d "$REPO_DIR/.git" ]; then - info "Cloning CSF Profile repository…" + info "Cloning CSF Profile repository..." git clone "$REPO_URL" "$REPO_DIR" else info "Repo already cloned at $REPO_DIR" @@ -55,7 +55,7 @@ fi cd "$REPO_DIR" # ── 4. Install npm dependencies ─────────────────────────────────────────────── -info "Installing dependencies (npm install)…" +info "Installing dependencies (npm install)..." npm install --silent success "Dependencies installed." @@ -67,7 +67,7 @@ if lsof -iTCP:"$INSTALLER_PORT" -sTCP:LISTEN &>/dev/null 2>&1; then fi # ── 6. Start installer server ───────────────────────────────────────────────── -info "Starting installer server on port $INSTALLER_PORT…" +info "Starting installer server on port $INSTALLER_PORT..." node "$REPO_DIR/installer/server.js" "$REPO_DIR" & SERVER_PID=$! sleep 1 # Brief pause to let the server bind