Skip to content

Harden CI dependency installs against lifecycle-script execution - #304

Merged
karoltheguy merged 2 commits into
mainfrom
fix/ci-supply-chain-hardening
Aug 3, 2026
Merged

Harden CI dependency installs against lifecycle-script execution#304
karoltheguy merged 2 commits into
mainfrom
fix/ci-supply-chain-hardening

Conversation

@karoltheguy

Copy link
Copy Markdown
Owner

Second PR in the SonarCloud cleanup series (group 7: CI/build supply-chain hardening), following #303.

Fixes 6 of the 12 githubactions:* findings in .github/workflows/tests.yml, applied identically to both the test-github and test-forgejo jobs.

npm (githubactions:S6505, 2 findings)

npm ci becomes:

npm ci --ignore-scripts
npm run copy-assets

The naive fix of just appending --ignore-scripts would have broken CI. The workflow's own comment says so: postinstall is what vendors monaco, xterm and quadlet-lint into static/vendor/, and the docker suites bind-mount the host tree over the app container. Suppressing it silently would have taken out every browser suite. Splitting it means third-party lifecycle scripts stay blocked while our own vendoring step is invoked deliberately.

Verified in an isolated copy of package.json + package-lock.json:

  • npm ci --ignore-scripts leaves no static/vendor/ at all, confirming the flag actually suppresses the hook
  • the following npm run copy-assets produces the full vendor tree, byte-identical to node_modules/monaco-editor/min/vs and the xterm/quadlet-lint sources

pip (githubactions:S8541, 4 findings)

Both requirements installs now pass --only-binary :all:, so no sdist setup.py executes during install.

Verified rather than assumed, since a missing wheel would fail CI hard: a pip install --dry-run --only-binary :all: of both requirements files on Python 3.12 (matching PYTHON_VERSION) resolves cleanly, exit 0.

Deliberately not fixed

The 6 githubactions:S8544 findings ("using dependencies without locking resolved versions") on the same lines stay open. Both requirements files use >= ranges, so satisfying that rule means introducing a genuinely pinned lockfile (pip-compile, ideally with hashes). That is a dependency-management decision that interacts with Dependabot, and folding it into a workflow-only change would make neither part reviewable. Worth its own issue.

Note unrelated to this PR

While verifying, I found that a local static/vendor/monaco/ checkout can go stale: same pinned monaco-editor 0.56.0 in node_modules, but an older vendored layout on disk. CI is unaffected (its cache is keyed on hashFiles('package-lock.json')), but a local dev server may be serving old monaco until npm run copy-assets is re-run.

Blocks third-party install hooks in both the test-github and test-forgejo
jobs, addressing SonarCloud githubactions:S6505 and githubactions:S8541.

npm ci now runs with --ignore-scripts. The repo's own postinstall
(copy-assets) is load-bearing: it vendors monaco, xterm and quadlet-lint
into static/vendor/, so it is invoked explicitly as a separate step
rather than left to run implicitly alongside dependency hooks.

pip installs now pass --only-binary :all:, so no sdist setup.py runs
during install. Verified with a dry-run resolve of requirements.txt and
requirements-test.txt on Python 3.12, matching the workflow's
PYTHON_VERSION: both resolve wheel-only, exit 0.

The six githubactions:S8544 findings on the same lines are deliberately
left open. Satisfying them needs a genuinely pinned lockfile rather than
the current >= ranges, which is a dependency-management decision with
Dependabot implications and does not belong in a workflow-only change.
@codacy-production

Copy link
Copy Markdown
Contributor

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@sonarqubecloud

sonarqubecloud Bot commented Aug 3, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
C Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@karoltheguy

Copy link
Copy Markdown
Owner Author

I will merge this PR now as Sonarqube's reported issues are now in #305 and will complete in another time.

@karoltheguy
karoltheguy merged commit 24d1395 into main Aug 3, 2026
14 of 15 checks passed
@karoltheguy
karoltheguy deleted the fix/ci-supply-chain-hardening branch August 3, 2026 20:00
karoltheguy added a commit that referenced this pull request Aug 3, 2026
Third PR in the SonarCloud cleanup series (group 2: HTML accessibility),
after #303 and #304. Clears all 62 open accessibility findings in the
templates and fixes #287.

