ci: lockfile check, gitleaks, docker smoke job, renovate, shipped nginx security headers
- "Verify lockfile is in sync" (git diff --exit-code package-lock.json after npm ci) replaces the deleted GitHub lockfile workflow (#99). - gitleaks 8.30.1 binary scan on push + PR with a small allowlist for the public homeserver/registry URLs (#95). - docker job builds the image, runs it and asserts 200 + the security headers; continue-on-error until the runner is confirmed to have a Docker daemon (#93). .dockerignore keeps the context small. - docker-nginx.conf now sends a CSP (frame-src allowlist matching videoEmbed.ts), frame-ancestors 'none', Referrer-Policy and nosniff — shipped config, verify against the live chat.lotusguild.org headers before adopting in prod nginx (#95, #44 shipped-config half). - renovate.json + weekly renovate workflow for cinny and element-call; needs a RENOVATE_TOKEN secret (names starting GITEA_ are reserved) and stays continue-on-error until it exists (#94). - e2e job appended for the Playwright smoke test (#90), continue-on-error until green on the runner. Fixes #93 Fixes #94 Fixes #95 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -1,2 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.git/
|
.git/
|
||||||
|
dist/
|
||||||
|
experiment/
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ jobs:
|
|||||||
sleep $((attempt * 15))
|
sleep $((attempt * 15))
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# ── #99 — lockfile.yml (GitHub-only, deleted) commented on lockfile
|
||||||
|
# diffs after the fact; this is the CI-native equivalent, ported as a
|
||||||
|
# hard gate. `npm ci` already refuses to run on an out-of-range
|
||||||
|
# mismatch, but it can silently normalize lesser lockfile drift (e.g.
|
||||||
|
# metadata/resolved fields behind a stale-but-satisfiable range)
|
||||||
|
# without failing — so assert zero diff afterward, which is the
|
||||||
|
# cheapest way to also catch that class of drift.
|
||||||
|
- name: Verify lockfile is in sync
|
||||||
|
run: git diff --exit-code package-lock.json
|
||||||
|
|
||||||
# ── Quality gates run BEFORE the slow build so a format/lint/type/test
|
# ── Quality gates run BEFORE the slow build so a format/lint/type/test
|
||||||
# error fails in seconds instead of after the ~minutes-long build. All are
|
# error fails in seconds instead of after the ~minutes-long build. All are
|
||||||
# hard gates — any failure fails the job and blocks the deploy. The tree is
|
# hard gates — any failure fails the job and blocks the deploy. The tree is
|
||||||
@@ -142,3 +152,140 @@ jobs:
|
|||||||
git push origin main
|
git push origin main
|
||||||
echo "Pushed — cinny-desktop release.yml will start via on:push trigger"
|
echo "Pushed — cinny-desktop release.yml will start via on:push trigger"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── #95 — secret scanning ────────────────────────────────────────────────
|
||||||
|
# zricethezav/gitleaks-action is GitHub-Actions-only; on the Gitea act_runner
|
||||||
|
# we can't assume the host has a Docker daemon reachable from job containers
|
||||||
|
# (see the `docker` job below), so this downloads the pinned linux/amd64
|
||||||
|
# binary release directly instead. `--no-git` scans the checked-out tree as
|
||||||
|
# plain files (a point-in-time content scan) rather than walking history,
|
||||||
|
# since this runs on both push and pull_request and a PR's shallow checkout
|
||||||
|
# doesn't carry full history anyway.
|
||||||
|
gitleaks:
|
||||||
|
name: Secret scan (gitleaks)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install gitleaks 8.30.1
|
||||||
|
run: |
|
||||||
|
curl -fsSL -o gitleaks.tar.gz \
|
||||||
|
https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz
|
||||||
|
tar -xzf gitleaks.tar.gz gitleaks
|
||||||
|
chmod +x gitleaks
|
||||||
|
|
||||||
|
- name: Scan for secrets
|
||||||
|
run: ./gitleaks detect --no-git -v --redact --source . --config .gitleaks.toml
|
||||||
|
|
||||||
|
# ── #93 — the image was never actually built in CI, so a Dockerfile break
|
||||||
|
# (or a header regression, once #95's nginx CSP shipped) could sit unnoticed
|
||||||
|
# until a manual `docker build` on deploy infra caught it. This builds the
|
||||||
|
# real image, boots it, and asserts both a 200 and the security headers
|
||||||
|
# added to docker-nginx.conf for #95.
|
||||||
|
#
|
||||||
|
# `continue-on-error: true` — informational for now. The shared act_runner
|
||||||
|
# may not expose a Docker daemon to job containers (same class of problem
|
||||||
|
# as the unreachable cache server noted above); flip this off once it's
|
||||||
|
# confirmed the runner can actually run `docker build`/`docker run` here.
|
||||||
|
docker:
|
||||||
|
name: Docker image build & smoke test
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
continue-on-error: true
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build image
|
||||||
|
run: docker build -t cinny-ci .
|
||||||
|
|
||||||
|
- name: Run container
|
||||||
|
run: docker run -d --name cinny-ci -p 8095:80 cinny-ci
|
||||||
|
|
||||||
|
- name: Wait for container to be ready
|
||||||
|
run: |
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
curl -fsS -o /dev/null http://localhost:8095/ && exit 0
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "container never became ready" >&2
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Check response and security headers
|
||||||
|
run: |
|
||||||
|
headers="$(curl -fsSI http://localhost:8095/)"
|
||||||
|
echo "$headers"
|
||||||
|
echo "$headers" | grep -qi '^HTTP/[0-9.]* 200' || { echo "expected HTTP 200"; exit 1; }
|
||||||
|
echo "$headers" | grep -qi '^content-security-policy:' || { echo "missing Content-Security-Policy header"; exit 1; }
|
||||||
|
echo "$headers" | grep -qi "frame-ancestors 'none'" || { echo "CSP missing frame-ancestors 'none'"; exit 1; }
|
||||||
|
echo "$headers" | grep -qi '^referrer-policy: *no-referrer' || { echo "missing Referrer-Policy header"; exit 1; }
|
||||||
|
echo "$headers" | grep -qi '^x-content-type-options: *nosniff' || { echo "missing X-Content-Type-Options header"; exit 1; }
|
||||||
|
|
||||||
|
- name: Stop container
|
||||||
|
if: always()
|
||||||
|
run: docker rm -f cinny-ci || true
|
||||||
|
|
||||||
|
# ── #90 — Playwright smoke test ──────────────────────────────────────────
|
||||||
|
# Boots the built client in a real (headless) Chromium and drives it. Two
|
||||||
|
# tiers live under e2e/ (see LOTUS_TESTING.md → "Playwright smoke test"):
|
||||||
|
# boot tier — always runs: login page renders with no console/page
|
||||||
|
# errors, sw.js is served + registers, bundled Element Call
|
||||||
|
# mounts in a frame.
|
||||||
|
# E2EE tier — password login, create a private encrypted room, send text
|
||||||
|
# + a compressed image, assert every `PUT …/send/*` went out
|
||||||
|
# as m.room.encrypted. Skips itself unless the E2E_* secrets
|
||||||
|
# below are set (create them under repo → Settings → Actions
|
||||||
|
# → Secrets; they are empty until then).
|
||||||
|
# dist/ is rebuilt in-job because actions/upload-artifact@v4 does not work
|
||||||
|
# on this Gitea runner (LOTUS_TODO), so `needs: build` only gates on the
|
||||||
|
# main job having passed, not on its artifact.
|
||||||
|
# continue-on-error: `playwright install --with-deps` needs apt on the
|
||||||
|
# runner image; promote to hard once green on the runner.
|
||||||
|
e2e:
|
||||||
|
name: Playwright smoke (e2e)
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
continue-on-error: true
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: '.node-version'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
npm config set fetch-retries 5
|
||||||
|
npm config set fetch-retry-mintimeout 20000
|
||||||
|
npm config set fetch-retry-maxtimeout 120000
|
||||||
|
npm config set fetch-timeout 600000
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
echo "npm ci attempt $attempt…"
|
||||||
|
npm ci && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
echo "npm ci failed after 3 attempts" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "npm ci failed; retrying in $((attempt * 15))s…" >&2
|
||||||
|
sleep $((attempt * 15))
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Install Playwright Chromium
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: '--max_old_space_size=6144'
|
||||||
|
VITE_APP_VERSION: ${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Playwright smoke test
|
||||||
|
run: npm run test:e2e
|
||||||
|
env:
|
||||||
|
CI: 'true'
|
||||||
|
E2E_HOMESERVER: ${{ secrets.E2E_HOMESERVER }}
|
||||||
|
E2E_USER: ${{ secrets.E2E_USER }}
|
||||||
|
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: Renovate
|
||||||
|
|
||||||
|
# Gitea #94 — no dependency update automation existed at all. Runs the
|
||||||
|
# official renovate/renovate Docker image against this Gitea instance.
|
||||||
|
#
|
||||||
|
# Requires a `RENOVATE_TOKEN` repo/org secret: a Gitea access token with
|
||||||
|
# read/write on LotusGuild/cinny and LotusGuild/element-call, created by a
|
||||||
|
# maintainer — this workflow does not (and cannot) create one for you.
|
||||||
|
# Note: Gitea reserves the `GITEA_` secret-name prefix, so the token cannot
|
||||||
|
# be named e.g. `GITEA_TOKEN` — hence `RENOVATE_TOKEN`.
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 4 * * 1' # weekly, Monday 04:00 UTC
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
renovate:
|
||||||
|
name: Renovate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
continue-on-error: true # informational until RENOVATE_TOKEN is confirmed present
|
||||||
|
steps:
|
||||||
|
- name: Run Renovate
|
||||||
|
uses: docker://renovate/renovate:44
|
||||||
|
env:
|
||||||
|
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
|
RENOVATE_PLATFORM: gitea
|
||||||
|
RENOVATE_ENDPOINT: https://code.lotusguild.org/api/v1
|
||||||
|
RENOVATE_REPOSITORIES: LotusGuild/cinny,LotusGuild/element-call
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
title = "gitleaks config for Lotus Chat (cinny fork)"
|
||||||
|
|
||||||
|
# Gitea #95 — secret scanning was entirely absent. Extend gitleaks' built-in
|
||||||
|
# ruleset (don't replace it) and allowlist the known-public infrastructure
|
||||||
|
# URLs that show up in tracked config, which are hostnames, not secrets.
|
||||||
|
[extend]
|
||||||
|
useDefault = true
|
||||||
|
|
||||||
|
[allowlist]
|
||||||
|
description = "Known-public Lotus/Matrix homeserver + npm registry URLs — not secrets"
|
||||||
|
regexes = [
|
||||||
|
'''https?://matrix\.lotusguild\.org''',
|
||||||
|
'''https?://code\.lotusguild\.org/api/packages/LotusGuild/npm/''',
|
||||||
|
'''matrix\.lotusguild\.org''',
|
||||||
|
]
|
||||||
|
paths = [
|
||||||
|
'''config\.json''',
|
||||||
|
'''\.npmrc''',
|
||||||
|
]
|
||||||
@@ -2,6 +2,42 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
|
|
||||||
|
# ── Gitea #95 / #44 — shipped image had no security headers at all.
|
||||||
|
# `always` so these are sent on error responses too, not just 200s.
|
||||||
|
#
|
||||||
|
# Content-Security-Policy, directive by directive:
|
||||||
|
# default-src 'self' baseline: same-origin unless a directive below opens it up
|
||||||
|
# script-src 'self' 'wasm-unsafe-eval'
|
||||||
|
# app code is same-origin only; 'wasm-unsafe-eval' is required
|
||||||
|
# for the wasm modules used for E2EE crypto and audio denoise
|
||||||
|
# style-src 'self' 'unsafe-inline'
|
||||||
|
# vanilla-extract (the app's CSS-in-JS) emits inline <style>,
|
||||||
|
# so 'unsafe-inline' is required — no remote stylesheets needed
|
||||||
|
# img-src * data: blob: avatars/media/previews come from whichever homeserver or
|
||||||
|
# media repo the user points the client at — not knowable
|
||||||
|
# ahead of time — plus data: URIs and blob: for local previews
|
||||||
|
# media-src * blob: same reasoning as img-src, for audio/video attachments
|
||||||
|
# connect-src * the Matrix homeserver is user-chosen at runtime, so this
|
||||||
|
# can't be pinned to a fixed origin
|
||||||
|
# worker-src 'self' blob: service worker + blob: web workers (crypto/denoise) are
|
||||||
|
# same-origin or created from in-memory blobs, never remote
|
||||||
|
# frame-src ... the rich link-preview embeds in
|
||||||
|
# src/app/utils/videoEmbed.ts, one entry per provider:
|
||||||
|
# YouTube, Vimeo, Dailymotion, Streamable, Twitch, Spotify,
|
||||||
|
# SoundCloud, Apple Music, Tidal, Mixcloud, Deezer,
|
||||||
|
# Instagram, Reddit, Bluesky, Loom, Kick, TikTok, Steam —
|
||||||
|
# plus 'self' (no first-party iframes today, cheap to allow)
|
||||||
|
# object-src 'none' no <object>/<embed> plugin content is used anywhere
|
||||||
|
# base-uri 'self' blocks a <base> tag injection from redirecting relative URLs
|
||||||
|
# frame-ancestors 'none' this app must never be framed by another site (clickjacking)
|
||||||
|
#
|
||||||
|
# Shipped config — verify against chat.lotusguild.org's live headers before
|
||||||
|
# enabling this in the production nginx config; this file is currently only
|
||||||
|
# exercised by the CI `docker` smoke-test job, not by the live deploy path.
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src * data: blob:; media-src * blob:; connect-src *; worker-src 'self' blob:; frame-src 'self' https://www.youtube-nocookie.com https://player.vimeo.com https://geo.dailymotion.com https://streamable.com https://clips.twitch.tv https://player.twitch.tv https://open.spotify.com https://w.soundcloud.com https://embed.music.apple.com https://embed.tidal.com https://www.mixcloud.com https://widget.deezer.com https://www.instagram.com https://embed.reddit.com https://embed.bsky.app https://www.loom.com https://player.kick.com https://www.tiktok.com https://store.steampowered.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
|
||||||
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": ["config:recommended"],
|
||||||
|
"schedule": ["before 6am on monday"],
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"matchPackagePatterns": ["^matrix-js-sdk"],
|
||||||
|
"groupName": "matrix-js-sdk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchPackagePatterns": ["^@lotusguild/"],
|
||||||
|
"groupName": "@lotusguild packages"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchPackageNames": ["@lotusguild/element-call-embedded"],
|
||||||
|
"matchUpdateTypes": ["major"],
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchCategories": ["security"],
|
||||||
|
"groupName": "security updates",
|
||||||
|
"automerge": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user