Merge chore/tour-2026-07-05 into develop
This commit is contained in:
@@ -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).
|
Commits: 1 (this report — `.claude/**`, hook-exempt; no code touched).
|
||||||
Scratch reports (.tour-semgrep/.tour-cso/.tour-clean/.tour-doc) folded here
|
Scratch reports (.tour-semgrep/.tour-cso/.tour-clean/.tour-doc) folded here
|
||||||
then deleted (STEP 3.2).
|
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.
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: #fff;
|
background: var(--page);
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -435,7 +435,6 @@
|
|||||||
linear-gradient(160deg, #f5f3ec 0%, #edeadf 55%, #f2efe6 100%);
|
linear-gradient(160deg, #f5f3ec 0%, #edeadf 55%, #f2efe6 100%);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.screen-label { display: none; }
|
|
||||||
.page { box-shadow: none; background: transparent; }
|
.page { box-shadow: none; background: transparent; }
|
||||||
.cv-header { padding: 18px 14mm 14px; }
|
.cv-header { padding: 18px 14mm 14px; }
|
||||||
.cv-body { padding: 10px 14mm 12px; background: transparent; }
|
.cv-body { padding: 10px 14mm 12px; background: transparent; }
|
||||||
|
|||||||
Binary file not shown.
+10
-8
@@ -1,27 +1,29 @@
|
|||||||
# Static site for bchanot.fr
|
# 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).
|
# Custom nginx config (gzip, cache, security headers).
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
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
|
WORKDIR /usr/share/nginx/html
|
||||||
|
USER root
|
||||||
RUN rm -rf ./*
|
RUN rm -rf ./*
|
||||||
|
USER nginx
|
||||||
|
|
||||||
COPY index.html ./
|
COPY index.html ./
|
||||||
COPY CV_Bastien_Chanot.html ./
|
COPY CV_Bastien_Chanot.html ./
|
||||||
COPY CV_Bastien_Chanot.pdf ./
|
COPY CV_Bastien_Chanot.pdf ./
|
||||||
COPY favicon.svg favicon-32.png favicon.ico apple-touch-icon.png ./
|
COPY favicon.svg favicon-32.png favicon.ico apple-touch-icon.png ./
|
||||||
|
|
||||||
# Non-root hardening: nginx:alpine already drops privileges to "nginx" user
|
# nginx-unprivileged listens on 8080 (>1024, no NET_BIND_SERVICE needed).
|
||||||
# for worker processes. Master runs as root only to bind port 80 inside
|
EXPOSE 8080
|
||||||
# the container — fine because the host port is the one exposed.
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
||||||
# Basic healthcheck: nginx must serve index.html.
|
# Basic healthcheck: nginx must serve index.html.
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
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;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
@@ -15,10 +15,18 @@ Static single-page site (no framework, no build step). Lives at https://bchanot.
|
|||||||
| `.claude/` | Memory registries, tasks, audits |
|
| `.claude/` | Memory registries, tasks, audits |
|
||||||
| `Dockerfile` | Container image build — copies static assets into nginx |
|
| `Dockerfile` | Container image build — copies static assets into nginx |
|
||||||
| `docker-compose.yml` | Service def — host port, hardening (read-only, cap_drop), tmpfs |
|
| `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 |
|
| `.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 |
|
| `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
|
## Local preview
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -75,10 +83,12 @@ WCAG AA contrast. Focus visible. Semantic HTML.
|
|||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
Production runs as a Docker container (`bchanot-web`, `nginx:1.27-alpine`)
|
Production currently serves the static files directly from the VPS's native
|
||||||
behind the host's nginx reverse proxy, which terminates TLS and `proxy_pass`es
|
nginx (which also terminates TLS). The repo additionally maintains a hardened
|
||||||
to it. The host port is set via `PORT` (default 8080) and bound to `127.0.0.1`,
|
container path (`bchanot-web`, `nginxinc/nginx-unprivileged:1.28-alpine`,
|
||||||
so all traffic goes through the front proxy.
|
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
|
```bash
|
||||||
cp .env.example .env # optional: set PORT
|
cp .env.example .env # optional: set PORT
|
||||||
|
|||||||
+3
-9
@@ -18,24 +18,18 @@ services:
|
|||||||
container_name: bchanot-web
|
container_name: bchanot-web
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${PORT:-8080}:80"
|
- "127.0.0.1:${PORT:-8080}:8080"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"]
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 5s
|
start_period: 5s
|
||||||
read_only: true
|
read_only: true
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /var/cache/nginx
|
# nginx-unprivileged writes pid + temp files under /tmp only.
|
||||||
- /var/run
|
|
||||||
- /tmp
|
- /tmp
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
cap_drop:
|
cap_drop:
|
||||||
- ALL
|
- ALL
|
||||||
cap_add:
|
|
||||||
- CHOWN
|
|
||||||
- SETGID
|
|
||||||
- SETUID
|
|
||||||
- NET_BIND_SERVICE
|
|
||||||
|
|||||||
+5
-4
@@ -352,6 +352,7 @@
|
|||||||
.reveal { opacity: 1; transform: none; animation: none; }
|
.reveal { opacity: 1; transform: none; animation: none; }
|
||||||
.brand::before { animation: none; }
|
.brand::before { animation: none; }
|
||||||
html { scroll-behavior: auto; }
|
html { scroll-behavior: auto; }
|
||||||
|
*, *::before, *::after { transition: none !important; animation: none !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── ABOUT ── */
|
/* ── ABOUT ── */
|
||||||
@@ -419,7 +420,7 @@
|
|||||||
margin-top: 40px;
|
margin-top: 40px;
|
||||||
}
|
}
|
||||||
.stack-card {
|
.stack-card {
|
||||||
background: #fff;
|
background: var(--page);
|
||||||
border: 1px solid var(--rule);
|
border: 1px solid var(--rule);
|
||||||
border-radius: var(--r-md);
|
border-radius: var(--r-md);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
@@ -640,7 +641,7 @@
|
|||||||
}
|
}
|
||||||
@media (min-width: 768px) { .projects-grid { grid-template-columns: repeat(2, 1fr); } }
|
@media (min-width: 768px) { .projects-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||||
.project-card {
|
.project-card {
|
||||||
background: #fff;
|
background: var(--page);
|
||||||
border: 1px solid var(--rule);
|
border: 1px solid var(--rule);
|
||||||
border-radius: var(--r-md);
|
border-radius: var(--r-md);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
@@ -731,7 +732,7 @@
|
|||||||
@media (min-width: 1200px) { .formation-themes { grid-template-columns: repeat(3, 1fr); } }
|
@media (min-width: 1200px) { .formation-themes { grid-template-columns: repeat(3, 1fr); } }
|
||||||
|
|
||||||
.theme-card {
|
.theme-card {
|
||||||
background: #fff;
|
background: var(--page);
|
||||||
border: 1px solid var(--rule);
|
border: 1px solid var(--rule);
|
||||||
border-radius: var(--r-md);
|
border-radius: var(--r-md);
|
||||||
padding: 22px;
|
padding: 22px;
|
||||||
@@ -860,7 +861,7 @@
|
|||||||
grid-template-columns: 56px 1fr;
|
grid-template-columns: 56px 1fr;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
background: #fff;
|
background: var(--page);
|
||||||
border: 1px solid var(--rule);
|
border: 1px solid var(--rule);
|
||||||
border-radius: var(--r-md);
|
border-radius: var(--r-md);
|
||||||
padding: 22px 24px;
|
padding: 22px 24px;
|
||||||
|
|||||||
@@ -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;
|
||||||
+18
-21
@@ -1,29 +1,27 @@
|
|||||||
# nginx server block for bchanot.fr static site.
|
# nginx server block for bchanot.fr static site.
|
||||||
# Container listens on port 80; host port is configured via docker-compose
|
# Container (nginx-unprivileged) listens on 8080; host port is configured via
|
||||||
# (PORT env var). A host-level reverse proxy (nginx, Traefik, Caddy) should
|
# docker-compose (PORT env var). A host-level reverse proxy (nginx, Traefik,
|
||||||
# terminate TLS and proxy_pass to http://127.0.0.1:${PORT}.
|
# Caddy) should terminate TLS and proxy_pass to http://127.0.0.1:${PORT}.
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 8080;
|
||||||
listen [::]:80;
|
listen [::]:8080;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# Security headers. HSTS is intentionally NOT set here — leave it to the
|
# Don't advertise the nginx version.
|
||||||
# outer reverse proxy that terminates TLS, otherwise it may be sent over
|
server_tokens off;
|
||||||
# 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;
|
|
||||||
|
|
||||||
# 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;
|
real_ip_header X-Forwarded-For;
|
||||||
set_real_ip_from 0.0.0.0/0;
|
set_real_ip_from 127.0.0.1;
|
||||||
|
|
||||||
# Compression.
|
# Compression.
|
||||||
gzip on;
|
gzip on;
|
||||||
@@ -34,7 +32,6 @@ server {
|
|||||||
gzip_types
|
gzip_types
|
||||||
text/plain
|
text/plain
|
||||||
text/css
|
text/css
|
||||||
text/html
|
|
||||||
text/javascript
|
text/javascript
|
||||||
application/javascript
|
application/javascript
|
||||||
application/json
|
application/json
|
||||||
@@ -44,24 +41,24 @@ server {
|
|||||||
|
|
||||||
# Long cache for the PDF (regenerated rarely, content-hash not used).
|
# Long cache for the PDF (regenerated rarely, content-hash not used).
|
||||||
location ~* \.pdf$ {
|
location ~* \.pdf$ {
|
||||||
expires 7d;
|
|
||||||
add_header Cache-Control "public, max-age=604800";
|
add_header Cache-Control "public, max-age=604800";
|
||||||
|
include /etc/nginx/snippets/security-headers.conf;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Short cache for HTML so content updates land fast.
|
# Short cache for HTML so content updates land fast.
|
||||||
location ~* \.html$ {
|
location ~* \.html$ {
|
||||||
expires 1h;
|
|
||||||
add_header Cache-Control "public, max-age=3600, must-revalidate";
|
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).
|
# Long cache for favicon + image assets (rarely change).
|
||||||
location ~* \.(?:ico|svg|png|jpg|jpeg|gif|webp)$ {
|
location ~* \.(?:ico|svg|png|jpg|jpeg|gif|webp)$ {
|
||||||
expires 30d;
|
|
||||||
add_header Cache-Control "public, max-age=2592000, immutable";
|
add_header Cache-Control "public, max-age=2592000, immutable";
|
||||||
|
include /etc/nginx/snippets/security-headers.conf;
|
||||||
access_log off;
|
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;
|
access_log /var/log/nginx/access.log;
|
||||||
error_log /var/log/nginx/error.log warn;
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user