| Rule | Count |
|---|---|
| `Web:InputWithoutLabelCheck` | 32 |
| `Web:S6853` | 27 |
| `Web:S5255` | 2 |
| `Web:S5254` | 1 |

## Not one fix repeated 62 times

The findings look uniform and are not. Six distinct cases, and applying
the obvious fix to all of them would have produced broken markup in two:

1. **Label paired with one control.** Add `for`/`id`, named
`<context>-<field>`. The bulk of the change.
2. **The same, but inside a `{% for %}` loop.**
`settings_servers.html`'s edit form renders once per server, so a static
`id="name"` would be emitted N times and every label would resolve to
the first server's field. The id has to interpolate the loop key. That
file already set the precedent one line up with `id="server-edit-row-{{
s[0] }}"`, so these follow it.
3. **Control with only a placeholder.** A placeholder is not an
accessible name: it is not exposed as one and it disappears once the
field has content. These get `aria-label`.
4. **A `<label>` sitting before a `<div class="radio-group">`.** A
`<label for>` must reference a labelable control, and a div is not one,
so `for` would have satisfied nothing and Sonar would have kept flagging
it. These become a `<span>` plus `role="radiogroup"` and
`aria-labelledby`.
5. **The machine-only `<textarea>` in the hidden HTMX save form.** Gets
an `aria-label` rather than a visible label, since it is never
user-facing.
6. **Document level.** `lang="en"` on `<html>`, and distinct
`aria-label`s on the two `<nav>` landmarks.

## The test

`tests/test_form_label_a11y.py` asserts the general contract (every
control has an accessible name, no label is left dangling) rather than
checking off the individual fixes, extending the source-assertion style
of `tests/test_settings_a11y.py` from #221.

Written before the fix, it independently reproduced Sonar's finding set
exactly, per file: 32 unnamed controls and 27 dangling labels, without
being given Sonar's line numbers. That match is the reason to trust it
as a stand-in for the analyzer going forward.

It also carries a guard that is green today and earns its place anyway:
`TestLoopScopedIdsAreUnique` fails if a static `id` appears inside a `{%
for %}`, which is precisely the mistake case 2 invites.

## Verification

* `-m unit`: 939 passed, 3 skipped
* unmarked suite: 47 passed
* `name=` attributes verified byte-identical before and after across all
seven templates, since form submission and the backend depend on them
* no pre-existing `id` value changed or dropped
* checked for id collisions across `dashboard.html` and the partials
injected into it. The four that exist (`admin-root`,
`systemctl-actions`, `systemd-status`, `themes-root`) are pre-existing
HTMX swap-target pairs, unchanged by this PR

## Out of scope

