Skip to content

Fix broken wheel/sdist packaging, and harden CI (timeouts, Dependabot, root-only NSE job, workflow lint) - #42

Merged
bandrel merged 8 commits into
mainfrom
ci/hardening
Aug 21, 2026
Merged

Fix broken wheel/sdist packaging, and harden CI (timeouts, Dependabot, root-only NSE job, workflow lint)#42
bandrel merged 8 commits into
mainfrom
ci/hardening

Conversation

@bandrel

@bandrel bandrel commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Why

Two packaging defects, found by actually building the artifacts rather than reading the config:

  1. The wheel shipped spoonmap.py and nothing else. The [tool.hatch.build.targets.wheel] artifacts list was a silent no-op — hatchling's artifacts key only un-excludes VCS-ignored files that already match an include pattern, it never adds files. Meanwhile spoonmap.py built its NSE script paths from the directory holding the module, so a pip installed spoonmap handed nmap nonexistent paths for ~14 ports. The [project.scripts] entry point was a claimed interface nothing verified.
  2. The sdist packaged 54 files of local scratch.remember/, .superpowers/sdd/, .claude/ — plus ranges.txt and any local config.json. Those are git-ignored via nested .gitignore files and .git/info/exclude, and hatchling honours neither. A uv build && uv publish from a working checkout would have shipped session logs and engagement scope.

Plus CI gaps: no timeout-minutes on any job (the suite exercises work_queue.join() and KeyboardInterrupt paths, and a past bug there failed as a hang), SHA-pinned actions with nothing to ever propose a bump, the macOS job never installing nmap, and the root-gated -sU NSE path verified nowhere.

Packaging

  • _NSE_DIR resolver: {_DIR}/nse in a checkout, {_DIR}/spoonmap_nse once installed. Checkout wins when both resolve. Verified across four deployment shapes including symlink-into-checkout, which works because _DIR uses realpath.
  • force-include actually packages the NSE tree; the sdist gets an explicit allowlist, not a blocklist, so it fails closed against scratch directories that don't exist yet.
  • New build job asserts on real archive contents: sdist excludes scratch and contains every required entry, the wheel's spoonmap_nse/ matches git-tracked nse/ by set equality (an untracked .nse file fails the build), and the wheel installed into a clean venv resolves every NSE path on disk.

CI

  • timeout-minutes on all 7 jobs; Dependabot for github-actions and uv.
  • nmap installed on macOS too — that leg was reporting 950 passed, 26 skipped against Ubuntu's 968 passed, 8 skipped, i.e. every NSE integration test silently skipping on the platform the tool is developed on.
  • New nse-root job runs test_nse_integration.py as root, the only place TestOpenvpnDetectNseUdp (the -sU path) executes. It carries a positive assertion that the class actually ran: both the root gate and conftest.py's port gate produce pytest skips, so the job would otherwise exit 0 having verified nothing. Verified failing when the class skips, when it's renamed, and when the module skips — plain pytest exits 0 in all three cases.
  • concurrency cancellation scoped to pull_request, so a fast follow-up merge no longer erases the previous main commit's green check.
  • bandit moved into the locked dev group (uvx --from "pkg==X" pins the tool but floats its transitive deps); actionlint + zizmor lint the whole workflow directory.
  • uv lock --check runs once instead of five times.
  • README and CLAUDE.md updated; the stale "Python 3.6+" claim corrected to 3.8+.

Verification

975 passed, 5 skipped, 100% coverage (floor 95%) · ruff clean · uv lock --check passes · bandit 32 baseline / 0 new · actionlint clean · zizmor no findings. No suppressions, no baseline additions, coverage floor untouched.

Both vacuous-pass guards were verified in the failing direction, not just the passing one — including planting an untracked nse/operator-notes.nse, confirming hatchling does package it, and confirming the set-equality check catches it.

