diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..700d8ac --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + labels: + - dependencies + - security + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + labels: + - ci diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ea2efd5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,94 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + test: + name: Test + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18, 20, 22] + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + + security-audit: + name: Security Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run npm audit + run: npm audit --audit-level=high + + zero-network-check: + name: Zero Network Verification + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Verify no network calls in source + run: | + echo "Checking for network API usage in source files..." + if grep -rn "fetch\|XMLHttpRequest\|WebSocket\|\.ajax\|sendBeacon\|new Request" \ + --include="*.js" --include="*.html" \ + --exclude-dir=node_modules --exclude-dir=.git \ + --exclude="build.js" .; then + echo "::error::Network API calls detected! FirePath must remain zero-network." + exit 1 + fi + echo "✓ No network calls found — privacy guarantee intact." + + build: + name: Build + runs-on: ubuntu-latest + needs: [test, security-audit, zero-network-check] + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build standalone HTML + run: node build.js + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: fire_calculator + path: fire_calculator.html + retention-days: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..aa470d2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,42 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + name: Create Release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + + - name: Build standalone HTML + run: node build.js + + - name: Generate checksum + run: sha256sum fire_calculator.html > fire_calculator.html.sha256 + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: | + fire_calculator.html + fire_calculator.html.sha256 diff --git a/.gitignore b/.gitignore index c2658d7..e375bd2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ node_modules/ +.env +.env.* +*.log +.DS_Store diff --git a/README.md b/README.md index b8ebdd6..31135ab 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,13 @@ FirePath-Core is the open-source foundation. **FirePath-Pro** is a Tauri desktop Built for and with the FIRE community. +* [r/financialindependence](https://reddit.com/r/financialindependence) — where FirePath was born from real feedback +* [r/leanfire](https://reddit.com/r/leanfire) — conservative withdrawal model users +* [r/FIRE](https://reddit.com/r/FIRE) — general FIRE planning discussion +* [r/privacy](https://reddit.com/r/privacy) — local-first, no-account tool users +* [r/selfhosted](https://reddit.com/r/selfhosted) — self-host on your own machine or Raspberry Pi + + Found a bug? [Open an issue](https://github.com/sandseb123/FirePath-Core/issues). Want to add a withdrawal model? See CONTRIBUTING.md. Have a Garmin .fit file parser? We'd love a PR. --- diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d53997f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,82 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 1.0.x | :white_check_mark: | + +## Architecture & Threat Model + +FirePath-Core is designed as a **local-first, zero-network** financial calculator. This architecture eliminates entire categories of security threats by design. + +### What FirePath Does NOT Do + +- **No network requests** — No `fetch`, `XMLHttpRequest`, `WebSocket`, or any outbound calls. Verify yourself: + ```bash + grep -rn "fetch\|XMLHttpRequest\|WebSocket\|\.ajax\|sendBeacon" fire_math.js fire_calculator.html + ``` +- **No server-side processing** — All calculations run in the browser +- **No data storage on remote servers** — Your financial data never leaves your machine +- **No authentication or accounts** — Nothing to breach +- **No third-party analytics or tracking** — No cookies, no telemetry +- **No CDN dependencies at runtime** — Chart.js is bundled inline via `build.js` + +### Attack Surface + +| Vector | Risk | Mitigation | +|--------|------|------------| +| XSS via CSV import | Low | Input sanitization applied to all CSV-parsed values (fixed in `2bd6dc8`) | +| XSS via scenario rendering | Low | Template outputs are escaped before DOM insertion | +| Malicious CSV file | Low | Parser validates structure; no `eval()` or dynamic code execution | +| Supply chain (npm) | Low | Only 2 dependencies (`chart.js`, `jest`); Dependabot enabled | +| Local file tampering | Medium | Users should verify file integrity via git checksums | +| Browser extension interference | Medium | Outside project scope; users should audit extensions | + +### Data Handling + +- **Inputs**: All financial inputs are entered by the user and stored only in browser memory during the session +- **Assumption files** (`.fire-assumptions.json`): Contain only modeling parameters (rates, ages, allocations) — no account numbers, balances, or PII +- **CSV import**: Parsed entirely in JavaScript; raw file content is never persisted + +## Reporting a Vulnerability + +If you discover a security vulnerability in FirePath-Core, please report it responsibly: + +1. **Email**: Open a [GitHub Security Advisory](https://github.com/sandseb123/FirePath-Core/security/advisories/new) (preferred) +2. **Alternatively**: Open a private issue on the repository + +### What to Include + +- Description of the vulnerability +- Steps to reproduce +- Potential impact +- Suggested fix (if any) + +### Response Timeline + +- **Acknowledgment**: Within 48 hours +- **Assessment**: Within 7 days +- **Fix release**: Within 30 days for confirmed vulnerabilities + +### What Qualifies + +- XSS or injection vulnerabilities in CSV parsing or UI rendering +- Data leakage (any code path that transmits user data externally) +- Dependency vulnerabilities with a viable exploit path +- Logic errors in financial calculations that could mislead users + +### What Does NOT Qualify + +- Issues requiring physical access to the user's machine +- Browser-specific bugs outside the project's control +- Social engineering attacks +- Vulnerabilities in dependencies without a demonstrated exploit in this project + +## Security Best Practices for Users + +1. **Download from the official repo** — Only use releases from [github.com/sandseb123/FirePath-Core](https://github.com/sandseb123/FirePath-Core) +2. **Verify file integrity** — Compare checksums after download +3. **Keep dependencies updated** — Run `npm audit` periodically if using the dev setup +4. **Use a modern browser** — Ensures latest security patches and CSP support +5. **Don't modify and re-share** — If you fork, audit your changes for security diff --git a/build.js b/build.js index a946420..ebc82b6 100644 --- a/build.js +++ b/build.js @@ -14,13 +14,14 @@ let html = fs.readFileSync(htmlPath, 'utf-8'); const chartJs = fs.readFileSync(chartPath, 'utf-8'); const placeholder = '/* Chart.js 4.x — bundled inline for offline-first guarantee. No CDN. */'; -if (!html.includes(placeholder)) { - console.error('ERROR: Placeholder not found in HTML file.'); +if (html.includes(placeholder)) { + html = html.replace(placeholder, chartJs); + fs.writeFileSync(htmlPath, html); + const sizeKB = Math.round(Buffer.byteLength(html) / 1024); + console.log(`Done. fire_calculator.html is now ${sizeKB}KB with Chart.js inlined.`); +} else if (html.includes('Chart')) { + console.log('Chart.js is already inlined. Skipping.'); +} else { + console.error('ERROR: Placeholder not found and Chart.js not detected in HTML file.'); process.exit(1); } - -html = html.replace(placeholder, chartJs); -fs.writeFileSync(htmlPath, html); - -const sizeKB = Math.round(Buffer.byteLength(html) / 1024); -console.log(`Done. fire_calculator.html is now ${sizeKB}KB with Chart.js inlined.`); diff --git a/fire_calculator.html b/fire_calculator.html index e323218..a7fe87b 100644 --- a/fire_calculator.html +++ b/fire_calculator.html @@ -627,6 +627,10 @@

Saved Scenarios

const u1 = rng(), u2 = rng(); return mean + stddev * Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); } +function escapeHTML(str) { + if (typeof str !== 'string') return str; + return str.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); +} // ── CSV Brokerage Import Parser ── function parseCSVLine(line) { @@ -1014,9 +1018,15 @@

