diff --git a/.github/scripts/check_sri.py b/.github/scripts/check_sri.py new file mode 100644 index 0000000..20cf705 --- /dev/null +++ b/.github/scripts/check_sri.py @@ -0,0 +1,45 @@ +"""Check the CDN scripts in index.html. + +- A script WITH an `integrity` attribute must also have `crossorigin`, and its hash + must match the file the CDN serves today. A wrong hash makes the browser refuse to + run the script, so FinLens would break with no visible error. That fails the check. +- A script WITHOUT `integrity` is reported as a warning for now. Content Security + Policy + SRI for every CDN script is PR #22; once it's on master, change + MISSING_IS_ERROR to True so no new unpinned script can slip in. +""" +import base64, hashlib, pathlib, re, sys, urllib.request + +MISSING_IS_ERROR = False + +root = pathlib.Path(__file__).resolve().parents[2] +html = (root / "index.html").read_text(encoding="utf-8") +errors = 0 + +for tag in re.finditer(r"]*\bsrc=\"(https?://[^\"]+)\"[^>]*>", html): + src, attrs = tag.group(1), tag.group(0) + m = re.search(r"integrity=\"([^\"]+)\"", attrs) + if not m: + level = "error" if MISSING_IS_ERROR else "warning" + errors += MISSING_IS_ERROR + print(f"::{level} file=index.html::No integrity attribute on {src}") + continue + if "crossorigin" not in attrs: + errors += 1 + print(f"::error file=index.html::integrity without crossorigin on {src}") + algo, expected = m.group(1).split("-", 1) + req = urllib.request.Request(src, headers={"User-Agent": "finlens-ci"}) + try: + with urllib.request.urlopen(req, timeout=30) as r: + body = r.read() + except Exception as e: # a CDN outage should fail loudly, not pass + errors += 1 + print(f"::error file=index.html::Could not download {src}: {e}") + continue + actual = base64.b64encode(hashlib.new(algo, body).digest()).decode() + if actual != expected: + errors += 1 + print(f"::error file=index.html::SRI hash mismatch for {src}\n in index.html: {algo}-{expected}\n CDN serves: {algo}-{actual}") + else: + print(f"ok {src}") + +sys.exit(1 if errors else 0) diff --git a/.github/scripts/check_syntax.py b/.github/scripts/check_syntax.py new file mode 100644 index 0000000..24fba2b --- /dev/null +++ b/.github/scripts/check_syntax.py @@ -0,0 +1,31 @@ +"""Syntax-check every JavaScript file FinLens loads, plus the inline ", html) +with tempfile.TemporaryDirectory() as tmp: + for n, body in enumerate(inline, 1): + f = pathlib.Path(tmp) / f"inline-{n}.js" + f.write_text(body, encoding="utf-8") + check(f, f"index.html (inline script {n})") + +sys.exit(1 if failed else 0) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fd71b76 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +# Runs on every pull request (any base branch, so stacked PRs are checked too) +# and on every push to master. Nothing here deploys or changes the repo. +on: + pull_request: + push: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + syntax: + name: JavaScript syntax + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: python3 .github/scripts/check_syntax.py + + sri: + name: CDN scripts (SRI) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - run: python3 .github/scripts/check_sri.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a41488d..1d2c039 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [2.0.0] — 2026-11-04 + +### Technical — CI +- **GitHub Actions checks on every pull request** (`.github/workflows/ci.yml`). Two jobs run on every PR, whatever branch it targets, and on every push to `master`. Nothing in the app changes. + - **JavaScript syntax** (`.github/scripts/check_syntax.py`) — runs `node --check` on every file in `js/` and on the inline `