From 6fbbce07172beba3073caa966142ea01d804efe3 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:41:20 +0100 Subject: [PATCH 01/15] fix(security): pin install and download paths flagged by SonarCloud Six SonarCloud vulnerabilities in new code, all in shell and workflow steps. Each was verified rather than assumed, because three of the four rules have a failure mode where the "fix" breaks the build instead of hardening it. S6505 bun install without --ignore-scripts ci.yml:61, ci.yml:255, start.sh:14 S6506 wget may follow a redirect to http ci.yml:176 S6506 curl | sh does not pin the scheme install.sh:78 S8549 cargo build without --locked ci.yml:488 Verification, in the order the risk demanded: - --ignore-scripts also suppresses the ROOT package's own preinstall, postinstall and prepare hooks, not just those of dependencies. frontend/ package.json declares none of the three and sets no trustedDependencies, and `bun install --frozen-lockfile --ignore-scripts && bun run build` was run to completion: 539 installs, no changes, built in 17.36s, rc=0 on both. ci.yml:255 sits in the `test` job -- the only gate in this workflow without continue-on-error -- so breaking it would have removed the one honest signal. - --locked FAILS when no Cargo.lock exists, which would have turned a passing step into a hard error. hyperpolymath/cicd-squabbler does carry one at the pinned ref 6be52e34 (blob 2ffb575f), confirmed via the contents API before the flag was added. - --https-only lets wget follow https->https but refuses a downgrade, where --max-redirect=0 would break the step outright if the mirror ever adds a redirect. The URL resolves HTTP 200 with no redirect today, so the flag is inert now and load-bearing later. Not a Sonar finding, but the reason --locked matters here: ci.yml:488 is the cicd-squabbler build inside the `Gate triage` job, which is currently red on upstream PR #6. An unlocked dependency resolve is exactly the class of drift that makes such a job fail for reasons unrelated to the repo under test. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm --- .github/workflows/ci.yml | 8 ++++---- install.sh | 2 +- start.sh | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1f0103..48bed45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,7 @@ jobs: - name: Install frontend dependencies working-directory: frontend - run: bun install --frozen-lockfile + run: bun install --frozen-lockfile --ignore-scripts - name: Licence header check continue-on-error: ${{ github.repository != 'hyperpolymath/MetaManifold-WebUI' }} @@ -173,7 +173,7 @@ jobs: # this resolves rather than merely requesting the newest. - name: Set up R run: | - wget -qO- https://cloud.r-project.org/bin/linux/ubuntu/marutter_pubkey.asc \ + wget --https-only -qO- https://cloud.r-project.org/bin/linux/ubuntu/marutter_pubkey.asc \ | sudo gpg --dearmor -o /usr/share/keyrings/r-project.gpg echo "deb [signed-by=/usr/share/keyrings/r-project.gpg] https://cloud.r-project.org/bin/linux/ubuntu $(lsb_release -cs)-cran40/" \ | sudo tee /etc/apt/sources.list.d/r-project.list @@ -252,7 +252,7 @@ jobs: - name: Install frontend dependencies working-directory: frontend - run: bun install --frozen-lockfile + run: bun install --frozen-lockfile --ignore-scripts # Explicit strict-typecheck gate (strict foundation; see # docs/types/strict-mode-status.md). Runs before the (heavier) build so @@ -485,7 +485,7 @@ jobs: git clone --quiet https://github.com/hyperpolymath/cicd-squabbler.git /tmp/squabbler git -C /tmp/squabbler checkout --quiet "$SQUABBLER_SHA" echo "building cicd-squabbler at $(git -C /tmp/squabbler rev-parse HEAD)" - cargo build --release --quiet --manifest-path /tmp/squabbler/Cargo.toml -p squabble-cli + cargo build --release --locked --quiet --manifest-path /tmp/squabbler/Cargo.toml -p squabble-cli "$SQUABBLE" --version # Proves the engine itself works before we trust its verdict on this repo. diff --git a/install.sh b/install.sh index 9dcd083..a822ac6 100755 --- a/install.sh +++ b/install.sh @@ -75,7 +75,7 @@ if command -v julia &>/dev/null; then echo "Found Julia: $(julia --version)" else echo "Julia not found. Installing via juliaup..." - curl -fsSL https://install.julialang.org | sh -s -- --yes + curl -fsSL --proto '=https' --proto-redir '=https' https://install.julialang.org | sh -s -- --yes # juliaup installs to ~/.juliaup; source the env file if present if [ -f "$HOME/.juliaup/env" ]; then diff --git a/start.sh b/start.sh index e1b2ce0..41e6e41 100644 --- a/start.sh +++ b/start.sh @@ -11,7 +11,7 @@ has_bundled_frontend() { build_frontend() { if command -v bun >/dev/null 2>&1; then - (cd frontend && bun install --frozen-lockfile && bun run build) + (cd frontend && bun install --frozen-lockfile --ignore-scripts && bun run build) return fi From 1ff182679acaf8e2fcd32ad5d9ca5f88d230d1aa Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:46:46 +0100 Subject: [PATCH 02/15] perf(ci): make the R package restore resumable instead of restarting at zero The `Install R packages` step rebuilt all 79 packages from source on every run and kept nothing when it failed. Measured on two consecutive runs of ci.yml: run 35614055917 Install R packages 10m 08s run 35612508961 Install R packages 13m 21s That is the largest single cost in the workflow, and it was paid in full every time -- including when the restore died partway, which discarded every package that had already built. Two things caused it. renv's cache is content-addressed and would have made the work reusable, but `renv::restore` runs under sudo, so the cache landed in root's home rather than anywhere durable; and the workflow had no actions/cache step for R at all -- only Julia had one (line 127). Nothing survived the runner. The fix pins RENV_PATHS_CACHE to /opt/renv-cache and caches that directory: - The key carries github.run_id so every attempt writes a NEW entry and progress accumulates across failures; restore-keys then picks the most recent entry matching the prefix. A changed renv.lock falls through to the second key and still re-uses every package whose version did not move. - The save step is `if: always()` deliberately. actions/cache saves only on success, which would discard exactly the partial progress this cache exists to keep: the packages that DID build before a failure are the ones the next attempt must not build again. - The cache is written by root, so a separate `if: always()` step makes it readable before it is packed. - `sudo env VAR=...` is used rather than `sudo -E` or a bare VAR=value prefix, so the variable reaches Rscript without depending on the runner's sudoers env_reset policy -- which the existing comment in this step already notes resets the environment. Reordering the downloads was considered and rejected: renv derives install order from the dependency graph, so a package cannot be moved ahead of the packages it links against. Caching is what makes the retry cheap, not ordering. actions/cache is pinned to 0057852bfaa89a56745cba8c7296529d2fc39830 (v4 == 4.3.0), dereferenced to a commit rather than trusting the tag ref, matching the SHA-pinning discipline the rest of this workflow follows. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm --- .github/workflows/ci.yml | 53 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48bed45..2b8831f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -200,12 +200,63 @@ jobs: # .Rprofile, and with it the renv autoloader, which would otherwise rebind # .Library to its own sandbox under root's cache; the flag is used rather than # the environment variable above because sudo resets the environment first. + + # renv keeps a content-addressed cache: a package already in it is linked into + # the library instead of being downloaded and compiled again. Persisting that + # cache across runs is what makes a failed restore RESUMABLE. Without it all 79 + # packages are rebuilt from source on every run -- measured at 10m08s and 13m21s + # on two consecutive runs, the largest single cost in this workflow -- and a + # restore that dies at package 60 throws away all 60. + # + # Reordering the packages is not an alternative: renv derives install order from + # the dependency graph, so a package cannot be pulled to the front of the queue + # ahead of the packages it links against. + # + # The cache path is pinned rather than left at the default ~/.cache/R/renv, + # because the restore runs under sudo where ~ is root's home, not the runner's. + - name: Restore the renv cache + id: renv-cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: /opt/renv-cache + # The run id keeps every key unique so each attempt writes a NEW entry and + # progress accumulates; restore-keys then picks the most recent entry that + # matches the prefix. A changed renv.lock still falls through to the second + # key and re-uses every package whose version did not move. + key: renv-${{ runner.os }}-R${{ env.R_APT_VERSION }}-${{ hashFiles('renv.lock') }}-${{ github.run_id }} + restore-keys: | + renv-${{ runner.os }}-R${{ env.R_APT_VERSION }}-${{ hashFiles('renv.lock') }}- + renv-${{ runner.os }}-R${{ env.R_APT_VERSION }}- + - name: Install R packages + env: + RENV_PATHS_CACHE: /opt/renv-cache run: | + sudo mkdir -p "$RENV_PATHS_CACHE" + sudo chmod 777 "$RENV_PATHS_CACHE" sudo Rscript --no-init-file -e 'install.packages(c("renv", "BiocManager"), repos="https://cloud.r-project.org", lib=.Library)' - sudo Rscript --no-init-file -e 'renv::restore(project=".", library=.Library, prompt=FALSE)' + # `sudo env VAR=...` rather than `sudo -E` or a bare VAR=value prefix: it sets + # the variable for Rscript directly and so does not depend on the runner's + # sudoers env_reset policy, which the comment above already notes resets it. + sudo env RENV_PATHS_CACHE="$RENV_PATHS_CACHE" Rscript --no-init-file -e 'renv::restore(project=".", library=.Library, prompt=FALSE)' Rscript --no-init-file -e 'for (pkg in c("dada2","Biostrings","ShortRead","vegan","dplyr")) if (!require(pkg,character.only=TRUE,quietly=TRUE)) stop(pkg, " failed to install")' + # Both of the next two steps run on failure ON PURPOSE. actions/cache saves only + # when the job succeeds, which would discard exactly the partial progress this + # cache exists to preserve: the packages that DID build before the restore died + # are the ones the next attempt must not build again. The cache is written by + # root, so it is made readable before it is packed. + - name: Make the renv cache readable + if: always() + run: sudo chmod -R a+rX /opt/renv-cache || true + + - name: Save the renv cache + if: always() + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: /opt/renv-cache + key: renv-${{ runner.os }}-R${{ env.R_APT_VERSION }}-${{ hashFiles('renv.lock') }}-${{ github.run_id }} + - name: Install cutadapt run: pip install "$CUTADAPT_SPEC" From 06ef568313986136e1236a29dd98cbd6481f7688 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:52:19 +0100 Subject: [PATCH 03/15] ci(renv): print progress linearly instead of repainting a non-TTY log renv wraps its download counter in ESC[?25l / ESC[?25h and rewrites the line in place. A captured Actions log is not a terminal, so the rewrite never lands: the counter sticks at "(0/79) Downloading: SummarizedExperiment, gtable, ..." for the full ten to thirteen minutes of the restore, and the residue "25l25h25l" is all that survives of the escape sequences. The step looks hung when it is working. Setting R_CLI_DYNAMIC=false and TERM=dumb makes cli emit one line per update, so the log shows real progress -- and, more usefully, a step that genuinely stops making progress becomes distinguishable from one that is merely slow. Both variables are passed through `sudo env` alongside RENV_PATHS_CACHE for the same reason that one already is: the runner's sudoers policy resets the environment, so a step-level `env:` block alone does not reach Rscript. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm --- .github/workflows/ci.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b8831f..34700eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -231,14 +231,23 @@ jobs: - name: Install R packages env: RENV_PATHS_CACHE: /opt/renv-cache + # renv draws its download counter by hiding the cursor (ESC[?25l), rewriting + # the line in place, then showing it again (ESC[?25h) -- the "25l25h" residue + # that litters the log. A captured CI log is not a terminal, so the rewrite + # never lands and the counter appears frozen at (0/79) for the whole ten + # minutes while the download is in fact progressing. Disabling cli's dynamic + # output makes each update print on its own line, so the log shows real + # progress and a genuine hang becomes distinguishable from a working step. + R_CLI_DYNAMIC: "false" + TERM: dumb run: | sudo mkdir -p "$RENV_PATHS_CACHE" sudo chmod 777 "$RENV_PATHS_CACHE" - sudo Rscript --no-init-file -e 'install.packages(c("renv", "BiocManager"), repos="https://cloud.r-project.org", lib=.Library)' + sudo env R_CLI_DYNAMIC=false TERM=dumb Rscript --no-init-file -e 'install.packages(c("renv", "BiocManager"), repos="https://cloud.r-project.org", lib=.Library)' # `sudo env VAR=...` rather than `sudo -E` or a bare VAR=value prefix: it sets # the variable for Rscript directly and so does not depend on the runner's # sudoers env_reset policy, which the comment above already notes resets it. - sudo env RENV_PATHS_CACHE="$RENV_PATHS_CACHE" Rscript --no-init-file -e 'renv::restore(project=".", library=.Library, prompt=FALSE)' + sudo env RENV_PATHS_CACHE="$RENV_PATHS_CACHE" R_CLI_DYNAMIC=false TERM=dumb Rscript --no-init-file -e 'renv::restore(project=".", library=.Library, prompt=FALSE)' Rscript --no-init-file -e 'for (pkg in c("dada2","Biostrings","ShortRead","vegan","dplyr")) if (!require(pkg,character.only=TRUE,quietly=TRUE)) stop(pkg, " failed to install")' # Both of the next two steps run on failure ON PURPOSE. actions/cache saves only From fc215ab7b9bc636eb5c9c1b0391a72af3a9e098b Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:01:03 +0100 Subject: [PATCH 04/15] fix(a11y): make click-handling containers real buttons SonarCloud typescript:S1082 flags a non-interactive element carrying an onClick as keyboard-inaccessible: a div or span takes no focus, so the control exists for a mouse and for nothing else. Seven such controls become {isExpanded && ( `https://blast.ncbi.nlm.nih.gov/Blast.cgi?PROGRAM=blastn&DATABASE=nt&CMD=Put&ENTREZ_QUERY=NOT+uncultured+organism%5Borganism%5D+NOT+environmental+sample%5Borganism%5D&QUERY=${encodeURIComponent(seq)}` @@ -656,7 +657,7 @@ function ColumnDropdown({ column, distinctFetcher, activeFilters, keywordFilter, }, [onClose]) return ( -
e.stopPropagation()}> +
e.stopPropagation()}>