The design hook flags monotonous spacing in `dashboard.html`. That is
real, already tracked as #228, and unrelated: this PR adds attributes
and touches no layout. Left unchanged and unsuppressed.
karoltheguy added a commit that referenced this pull request Aug 3, 2026
…ons (#309)

Fourth PR in the SonarCloud cleanup series (group 3: Python test
hygiene), after #303, #304 and #308. Clears 43 of the 44 findings; the
44th is blocked on an app-side fix and explained below.

| Rule | Count | Status |
|---|---|---|
| `python:S5778` exception test has multiple invocations | 24 | Fixed |
| `python:S9073` composite assertion | 12 | Fixed |
| `python:S8997` global state mutation | 5 | Fixed, found a real bug |
| `python:S5958` assertion too broad | 3 | 2 fixed, 1 blocked |

## The one that was a real bug, not hygiene

`tests/test_config.py`'s `tearDown` **deleted** three environment
variables instead of restoring them:

```python
if "QUADLET_MASTER_KEY" in os.environ:
    del os.environ["QUADLET_MASTER_KEY"]
```

`.github/workflows/tests.yml:22` sets `QUADLET_MASTER_KEY` for the
entire job, so running this TestCase silently unset a variable it never
owned. It has not bitten yet only because `--dist=loadfile` puts each
file in its own worker, which masks the contamination.

`setUp` now snapshots the three variables and `tearDown` restores the
exact prior state, including restoring "was absent" as absent.
Demonstrated by running the TestCase in-process with the variable set:

```
BEFORE   tests=4 failures=0  QUADLET_MASTER_KEY after = None
AFTER    tests=4 failures=0  QUADLET_MASTER_KEY after = 'sentinel_value_from_ci'
```

Note the rule's own suggested fix is impossible in that file: it is a
`unittest.TestCase`, and `monkeypatch` is a pytest fixture that cannot
be injected into `TestCase` methods. Those three use `patch.dict`
instead, which restores prior values rather than clearing them. The two
findings in `test_crypto.py` are plain pytest functions and do use
`monkeypatch`; both genuinely leaked, one via a `finally` that restored
`DATABASE_PATH` but not the env var, the other via a `pop` on the last
line that never runs if an earlier assert fails.

## The blocked one

`tests/test_crypto.py` keeps `pytest.raises(Exception)` with a comment
saying why. `services/ssh_manager.py:118` genuinely raises bare
`Exception` on the decrypt-failure path, so there is no more specific
type to narrow to. That line is already tracked as `python:S112`
("replace this generic exception class with a more specific one") and is
scheduled in group 8. Once the app raises a dedicated exception, this
test can narrow. Inventing a type here would have made the test pass for
the wrong reason.

## What the mechanical fixes actually change

`S5778`: the offending second invocation was always mock construction
*inside* the block, so it could pass because `MagicMock()` threw rather
than the code under test. Hoisted to a local; each block now contains
exactly the call under test.

`S9073`: one split is worth a look. It exposed a decorative half:

```python
assert "(" in teardown_block, "Expected window._quadletLintDetach() to be invoked..."
```

Standing alone, "this JavaScript contains an open paren" is vacuously
true. Replaced with `assert "window._quadletLintDetach(" in
teardown_block`, which implies both original halves and actually tests
invocation:

| Scenario | Old | New |
|---|---|---|
| function invoked | passes | passes |
| merely referenced, never invoked | **passes** | **fails** |

## Verification

Test hygiene changes can quietly delete coverage, so this was checked by
AST rather than by eye:

* **159 test functions before and after**, none removed
* **assert statements 251 to 262**, up from splitting composites, never
down
* **no exception type broadened**: `pytest.raises(Exception)` went 3 to
1, the remainder being the blocked case
* `-m unit`: 939 passed, 3 skipped. Unmarked suite: 47 passed
* Re-run in random order (`-p randomly` enabled) to catch any new
cross-test contamination: 939 passed
* The two e2e files cannot run locally and were syntax-checked
karoltheguy added a commit that referenced this pull request Aug 3, 2026
Fixes the quality gate on this PR. Adding DualProtocolCM to both
test_log_level.py and test_session_duration.py pushed new-code duplication
to 14% against a 3% threshold.

The duplication was mostly pre-existing: the two files carried byte-identical
copies of the login mock, the login helper and SettingsMockDB, 75 duplicated
lines each. Touching those lines made them count as new code, which is the
same inherit-the-debt effect seen on PR #304.

Extracting the shared scaffolding fixes the cause rather than the symptom.
The two files drop from 147 and 127 lines to 79 and 56, and shared blocks
fall from 75 lines each to 22, which is now just imports and fixtures.

Follows the existing tests/sudo_permissions.py precedent for a non-test
helper module under tests/. test_sync_engine.py has its own DualProtocolCM
copy, deliberately left alone here since it is modified on PR #309 and
touching it would conflict.

Unit 942 and unmarked 47, both unchanged.
karoltheguy added a commit that referenced this pull request Aug 3, 2026
…art 1) (#310)

Fifth PR in the SonarCloud cleanup series (group 8: Python
maintainability), after #303, #304, #308 and #309. Clears 13 of 19
findings. The 6 `python:S3776` cognitive-complexity refactors are held
back for a second PR, since a control-flow change deserves isolated
review and a revert that does not drag constant extraction with it.

| Rule | Count | Status |
|---|---|---|
| `python:S1192` duplicated literals | 7 | Fixed |
| `python:S108` empty block | 2 | Fixed, exposed two bad test mocks |
| `python:S1066` nested if | 2 | Fixed |
| `python:S112` generic exception | 2 | Fixed, unblocks a finding left
open in #309 |
| `python:S3776` cognitive complexity | 6 | Deferred to part 2 |

## S1192, and a count discrepancy worth knowing

Sonar reports 3 occurrences each of `"/login"` and `"/change-password"`.
The file actually contains **5 of each**, since Sonar's
duplicate-literal count excludes route decorators. All occurrences were
replaced, not just the flagged subset.

Verified by AST rather than by eye: each of the seven literals now
appears exactly once, as its constant definition, and **no other string
literal in the file changed count**. That check is what makes a bulk
find-and-replace across a 1700-line routing module trustworthy.

## S108 found a test that was propping up the outlier

The two `async with db.execute(...): pass` blocks become `await
db.execute(...)`, which is what **every other write site in the
codebase** does. These two were the only exceptions.

That broke two tests, and the tests were wrong. Their hand-rolled
`SettingsMockDB.execute` returned a cursor supporting only `async with`,
while a real `aiosqlite` `execute()` result supports both protocols. The
repo already knows this: `tests/test_sync_engine.py:90` defines
`DualProtocolCM` whose docstring says *"Objects returned by aiosqlite
execute() support both `async with obj` and `await obj`."* Both mocks
now use that same pattern, so the fake stops dictating which idiom
production code may use.

Worth being explicit, since "the change broke tests so I changed the
tests" deserves scrutiny: the mock was asserting an implementation
detail, not behavior. The tests still prove the same thing, that
settings persist and apply live.

## S112, and why these do not subclass SSHCommandError

The two bare `raise Exception` in `ssh_manager.py` become
`ServerConfigurationError` and `KeyDecryptionError`, both subclassing
`Exception` directly.

Not `SSHCommandError`, because that type carries `exit_status` and
`stderr` for a command that actually ran, and
`tests/test_ssh_manager.py:137-138` asserts on exactly those fields.
Both new errors fire while assembling the connection, before anything
reaches the host, so typing them as command failures would hand callers
a null exit status and imply a command failed when none was issued.

Caller audit: all ~41 handlers in the codebase use `except Exception`,
and there are no exact-type comparisons such as `type(e) is Exception`,
so nothing downstream changes. Three new tests cover both types plus
`__cause__` chaining, and they fail against the previous code at import
time.

**Follow-up this enables:** #309 left one `python:S5958` open because
`ssh_manager.py:118` raised bare `Exception`, with a comment saying so.
Once both merge, that comment is stale and the test can narrow to
`KeyDecryptionError`. It could not be done in either PR alone without a
conflict, since `tests/test_crypto.py` is modified in #309.

## S1066

Two nested ifs merged. `and` short-circuits, so `_drop_if_stale()`'s
side effect still fires only when the connection is cached, exactly as
before, and the conditions are not reordered.

**One deliberate extra, not flagged by Sonar:** the structurally
identical re-check inside `get_connection`'s lock was merged too. Fixing
only the flagged one left two adjacent, semantically identical checks
written differently, which is worse than either form. Easy to drop if
you would rather keep the diff strictly to the findings.

## Verification

* `-m unit`: 939 to 942, the three new exception tests, nothing lost
* unmarked suite: 47 passed, unchanged
* re-run in random order to catch cross-test contamination: 942 passed
* AST verification of literal extraction as described above

## Deliberately skipped, with reasons

Three `S3776` findings will not be fixed even in part 2:

* `sync_engine.py:166` `check_quadlets` (complexity 25) and
`sync_engine.py:114` `_fetch_mtimes`. These are the subject of open bug
#283: the function iterates the `quadlets` table, and the only `INSERT
INTO quadlets` anywhere in the repo is in test files, so it is dead in
production. Its uncovered branches are **uncoverable by construction**
while the work set is always empty, meaning no test can protect a
refactor of the most complex function in the group. Whoever fixes #283
rewrites it anyway.
* `scripts/import_servers.py:12`, a one-off script not referenced by
`Dockerfile` or `start.sh`, so not shipped, and not covered.
Restructuring untested, unshipped code to satisfy a metric is risk
without benefit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant