diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9e8abcf --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +version: 2 +updates: + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + groups: + cargo-minor-and-patch: + update-types: ['minor', 'patch'] + + - package-ecosystem: npm + directory: /ui + schedule: + interval: weekly + groups: + npm-minor-and-patch: + update-types: ['minor', 'patch'] + + - package-ecosystem: npm + directory: /docs/site + schedule: + interval: weekly + groups: + npm-minor-and-patch: + update-types: ['minor', 'patch'] + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index f4e444b..e244d24 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -18,6 +18,13 @@ on: # Weekly vulnerability check (Mondays 9am UTC) - cron: '0 9 * * 1' +permissions: + contents: read + +concurrency: + group: docker-${{ github.ref }} + cancel-in-progress: true + env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository_owner }}/hone-money @@ -27,14 +34,16 @@ jobs: security-scan-rust: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Install Rust - uses: dtolnay/rust-toolchain@1.92.0 + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: '1.92.0' - name: Cache cargo-audit binary id: cache-cargo-audit - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.cargo/bin/cargo-audit key: cargo-audit-0.22.0 @@ -49,14 +58,14 @@ jobs: security-scan-node: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: sparse-checkout: | ui/package.json ui/package-lock.json - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '24' @@ -67,13 +76,15 @@ jobs: build-backend-amd64: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Install Rust - uses: dtolnay/rust-toolchain@1.92.0 + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: '1.92.0' - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | ~/.cargo/bin/ @@ -94,7 +105,7 @@ jobs: run: chmod +x target/release/hone - name: Upload binary - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: hone-x86_64-unknown-linux-gnu path: target/release/hone @@ -102,15 +113,16 @@ jobs: build-backend-arm64: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Install Rust - uses: dtolnay/rust-toolchain@1.92.0 + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 with: + toolchain: '1.92.0' targets: aarch64-unknown-linux-gnu - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | ~/.cargo/bin/ @@ -134,7 +146,7 @@ jobs: run: chmod +x target/aarch64-unknown-linux-gnu/release/hone - name: Upload binary - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: hone-aarch64-unknown-linux-gnu path: target/aarch64-unknown-linux-gnu/release/hone @@ -142,10 +154,10 @@ jobs: build-frontend: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '24' cache: 'npm' @@ -160,7 +172,7 @@ jobs: run: npm run build - name: Upload frontend - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: frontend-dist path: ui/dist @@ -176,49 +188,42 @@ jobs: actions: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Download AMD64 binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: hone-x86_64-unknown-linux-gnu path: ./binaries/amd64 - name: Download ARM64 binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: hone-aarch64-unknown-linux-gnu path: ./binaries/arm64 - name: Download frontend - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: frontend-dist path: ./ui/dist - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Login to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Login to DHI (Docker Hardened Images) - uses: docker/login-action@v3 - with: - registry: dhi.io - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Extract metadata for Docker id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | @@ -234,7 +239,7 @@ jobs: chmod +x binaries/arm64/hone - name: Build image (amd64 for scanning) - uses: docker/build-push-action@v5 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . file: Dockerfile.release @@ -243,7 +248,7 @@ jobs: tags: ${{ env.IMAGE_NAME }}:scan - name: Scan image for vulnerabilities - uses: aquasecurity/trivy-action@0.33.1 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: ${{ env.IMAGE_NAME }}:scan format: 'table' @@ -253,14 +258,14 @@ jobs: - name: Full vulnerability report (check for newly fixed CVEs) if: github.event_name == 'schedule' - uses: aquasecurity/trivy-action@0.33.1 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: ${{ env.IMAGE_NAME }}:scan format: 'table' severity: 'CRITICAL,HIGH' - name: Build and push (multi-arch) - uses: docker/build-push-action@v5 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . file: Dockerfile.release diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e8142e8..598c8e1 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -8,6 +8,13 @@ on: - '.github/workflows/docs.yml' workflow_dispatch: +permissions: + contents: read + +concurrency: + group: docs-deploy + cancel-in-progress: true + jobs: deploy: runs-on: ubuntu-latest @@ -16,10 +23,10 @@ jobs: deployments: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '24' cache: 'npm' @@ -34,7 +41,7 @@ jobs: run: npm run build - name: Create project if needed - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0 continue-on-error: true with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} @@ -42,7 +49,7 @@ jobs: command: pages project create hone-fyi --production-branch=main - name: Deploy to Cloudflare Pages - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 89f1221..2e74dd6 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -8,6 +8,13 @@ on: - '.github/workflows/pages.yml' workflow_dispatch: +permissions: + contents: read + +concurrency: + group: pages-deploy + cancel-in-progress: true + jobs: deploy: runs-on: ubuntu-latest @@ -16,10 +23,10 @@ jobs: deployments: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Create project if needed - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0 continue-on-error: true with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} @@ -27,7 +34,7 @@ jobs: command: pages project create hone-money --production-branch=main - name: Deploy to Cloudflare Pages - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml new file mode 100644 index 0000000..b6477df --- /dev/null +++ b/.github/workflows/rust-tests.yml @@ -0,0 +1,63 @@ +name: Rust Tests + +on: + push: + branches: [main] + paths: + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.github/workflows/rust-tests.yml' + pull_request: + branches: [main] + paths: + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.github/workflows/rust-tests.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: rust-tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Format, lint, test + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Install Rust + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: '1.92.0' + components: rustfmt, clippy + + - name: Cache cargo + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo-test- + + - name: Install OpenSSL (for SQLCipher) + run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev + + - name: Check formatting + run: cargo fmt --all --check + + - name: Clippy + run: cargo clippy --workspace --all-targets + + - name: Run tests + run: cargo test --workspace diff --git a/.github/workflows/trivyignore-audit.yml b/.github/workflows/trivyignore-audit.yml index 6caef40..6769aad 100644 --- a/.github/workflows/trivyignore-audit.yml +++ b/.github/workflows/trivyignore-audit.yml @@ -6,104 +6,105 @@ on: - cron: '0 10 * * 1' workflow_dispatch: +permissions: + contents: read + issues: write + jobs: audit-trivyignore: runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - name: Install Trivy - run: | - sudo apt-get install -y wget apt-transport-https gnupg lsb-release - wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo apt-key add - - echo deb https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main | sudo tee -a /etc/apt/sources.list.d/trivy.list - sudo apt-get update - sudo apt-get install -y trivy + # Scan the published image with NO ignore file, so ignored CVEs show up + # with their current fix status + - name: Scan published image + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: ghcr.io/${{ github.repository_owner }}/hone-money:latest + format: 'json' + output: 'scan.json' + exit-code: '0' - - name: Check CVE fix status + - name: Check ignored CVEs against scan id: check-cves run: | - #!/bin/bash set -e - # Extract CVEs from .trivyignore (skip comments and empty lines) - CVES=$(grep -E '^CVE-[0-9]+-[0-9]+' .trivyignore || true) + CVES=$(grep -oE '^CVE-[0-9]+-[0-9]+' .trivyignore || true) if [ -z "$CVES" ]; then echo "No CVEs found in .trivyignore" - echo "has_fixes=false" >> $GITHUB_OUTPUT + echo "has_findings=false" >> "$GITHUB_OUTPUT" exit 0 fi - echo "Checking CVEs: $CVES" - - # Check each CVE against Trivy's vulnerability database - trivy image --download-db-only - FIXED_CVES="" - UNFIXED_CVES="" + STALE_CVES="" for CVE in $CVES; do - echo "Checking $CVE..." + COUNT=$(jq -r --arg cve "$CVE" \ + '[.Results[]?.Vulnerabilities[]? | select(.VulnerabilityID == $cve)] | length' scan.json) - # Query the NVD/Trivy for fix status - # We use trivy's vuln db to check if Debian has a fix - STATUS=$(trivy db search "$CVE" 2>/dev/null | grep -i "debian" | head -1 || echo "") + if [ "$COUNT" = "0" ]; then + echo "○ $CVE no longer present in image (stale ignore entry)" + STALE_CVES="$STALE_CVES $CVE" + continue + fi - if echo "$STATUS" | grep -qi "fixed"; then - echo " ✓ $CVE has a fix available" + FIX=$(jq -r --arg cve "$CVE" \ + '[.Results[]?.Vulnerabilities[]? | select(.VulnerabilityID == $cve) | .FixedVersion // empty] | map(select(. != "")) | first // empty' scan.json) + + if [ -n "$FIX" ]; then + echo "✓ $CVE has a fix available: $FIX" FIXED_CVES="$FIXED_CVES $CVE" else - echo " ✗ $CVE still unfixed" - UNFIXED_CVES="$UNFIXED_CVES $CVE" + echo "✗ $CVE still present, no fix available" fi done - if [ -n "$FIXED_CVES" ]; then - echo "has_fixes=true" >> $GITHUB_OUTPUT - echo "fixed_cves=$FIXED_CVES" >> $GITHUB_OUTPUT - else - echo "has_fixes=false" >> $GITHUB_OUTPUT - fi - - - name: Update trivyignore with dates - run: | - #!/bin/bash - TODAY=$(date +%Y-%m-%d) - - # Update last-checked date in header - if grep -q "Last checked:" .trivyignore; then - sed -i "s/Last checked: .*/Last checked: $TODAY/" .trivyignore + if [ -n "$FIXED_CVES" ] || [ -n "$STALE_CVES" ]; then + echo "has_findings=true" >> "$GITHUB_OUTPUT" + echo "fixed_cves=$FIXED_CVES" >> "$GITHUB_OUTPUT" + echo "stale_cves=$STALE_CVES" >> "$GITHUB_OUTPUT" else - # Add header if not present - sed -i "1i # Last checked: $TODAY" .trivyignore + echo "has_findings=false" >> "$GITHUB_OUTPUT" fi - - name: Create issue if fixes available - if: steps.check-cves.outputs.has_fixes == 'true' - uses: actions/github-script@v7 + - name: Create or update issue + if: steps.check-cves.outputs.has_findings == 'true' + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const fixedCves = '${{ steps.check-cves.outputs.fixed_cves }}'.trim().split(' ').filter(Boolean); + const staleCves = '${{ steps.check-cves.outputs.stale_cves }}'.trim().split(' ').filter(Boolean); - const body = `## CVE Fixes Now Available + let body = '## .trivyignore Audit Findings\n\n'; - The following CVEs in \`.trivyignore\` now have fixes available in Debian: + if (fixedCves.length) { + body += `### Fixes Now Available + + These CVEs are still present in the image but now have fixed versions: ${fixedCves.map(cve => `- ${cve}`).join('\n')} - ### Action Required - 1. Update the base image in \`Dockerfile.release\` - 2. Remove the fixed CVEs from \`.trivyignore\` - 3. Run the Docker build to verify the fixes are applied + Update the base image in \`Dockerfile.release\`, rebuild, then remove them from \`.trivyignore\`. + + `; + } + + if (staleCves.length) { + body += `### Stale Entries - This issue was automatically created by the trivyignore-audit workflow.`; + These CVEs no longer appear in the image scan at all — remove them from \`.trivyignore\`: + + ${staleCves.map(cve => `- ${cve}`).join('\n')} + + `; + } + + body += '\nThis issue was automatically created by the trivyignore-audit workflow.'; - // Check if issue already exists const issues = await github.rest.issues.listForRepo({ owner: context.repo.owner, repo: context.repo.repo, @@ -111,10 +112,9 @@ jobs: labels: 'security,trivyignore' }); - const existingIssue = issues.data.find(i => i.title.includes('CVE Fixes Available')); + const existingIssue = issues.data.find(i => i.title.includes('.trivyignore audit')); if (existingIssue) { - // Update existing issue await github.rest.issues.update({ owner: context.repo.owner, repo: context.repo.repo, @@ -123,26 +123,12 @@ jobs: }); console.log(`Updated issue #${existingIssue.number}`); } else { - // Create new issue await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, - title: '🔒 CVE Fixes Available - Update Base Image', + title: '🔒 .trivyignore audit: entries need attention', body: body, labels: ['security', 'trivyignore'] }); console.log('Created new issue'); } - - - name: Commit updated trivyignore - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - if git diff --quiet .trivyignore; then - echo "No changes to .trivyignore" - else - git add .trivyignore - git commit -m "chore: update .trivyignore last-checked date" - git push - fi diff --git a/.github/workflows/ux-tests.yml b/.github/workflows/ux-tests.yml index f8ab4b7..fe3244c 100644 --- a/.github/workflows/ux-tests.yml +++ b/.github/workflows/ux-tests.yml @@ -6,15 +6,28 @@ on: paths: - 'ui/**' - 'samples/**' + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' - '.github/workflows/ux-tests.yml' pull_request: branches: [main] paths: - 'ui/**' - 'samples/**' + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' - '.github/workflows/ux-tests.yml' workflow_dispatch: +permissions: + contents: read + +concurrency: + group: ux-tests-${{ github.ref }} + cancel-in-progress: true + env: TEST_DB: /tmp/hone-ux-test.db @@ -25,13 +38,15 @@ jobs: timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Install Rust - uses: dtolnay/rust-toolchain@1.92.0 + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: '1.92.0' - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | ~/.cargo/bin/ @@ -49,7 +64,7 @@ jobs: run: cargo build --release --bin hone - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '24' cache: 'npm' @@ -91,7 +106,7 @@ jobs: TEST_DB: ${{ env.TEST_DB }} - name: Upload Playwright report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: failure() with: name: playwright-report @@ -99,7 +114,7 @@ jobs: retention-days: 7 - name: Upload screenshots - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() with: name: ux-screenshots diff --git a/.trivyignore b/.trivyignore index 7bf7e98..a2a9854 100644 --- a/.trivyignore +++ b/.trivyignore @@ -1,8 +1,8 @@ # Trivy vulnerability ignore list # These are base image vulnerabilities without available fixes # -# Last checked: 2026-06-29 # Audited weekly by .github/workflows/trivyignore-audit.yml +# (files an issue when a fix lands or an entry goes stale) # # Format: # # Description - reason for ignore diff --git a/Dockerfile b/Dockerfile index 17f1bfd..83484b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,11 @@ RUN cd ui && npm run build # with no shell, package manager, or unnecessary utilities. FROM gcr.io/distroless/cc-debian12 WORKDIR /app + +# Lets the server warn when asked to bind loopback, which is unreachable +# through the container port mapping +ENV HONE_IN_CONTAINER=1 + COPY --from=backend-builder /app/target/release/hone /app/hone COPY --from=frontend-builder /app/ui/dist /app/ui/dist EXPOSE 3000 diff --git a/Dockerfile.release b/Dockerfile.release index cf9af67..248d3d3 100644 --- a/Dockerfile.release +++ b/Dockerfile.release @@ -12,6 +12,10 @@ FROM gcr.io/distroless/cc-debian12 WORKDIR /app +# Lets the server warn when asked to bind loopback, which is unreachable +# through the container port mapping +ENV HONE_IN_CONTAINER=1 + # Copy the correct binary based on target platform # Binary must already be executable (set in CI) ARG TARGETARCH diff --git a/crates/hone-cli/src/commands/serve.rs b/crates/hone-cli/src/commands/serve.rs index e69edd2..8211d54 100644 --- a/crates/hone-cli/src/commands/serve.rs +++ b/crates/hone-cli/src/commands/serve.rs @@ -18,6 +18,16 @@ pub async fn cmd_serve( println!("🚀 Starting Hone web server..."); println!(" Database: {}", db_path.display()); println!(" Listening: http://{}:{}", host, port); + if is_loopback_host(host) && running_in_container() { + println!(); + println!( + " ⚠️ Bound to {} inside a container — published ports will NOT be reachable.", + host + ); + println!( + " Use --host 0.0.0.0 to accept connections through the container port mapping." + ); + } if let Some(dir) = static_dir { println!(" Static files: {}", dir.display()); } @@ -124,3 +134,42 @@ pub async fn cmd_serve( Ok(()) } + +/// Loopback inside a container is almost always a misconfiguration: the +/// container's port mapping forwards to its external interface, so a server +/// bound to 127.0.0.1 is unreachable from the host (issue #1). +fn is_loopback_host(host: &str) -> bool { + host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .map(|ip| ip.is_loopback()) + .unwrap_or(false) +} + +fn running_in_container() -> bool { + // HONE_IN_CONTAINER is baked into the published images; /.dockerenv covers + // plain Docker for images that predate the env var + std::env::var_os("HONE_IN_CONTAINER").is_some() || Path::new("/.dockerenv").exists() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn loopback_hosts_detected() { + assert!(is_loopback_host("127.0.0.1")); + assert!(is_loopback_host("127.0.0.53")); + assert!(is_loopback_host("localhost")); + assert!(is_loopback_host("LOCALHOST")); + assert!(is_loopback_host("::1")); + } + + #[test] + fn non_loopback_hosts_not_flagged() { + assert!(!is_loopback_host("0.0.0.0")); + assert!(!is_loopback_host("::")); + assert!(!is_loopback_host("192.168.1.10")); + assert!(!is_loopback_host("example.internal")); + } +} diff --git a/crates/hone-core/src/detect.rs b/crates/hone-core/src/detect.rs index f15e876..7c363cd 100644 --- a/crates/hone-core/src/detect.rs +++ b/crates/hone-core/src/detect.rs @@ -2181,10 +2181,14 @@ mod tests { .iter() .find(|a| a.alert_type == AlertType::TipDiscrepancy) .expect("Should have created a tip discrepancy alert"); - + assert!(tip_alert.message.as_ref().unwrap().contains("RESTO BAR")); assert!(tip_alert.message.as_ref().unwrap().contains("$55.00")); assert!(tip_alert.message.as_ref().unwrap().contains("$45.00")); - assert!(tip_alert.message.as_ref().unwrap().contains("potential tip: $10.00")); + assert!(tip_alert + .message + .as_ref() + .unwrap() + .contains("potential tip: $10.00")); } } diff --git a/crates/hone-core/src/insights/spending_explainer.rs b/crates/hone-core/src/insights/spending_explainer.rs index c484417..4bc137a 100644 --- a/crates/hone-core/src/insights/spending_explainer.rs +++ b/crates/hone-core/src/insights/spending_explainer.rs @@ -414,7 +414,10 @@ mod tests { let txs = db.list_transactions(None, 100, 0).unwrap(); // Find the specifically inserted transaction to avoid tagging the wrong one - let newly_inserted = txs.iter().find(|t| t.import_hash == format!("baseline_{}_{}", month, i)).unwrap(); + let newly_inserted = txs + .iter() + .find(|t| t.import_hash == format!("baseline_{}_{}", month, i)) + .unwrap(); db.add_transaction_tag(newly_inserted.id, dining_tag.id, TagSource::Manual, None) .unwrap(); } diff --git a/crates/hone-core/tests/integration_tests.rs b/crates/hone-core/tests/integration_tests.rs index a80feb0..6f2bdbe 100644 --- a/crates/hone-core/tests/integration_tests.rs +++ b/crates/hone-core/tests/integration_tests.rs @@ -120,7 +120,10 @@ async fn test_zombie_detection() { // Run detection let detector = WasteDetector::new(&db); - let results = detector.detect_zombies_only().await.expect("Detection failed"); + let results = detector + .detect_zombies_only() + .await + .expect("Detection failed"); // All subscriptions should be detected as zombies since they're unacknowledged // and have been running for 3+ months @@ -188,7 +191,10 @@ async fn test_price_increase_detection() { // Note: The subscription detection algorithm requires amounts within 10% of median // A $14.99 to $16.50 change (~10%) may not pass subscription detection // Let's just verify the detection runs without error - let results = detector.detect_increases_only().await.expect("Detection failed"); + let results = detector + .detect_increases_only() + .await + .expect("Detection failed"); // The price increase detection depends on: // 1. A subscription being detected (amounts must be consistent) @@ -214,7 +220,10 @@ async fn test_duplicate_detection() { // Run detection let detector = WasteDetector::new(&db); - let results = detector.detect_duplicates_only().await.expect("Detection failed"); + let results = detector + .detect_duplicates_only() + .await + .expect("Detection failed"); // Netflix and Hulu should be detected as duplicate streaming services assert!( diff --git a/docs/deployment.md b/docs/deployment.md index 4f03a76..b88e56b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -64,6 +64,10 @@ For local/development use, add `--no-auth` to the command: command: ["serve", "--host", "0.0.0.0", "--db", "/data/hone.db", "--no-auth"] ``` +> **Keep `--host 0.0.0.0` when editing `command:`** — without it the server only +> listens inside the container and the published port won't work. The startup log +> confirms the bind address on the `Listening:` line. + ### Security Requirements **Important**: Enable JWT validation for production deployments behind Cloudflare Access. @@ -295,6 +299,26 @@ docker compose exec hone /app/hone backup create docker compose down ``` +## Troubleshooting + +### `curl: (52) Empty reply from server` + +The server is running but bound to the wrong interface. Check the startup log: + +```bash +docker compose logs hone | grep Listening +``` + +If it shows `Listening: http://127.0.0.1:3000`, the `--host 0.0.0.0` argument is +missing from your `command:` line — loopback inside the container is unreachable +through Docker's port mapping. Restore the full command: + +```yaml +command: ["serve", "--host", "0.0.0.0", "--db", "/data/hone.db"] +``` + +Newer images also print a startup warning when this misconfiguration is detected. + ## Mac Training Setup Run model fine-tuning on a Mac while Hone runs on another machine (e.g., Raspberry Pi).