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/
|
||||
.git/
|
||||
dist/
|
||||
experiment/
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
@@ -57,6 +57,16 @@ jobs:
|
||||
sleep $((attempt * 15))
|
||||
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
|
||||
# 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
|
||||
@@ -142,3 +152,140 @@ jobs:
|
||||
git push origin main
|
||||
echo "Pushed — cinny-desktop release.yml will start via on:push trigger"
|
||||
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;
|
||||
|
||||
# ── 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 / {
|
||||
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