Saved Scenarios

if(lastFireDate !== null && ytf !== Infinity && lastFireDate !== Infinity) { const diff = lastFireDate - ytf; if(Math.abs(diff) >= 0.08) { - const months = Math.round(diff * 12); - if(months > 0) deltaPill = `${months} month${months!==1?'s':''} earlier ↑`; - else if(months < 0) deltaPill = `${-months} month${-months!==1?'s':''} later ↓`; + const totalMonths = Math.round(Math.abs(diff) * 12); + const years = Math.floor(totalMonths / 12); + const remainingMonths = totalMonths % 12; + let deltaText; + if(years > 0 && remainingMonths > 0) deltaText = `${years}y ${remainingMonths}m`; + else if(years > 0) deltaText = `${years} year${years!==1?'s':''}`; + else deltaText = `${totalMonths} month${totalMonths!==1?'s':''}`; + if(diff > 0) deltaPill = `${deltaText} earlier ↑`; + else deltaPill = `${deltaText} later ↓`; } } lastFireDate = ytf; @@ -1249,7 +1259,7 @@

What These Numbers Mean

const betterYTF = i>0 && s.yearsToFire < baseline.yearsToFire; const betterSR = i>0 && s.successRate > baseline.successRate; return `
-

${s.name}

+

${escapeHTML(s.name)}

FIRE #${fmt(s.fireNumber)}
Years${fmtN(s.yearsToFire)}
Success${Math.round(s.successRate*100)}%
@@ -1313,12 +1323,12 @@

${s.name}

for (let idx = 0; idx < importedAccounts.length; idx++) { const acct = importedAccounts[idx]; html += `
`; - html += `${acct.label} — $${acct.totalValue.toLocaleString()}`; + html += `${escapeHTML(acct.label)} — $${acct.totalValue.toLocaleString()}`; html += ``; html += `
`; html += `
`; for (const h of acct.holdings) { - html += ``; + html += ``; } html += `
SymbolDescriptionValue
${h.symbol}${h.description}$${h.value.toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2})}
${escapeHTML(h.symbol)}${escapeHTML(h.description)}$${h.value.toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2})}
`; } diff --git a/index.html b/index.html index e323218..0412a50 100644 --- a/index.html +++ b/index.html @@ -1014,9 +1014,15 @@

Saved Scenarios

if(lastFireDate !== null && ytf !== Infinity && lastFireDate !== Infinity) { const diff = lastFireDate - ytf; if(Math.abs(diff) >= 0.08) { - const months = Math.round(diff * 12); - if(months > 0) deltaPill = `${months} month${months!==1?'s':''} earlier ↑`; - else if(months < 0) deltaPill = `${-months} month${-months!==1?'s':''} later ↓`; + const totalMonths = Math.round(Math.abs(diff) * 12); + const years = Math.floor(totalMonths / 12); + const remainingMonths = totalMonths % 12; + let deltaText; + if(years > 0 && remainingMonths > 0) deltaText = `${years}y ${remainingMonths}m`; + else if(years > 0) deltaText = `${years} year${years!==1?'s':''}`; + else deltaText = `${totalMonths} month${totalMonths!==1?'s':''}`; + if(diff > 0) deltaPill = `${deltaText} earlier ↑`; + else deltaPill = `${deltaText} later ↓`; } } lastFireDate = ytf; diff --git a/package.json b/package.json index dc422f4..d0d19a5 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,17 @@ { - "name": "user", + "name": "firepath-core", "version": "1.0.0", - "description": "", + "description": "Local-first FIRE calculator — your retirement numbers never leave your machine", "main": "fire_math.js", "scripts": { - "test": "jest --verbose" + "test": "jest --verbose", + "build": "node build.js", + "audit:security": "npm audit --audit-level=high", + "verify:network": "! grep -rn 'fetch\\|XMLHttpRequest\\|WebSocket' --include='*.js' --include='*.html' --exclude-dir=node_modules --exclude=build.js ." }, - "keywords": [], - "author": "", - "license": "ISC", + "keywords": ["fire", "financial-independence", "retirement", "calculator", "privacy"], + "author": "sandseb123", + "license": "MIT", "devDependencies": { "jest": "^30.2.0" },