Known residuals

  • nse-root has never run on an ubuntu-latest runner; this PR's own CI is its first real test. It now fails loudly rather than passing empty.
  • The nse/ leak protection is a CI gate, not a build-time exclusion: a local uv build && uv publish with untracked scratch in nse/ would still leak. Closing that needs a hatch build hook — follow-up, not a blocker.
  • 6 CUPS tests still skip on Ubuntu (both CUPS classes skip at once — one needs a reachable cupsd on 631, the other needs 631 free). Deliberately not "fixed": the cause was inferred, not observed. -rs is now on every pytest invocation, so this PR's CI log prints the real reason and settles it.

🤖 Generated with Claude Code

bandrel and others added 8 commits August 21, 2026 16:26
Port-script maps hardcoded f'{_DIR}/nse/<name>.nse', which only exists in a
checkout. Add _NSE_DIR (and the _resolve_nse_dir() helper behind it), which
falls back to {_DIR}/spoonmap_nse for the installed-wheel layout added in the
next commit, and repoint every occurrence at it. Checkout still wins when
nse/ is present, so existing path assertions are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The wheel's [tool.hatch.build.targets.wheel] `artifacts` list was a no-op —
it only un-excludes VCS-ignored files already matched by an include pattern —
so the built wheel shipped spoonmap.py alone while _NSE_DIR pointed at a
directory nothing packaged. Replace it with only-include plus force-include
of nse/ -> spoonmap_nse/ (renamed to avoid a site-packages name collision).

The default sdist swept in 54 of 91 entries of local session scratch
(.remember/, .superpowers/sdd/, .claude/) because hatchling honours neither
nested .gitignore nor .git/info/exclude; replace it with an explicit
allowlist, which also deliberately omits ranges.txt and config.json since
both hold real engagement data on an operator's working checkout.

