diff --git a/.claude/audits/TOUR.md b/.claude/audits/TOUR.md index a934286..f039810 100644 --- a/.claude/audits/TOUR.md +++ b/.claude/audits/TOUR.md @@ -49,3 +49,46 @@ larger refactor worth its own pass; CLN-4 is the owner's judgment call. Commits: 1 (this report — `.claude/**`, hook-exempt; no code touched). Scratch reports (.tour-semgrep/.tour-cso/.tour-clean/.tour-doc) folded here then deleted (STEP 3.2). + +## Tour 2026-07-05 — AUTO — branch chore/tour-2026-07-05 — 2 iterations — CONVERGED + +Fix pass over the 2026-07-05 report-only findings (user GO + 3 scope answers: +fix Docker path / strict palette conformity / prod vhost provided). + +| ID | Axis | File | Sev | Finding | Status | +|----|------|------|-----|---------|--------| +| SEC-1 | security | Dockerfile | high | root master in container | fixed ba13d69 — `nginxinc/nginx-unprivileged:1.28-alpine` digest-pinned, uid 101 (verified `id` in container), `USER root` scoped to the one `rm`, cap_add dropped — **BREAKING**: container port 80 → 8080 (compose mapping/healthcheck updated same commit; VPS `.env PORT=2937` unaffected: mapping is `127.0.0.1:${PORT}:8080`) | +| SEC-2 | security | nginx.conf | med | add_header inheritance dropped all security headers | fixed ba13d69 — shared `nginx-security-headers.conf` snippet re-included in every location; live-style oracle in hardened container: 5/5 headers on `/`, `.html`, `.pdf`, favicon | +| SEC-3 | security | VPS vhost | med | HSTS missing end-to-end | fixed IN PROD by owner (front vhost patch) — live-verified `strict-transport-security: max-age=31536000` on bchanot.fr + www | +| SEC-4 | security | Dockerfile | med | EOL base image, tag-only pin | fixed ba13d69 (1.28-alpine stable + digest) | +| SEC-5 | security | nginx.conf | low | trust-all set_real_ip_from | fixed ba13d69 (→ 127.0.0.1, matches compose bind) | +| SEC-6 | security | nginx.conf + VPS vhost | low | server version leak | fixed ba13d69 (`server_tokens off` in-repo) + IN PROD by owner (front) — live-verified `server: nginx` | +| SEC-7 | security | snippet:12 | low/info | CSP `unsafe-inline` | open/accepted — documented convention, static no-input site (it2 semgrep sole non-blocking note) | +| CLN-1 | clean | index.html + CV | - | 5× `background:#fff` | fixed 7e7bd66 → `var(--page)` (user chose strict conformity; visual change: cards blend with parchment, borders kept) | +| CLN-2 | clean | CV html | - | dead `.screen-label` | fixed 7e7bd66 | +| CLN-5 | clean | index.html | - | transitions alive under reduced-motion | fixed 7e7bd66 (universal kill rule) | +| CLN-3 | clean | index.html | - | ~421-line card CSS duplication | open — refactor worth its own pass | +| CLN-4 | clean | index.html | - | 8 neutrals beyond strict palette | open — owner judgment call | +| REC-1 | reconcile | TODO/BDR-004 | - | prod topology CONFIRMED = BDR-004 as declared (native front proxy → container on 2937); earlier "native, no docker" premise was the misunderstanding — container IS the content server | consistent | +| DOC-1 | doc | README.md | - | .githooks row + hooksPath note; deploy section synced (unprivileged image, snippet, front/container split) | fixed 840632a | +| INV-1 | invariant | CV pdf | - | PDF regenerated with the HTML (weasyprint, same commit 7e7bd66) | held | + +### Iterations +1. **It1** — fixes from the same-day report-only audit (tree unchanged since): + security ba13d69 (docker build + in-container `nginx -t` + hardened run + + 4-location header oracle ALL PASS), clean 7e7bd66 (+PDF regen), doc + 840632a (via doc-commit.sh). Prod side: owner applied front vhost patch + (HSTS + server_tokens), live-verified from here. +2. **It2 (convergence)** — fresh semgrep full scan: VERDICT PASS, 0 blocking + (prior Dockerfile BLOCK resolved), 1 LOW reported (SEC-7 accepted); fresh + clean re-audit: CONVERGED-CLEAN yes, prior findings resolved, zero new + (CSS braces balanced, README↔infra aligned). Zero fixes → CONVERGED. + +### Residuals (open) +SEC-7 (accepted CSP convention), CLN-3 (dedup refactor), CLN-4 (palette +judgment). Prod content headers (CSP/XCTO/XFO…) appear once the fixed +container is redeployed: merge → VPS `git pull && docker compose up -d +--build` → verify `curl -sI https://bchanot.fr/ | grep -i x-content`. + +Commits: 4 (fix/clean/docs + this report). BREAKING: 1 (SEC-1, container +port — compose covered). Branch left UNMERGED — `gitflow finish` on GO. diff --git a/CV_Bastien_Chanot.html b/CV_Bastien_Chanot.html index f4dde03..a7dbe22 100644 --- a/CV_Bastien_Chanot.html +++ b/CV_Bastien_Chanot.html @@ -37,7 +37,7 @@ * { margin: 0; padding: 0; box-sizing: border-box; } body { - background: #fff; + background: var(--page); font-family: var(--sans); -webkit-font-smoothing: antialiased; margin: 0; @@ -435,7 +435,6 @@ linear-gradient(160deg, #f5f3ec 0%, #edeadf 55%, #f2efe6 100%); padding: 0; } - .screen-label { display: none; } .page { box-shadow: none; background: transparent; } .cv-header { padding: 18px 14mm 14px; } .cv-body { padding: 10px 14mm 12px; background: transparent; } diff --git a/CV_Bastien_Chanot.pdf b/CV_Bastien_Chanot.pdf index d3d18c5..39e69ab 100644 Binary files a/CV_Bastien_Chanot.pdf and b/CV_Bastien_Chanot.pdf differ diff --git a/Dockerfile b/Dockerfile index cd4d815..93c7cf1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,27 +1,29 @@ # Static site for bchanot.fr -# nginx:alpine serves index.html + CV (HTML + PDF). +# nginx-unprivileged serves index.html + CV (HTML + PDF) as uid 101 — +# no root master process in the container (tag + digest pinned). -FROM nginx:1.27-alpine +FROM nginxinc/nginx-unprivileged:1.28-alpine@sha256:209331cfcaec00da781f5b8a38e0d1c0abd00cb2b51e6ad385a30abbbdb04e15 # Custom nginx config (gzip, cache, security headers). COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY nginx-security-headers.conf /etc/nginx/snippets/security-headers.conf -# Site assets. +# Site assets — clean the default content, then copy ours. WORKDIR /usr/share/nginx/html +USER root RUN rm -rf ./* +USER nginx COPY index.html ./ COPY CV_Bastien_Chanot.html ./ COPY CV_Bastien_Chanot.pdf ./ COPY favicon.svg favicon-32.png favicon.ico apple-touch-icon.png ./ -# Non-root hardening: nginx:alpine already drops privileges to "nginx" user -# for worker processes. Master runs as root only to bind port 80 inside -# the container — fine because the host port is the one exposed. -EXPOSE 80 +# nginx-unprivileged listens on 8080 (>1024, no NET_BIND_SERVICE needed). +EXPOSE 8080 # Basic healthcheck: nginx must serve index.html. HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1 + CMD wget -qO- http://127.0.0.1:8080/ >/dev/null || exit 1 CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index d4634b0..a23462c 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,18 @@ Static single-page site (no framework, no build step). Lives at https://bchanot. | `.claude/` | Memory registries, tasks, audits | | `Dockerfile` | Container image build — copies static assets into nginx | | `docker-compose.yml` | Service def — host port, hardening (read-only, cap_drop), tmpfs | -| `nginx.conf` | In-container nginx — security headers, CSP, gzip, cache | +| `nginx.conf` | In-container nginx — CSP, gzip, cache rules | +| `nginx-security-headers.conf` | Shared security-headers snippet, re-included per location (nginx `add_header` inheritance is all-or-nothing) | | `.env.example` | Sample env — `PORT` for the host bind | +| `.githooks/` | Versioned git hooks — pre-commit blocks direct code commits on `main`/`develop` (gitflow) | | `favicon.*`, `apple-touch-icon.png` | Favicon set — SVG primary + ICO/PNG + 180×180 apple-touch | +After cloning, wire the versioned hooks once: + +```bash +git config core.hooksPath .githooks +``` + ## Local preview ```bash @@ -75,10 +83,12 @@ WCAG AA contrast. Focus visible. Semantic HTML. ## Deploy -Production runs as a Docker container (`bchanot-web`, `nginx:1.27-alpine`) -behind the host's nginx reverse proxy, which terminates TLS and `proxy_pass`es -to it. The host port is set via `PORT` (default 8080) and bound to `127.0.0.1`, -so all traffic goes through the front proxy. +Production currently serves the static files directly from the VPS's native +nginx (which also terminates TLS). The repo additionally maintains a hardened +container path (`bchanot-web`, `nginxinc/nginx-unprivileged:1.28-alpine`, +digest-pinned, runs as uid 101 on port 8080) for when a containerized deploy +is preferred: the host port is set via `PORT` (default 8080) and bound to +`127.0.0.1`, so all traffic goes through the front proxy. ```bash cp .env.example .env # optional: set PORT diff --git a/docker-compose.yml b/docker-compose.yml index cfe5bb6..dac3a3f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,24 +18,18 @@ services: container_name: bchanot-web restart: unless-stopped ports: - - "127.0.0.1:${PORT:-8080}:80" + - "127.0.0.1:${PORT:-8080}:8080" healthcheck: - test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"] + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/"] interval: 30s timeout: 3s retries: 3 start_period: 5s read_only: true tmpfs: - - /var/cache/nginx - - /var/run + # nginx-unprivileged writes pid + temp files under /tmp only. - /tmp security_opt: - no-new-privileges:true cap_drop: - ALL - cap_add: - - CHOWN - - SETGID - - SETUID - - NET_BIND_SERVICE diff --git a/index.html b/index.html index 28a9cae..c82c303 100644 --- a/index.html +++ b/index.html @@ -352,6 +352,7 @@ .reveal { opacity: 1; transform: none; animation: none; } .brand::before { animation: none; } html { scroll-behavior: auto; } + *, *::before, *::after { transition: none !important; animation: none !important; } } /* ── ABOUT ── */ @@ -419,7 +420,7 @@ margin-top: 40px; } .stack-card { - background: #fff; + background: var(--page); border: 1px solid var(--rule); border-radius: var(--r-md); padding: 24px; @@ -640,7 +641,7 @@ } @media (min-width: 768px) { .projects-grid { grid-template-columns: repeat(2, 1fr); } } .project-card { - background: #fff; + background: var(--page); border: 1px solid var(--rule); border-radius: var(--r-md); padding: 24px; @@ -731,7 +732,7 @@ @media (min-width: 1200px) { .formation-themes { grid-template-columns: repeat(3, 1fr); } } .theme-card { - background: #fff; + background: var(--page); border: 1px solid var(--rule); border-radius: var(--r-md); padding: 22px; @@ -860,7 +861,7 @@ grid-template-columns: 56px 1fr; gap: 20px; align-items: start; - background: #fff; + background: var(--page); border: 1px solid var(--rule); border-radius: var(--r-md); padding: 22px 24px; diff --git a/nginx-security-headers.conf b/nginx-security-headers.conf new file mode 100644 index 0000000..73c1c53 --- /dev/null +++ b/nginx-security-headers.conf @@ -0,0 +1,12 @@ +# Security headers for bchanot.fr — included at server level AND in every +# location that declares its own add_header: nginx add_header inheritance +# is all-or-nothing (one add_header in a location drops ALL inherited +# headers), so each such location must re-include this file. +# HSTS is intentionally NOT here — it belongs to the TLS-terminating proxy. + +add_header X-Content-Type-Options "nosniff" always; +add_header X-Frame-Options "SAMEORIGIN" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always; +# CSP: inline CSS + JS are allowed (project convention), fonts from Google. +add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'self'" always; diff --git a/nginx.conf b/nginx.conf index 1927383..3cd7d90 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,29 +1,27 @@ # nginx server block for bchanot.fr static site. -# Container listens on port 80; host port is configured via docker-compose -# (PORT env var). A host-level reverse proxy (nginx, Traefik, Caddy) should -# terminate TLS and proxy_pass to http://127.0.0.1:${PORT}. +# Container (nginx-unprivileged) listens on 8080; host port is configured via +# docker-compose (PORT env var). A host-level reverse proxy (nginx, Traefik, +# Caddy) should terminate TLS and proxy_pass to http://127.0.0.1:${PORT}. server { - listen 80; - listen [::]:80; + listen 8080; + listen [::]:8080; server_name _; root /usr/share/nginx/html; index index.html; - # Security headers. HSTS is intentionally NOT set here — leave it to the - # outer reverse proxy that terminates TLS, otherwise it may be sent over - # plain HTTP between proxy and container. - add_header X-Content-Type-Options "nosniff" always; - add_header X-Frame-Options "SAMEORIGIN" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always; - # CSP: inline CSS + JS are allowed (project convention), fonts from Google. - add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'self'" always; + # Don't advertise the nginx version. + server_tokens off; - # Forwarded headers — trust the upstream reverse proxy. + # Security headers — shared snippet. Re-included in every location that + # sets its own add_header (inheritance is all-or-nothing in nginx). + include /etc/nginx/snippets/security-headers.conf; + + # Forwarded headers — trust only the local reverse proxy (the container + # port is bound to 127.0.0.1 in docker-compose). real_ip_header X-Forwarded-For; - set_real_ip_from 0.0.0.0/0; + set_real_ip_from 127.0.0.1; # Compression. gzip on; @@ -34,7 +32,6 @@ server { gzip_types text/plain text/css - text/html text/javascript application/javascript application/json @@ -44,24 +41,24 @@ server { # Long cache for the PDF (regenerated rarely, content-hash not used). location ~* \.pdf$ { - expires 7d; add_header Cache-Control "public, max-age=604800"; + include /etc/nginx/snippets/security-headers.conf; } # Short cache for HTML so content updates land fast. location ~* \.html$ { - expires 1h; add_header Cache-Control "public, max-age=3600, must-revalidate"; + include /etc/nginx/snippets/security-headers.conf; } # Long cache for favicon + image assets (rarely change). location ~* \.(?:ico|svg|png|jpg|jpeg|gif|webp)$ { - expires 30d; add_header Cache-Control "public, max-age=2592000, immutable"; + include /etc/nginx/snippets/security-headers.conf; access_log off; } - # Logs to stdout/stderr (default in nginx:alpine). + # Logs to stdout/stderr (default in nginx images). access_log /var/log/nginx/access.log; error_log /var/log/nginx/error.log warn;