Add a `build` CI job that builds both artifacts and asserts on their actual
contents: the sdist contains no scratch/engagement-data entries, the wheel
contains every nse/*.nse file (count derived from the directory listing, not
hardcoded), and a wheel installed into a clean venv resolves every bundled
NSE path in both port-script maps to a real file on disk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
uvx --from "bandit[toml]==1.9.4" pinned bandit itself but let its
transitive dependencies float, contradicting the reasoning that put
ruff exact-pinned in dev. Add bandit[toml]==1.9.4 to dev and refresh
uv.lock so CI can invoke it via `uv run --frozen bandit`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test_resolver_prefers_nse_subdir_when_present duplicated the coverage
already provided by test_nse_dir_prefers_checkout_directory just above
it.

More importantly, CI's wheel-contents check only walks
INTERNAL_PORT_SCRIPTS/EXTERNAL_PORT_SCRIPTS, so a revert of any of the
~45 other _NSE_DIR call sites in _FINDING_REPRO or
_scan_extra_sql_ports() back to a _DIR-relative nse/ path (which breaks
once installed from the wheel) would be invisible to the suite. Add a
test that asserts on the module's own source text instead, so every
call site is covered at once regardless of which one regresses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Add timeout-minutes to every job (10 min; generous but bounded versus
  the 6-hour default). This suite exercises work_queue.join(),
  threading.Event polling, and KeyboardInterrupt handling, and a past
  bug in that area failed as a hang rather than a clean failure.
- Add nse-root: runs tests/test_nse_integration.py as root via
  `sudo -E env "PATH=$PATH" uv run --frozen pytest ... --no-cov`, so
  TestOpenvpnDetectNseUdp (the -sU path, gated on os.geteuid() == 0 and
  skipped everywhere else) actually executes. sudo resets PATH, which
  drops the uv setup-uv just installed — verified locally that the
  explicit PATH re-injection is what makes this work rather than
  silently erroring or skipping. Coverage is disabled for this one
  job (95% floor in pyproject.toml is unchanged) since a single-module
  run can't meet a whole-suite floor.
- Install nmap on macOS (brew) as well as Ubuntu (apt), and drop the
  Linux-only guard: until now all 26 NSE integration tests silently
  skipped on macOS, the platform the tool is developed on. Add a
  nmap-presence guard step to every job that expects it, so a broken
  install fails loudly instead of turning assertions into skips.
- Scope concurrency's cancel-in-progress to pull_request events, so a
  fast follow-up merge to main no longer cancels the previous commit's
  in-progress run and erases its green check.
- Move bandit invocation to `uv run --frozen bandit` (dependencies now
  locked in pyproject.toml's dev group) and consolidate `uv lock
  --check` into the lint job instead of running it on every test
  matrix leg.
- Add -rs to pytest invocations so skips are visible in the log.
- Add workflow-lint: actionlint (YAML/expression errors) and zizmor
  (Actions-specific security auditing) against this file itself, since
  it's hand-edited often and carries several hand-maintained SHA pins.
- Add .github/dependabot.yml: weekly github-actions and uv updates, so
  the hand-maintained SHA/version pins actually receive proposed
  security bumps.
- Carry-over fixes from Task 1's review: add an empty-input guard to
  the wheel NSE-contents check (mirroring the clean-venv step's
  existing guard), drop the unused `import spoonmap` in the clean-venv
  snippet, and comment that only ranges.txt exercises the sdist
  assertion's six banned patterns today (the other five guard against
  a future commit).

Verified locally: actionlint and zizmor both clean, `bash -n` on every
shell block, YAML parses, and `sudo -E env "PATH=$PATH" uv run --frozen
pytest tests/test_nse_integration.py -k OpenvpnDetectNseUdp` passes
(not skips) both tests in TestOpenvpnDetectNseUdp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both documents described exactly four jobs (test, test-legacy, lint,
bandit); Task 1 had already added build without updating either. Bring
both current: build, nse-root, and workflow-lint, plus the
cancel-in-progress scoping, dependabot.yml, the single uv lock --check,
and bandit's move into the locked dev group. Also correct README's
"Python 3.6+ is required" — requires-python has been >=3.8 and CI
floors there too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…inor

Important: nse-root previously exited 0 whether or not the root-gated
class actually ran, because both a genuine port conflict
(tests/conftest.py) and the os.geteuid() root gate
(tests/test_nse_integration.py) are pytest skips, not errors. Add:
- a preflight step running the exact same `sudo -E env "PATH=$PATH"
  uv run --frozen ...` invocation to assert os.geteuid() == 0, proving
  sudo/PATH-reinjection/uv resolution work before the real run;
- --junitxml on the real run, and a step that parses it for any
  <skipped> whose message matches the root-gate reason string
  specifically ("requires root for -sU raw socket scan"), distinct
  from the port-conflict reason from conftest.py. A port-conflict skip
  still passes; a root-gate skip now fails the job.
Verified both directions locally: passes when TestOpenvpnDetectNseUdp
runs as root (23 passed, 3 skipped — the 3 are the unrelated CUPS
port-631 conflict), and fails when the same class is skipped for the
root reason (non-root run, both tests skip, script exits 1 naming
them).

Minor fixes:
- nse-root: enable-cache: false (root-owned uv cache entries would
  EPERM the runner-owned cache prune setup-uv runs afterward).
- workflow-lint comment: "four hand-maintained SHA pins" was already
  stale (13 occurrences, 2 distinct SHAs); reworded to not need
  updating as jobs are added.
- dependabot.yml: uv updates now scoped via `allow: dependency-type:
  development`, matching that every locked dependency today lives in
  the dev group.
- Documented why actionlint/zizmor stay on a bare `uvx --from` instead
  of the locked dev group: neither is part of spoonmap's dependency
  surface, both are single dependency-light binaries, and their blast
  radius is limited to linting the workflow file, unlike bandit which
  runs against spoonmap.py itself.
- workflow-lint now points actionlint (no path arg, auto-discovers)
  and zizmor (.github/workflows/ as input) at the whole workflows
  directory instead of naming ci.yml, so a future workflow file is
  covered. dependabot.yml is deliberately excluded from both — neither
  tool understands it, and its YAML validity is a separate parse.
- brew install nmap now sets HOMEBREW_NO_AUTO_UPDATE=1 so an
  unrelated `brew update` can't eat into the 10-minute timeout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, nse/ leak

Critical: workflow-lint had no `Install uv` step, unlike every other job.
`uv`/`uvx` is not preinstalled on ubuntu-latest, so both its steps died at
"command not found" on every real CI run while passing in local
verification, where `uv` was already on the developer's PATH — meaning
actionlint and zizmor audited nothing. Added the same pinned setup-uv step
the other six jobs use, with enable-cache: true (nothing in this job runs
under sudo, so there's no root-owned-cache conflict the way nse-root has).

Important: nse-root's junit guard was negative-only ("no skip mentioned the
root-gate reason") and passed vacuously if that reason string was reworded,
TestOpenvpnDetectNseUdp was renamed/deleted, or the whole module skipped
itself (nmap missing) — none of those produce a matching skip message.
Replaced with a positive assertion: at least one testcase with that
classname must exist in the junit report, and none of its testcases may be
skipped, for any reason. Verified all three previously-vacuous directions
now fail for real (non-root run: both tests skip and script exits 1 naming
them; class excluded via -k: "no testcase found", exit 1; a junit report
with zero collected testcases: same), and that a real root run (23 passed,
3 skipped — the 3 are the unrelated CUPS port-631 conflict on another
class) still passes.

Important: nse/ is a directory-wide include in both the sdist
(pyproject.toml) and the wheel's force-include, so anything sitting in it
ships regardless of name — reproduced a `nse/operator-notes.txt` landing in
both dist/*.tar.gz and dist/*.whl. Rewrote both the sdist and wheel content
checks to assert set equality against `git ls-files nse/`, not
`os.listdir('nse')` — the latter is tautological, since the build reads the
same working tree the check would compare against, so it can never catch
an untracked file. Verified: clean tree passes; with the untracked file
present, both checks now fail identifying `operator-notes.txt` as the
offender; removing it restores a clean pass.

Minor: added a positive floor to the sdist check — every required top-level
entry (spoonmap.py, nse/, tests/, README.md, CLAUDE.md, config.json.sample,
exclusions.txt, pyproject.toml, uv.lock, .bandit-baseline.json,
.github/workflows/) must actually be present. Deliberately hardcoded rather
than read back out of pyproject.toml's own include list: the failure this
guards against is that include list itself being gutted, so the floor can't
be derived from the same config it exists to check. Verified: temporarily
dropping "tests/" and "uv.lock" from pyproject.toml's include list makes
`uv build` still succeed but the new check correctly reports them missing;
restored afterward.

Minor: force-included config.json.sample into the wheel (pyproject.toml).
spoonmap.py's own config-error messages ("See config.json.sample for the
expected keys") name it by bare filename; it already shipped in the sdist
but not the wheel, leaving a wheel-installed user's error message pointing
at a file that didn't exist. exclusions.txt deliberately stays out: its
only reference is an interactive prompt's suggested default path, not a
template any error message points at, and the checked-in file is empty, so
shipping it adds no content.

Minor: doc drift — README.md and CLAUDE.md both said "the workflow file"
(singular) for workflow-lint; both tools now scan the whole
.github/workflows/ directory. Both docs now also describe the nse-root
junit guard's positive-assertion property (previously undocumented and,
after this fix, the job's single most important behavior) and the
Install-uv fix.

Minor: narrowed test_no_call_site_reverts_to_dir_relative_nse_path from a
substring regex over inspect.getsource() (which a future comment or
docstring quoting the banned pattern as prose could trip) to an AST walk
that matches only the code shape a real revert produces: a JoinedStr whose
FormattedValue names `_DIR`, immediately followed by a Constant string
starting with '/nse/'. Comments aren't part of the AST at all, and a plain
string constant (docstring or otherwise) can't produce that JoinedValue
shape, so prose can't trip it — verified locally with a comment, a
docstring, and a real `f'{_DIR}/nse/x.nse'` revert: only the last one is
flagged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bandrel
bandrel merged commit d52d85c into main Aug 21, 2026
12 checks passed
@bandrel
bandrel deleted the ci/hardening branch August 21, 2026 21:31
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