From 97c9395991457f72b2b2972ca143777e363a3307 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 21 Aug 2026 17:59:45 -0400 Subject: [PATCH 1/6] fix: resolve operator paths against CWD, not module directory dir_path was derived from os.path.dirname(os.path.realpath(__file__)), which decides where config.json, exclusions.txt, and scan output resolve. Under `uv tool install`, that directory is inside uv's managed tool venv -- not a place an operator can put a config or would want engagement results written, and one uv tool upgrade rebuilds from scratch. Extract the derivation into a module-level _operator_dir() helper (os.getcwd()) so it is testable outside main()'s pragma-no-cover region, and switch main()'s dir_path to it. _DIR/_NSE_DIR are left untouched -- they anchor the bundled NSE scripts, which are program data and must keep resolving from the module's own location regardless of the caller's CWD. Also drop the vestigial `global dir_path` in main(): nothing outside main() reads a module-level dir_path (both _cleanup_cmd and _load_config take it as a parameter), unlike output_path, whose global is genuinely read by tests via spoonmap.output_path. The two config.json.sample guidance messages now name the resolved path (via _DIR) instead of a bare filename, since that file lives next to the module -- nowhere near an installed user's CWD -- so a bare name is unactionable. Co-Authored-By: Claude Fable 5 --- spoonmap.py | 20 ++++++++++++++++---- tests/test_spoonmap.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/spoonmap.py b/spoonmap.py index d35073a..5e1d4f4 100755 --- a/spoonmap.py +++ b/spoonmap.py @@ -5357,7 +5357,7 @@ def _config_target_scan(value): return valid print(_COLOR_ERROR + f'ERROR: config.json: target_scan = {value!r} is not ' "'Internal' or 'External'." + _COLOR_RESET) - print('See config.json.sample for the expected keys, or delete ' + print(f'See {_DIR}/config.json.sample for the expected keys, or delete ' 'config.json to be prompted instead.') sys.exit(1) @@ -5380,7 +5380,7 @@ def _load_config(config_parser, dir_path, resume=False): print(_COLOR_ERROR + 'ERROR: config.json is missing required ' f'{"key" if len(missing) == 1 else "keys"}: ' + ', '.join(missing) + _COLOR_RESET) - print('See config.json.sample for the expected keys, or delete ' + print(f'See {_DIR}/config.json.sample for the expected keys, or delete ' 'config.json to be prompted instead.') sys.exit(1) @@ -5463,12 +5463,24 @@ def _load_config(config_parser, dir_path, resume=False): } +def _operator_dir(): + """Directory operator data (config.json, exclusions, output) resolves against. + + Deliberately the CWD, not _DIR (the module's own location): an installed + `spoonmap` (via `uv tool install`) has a module directory inside uv's + managed tool venv, which is not a place an operator can put a config or + would want scan results written, and which `uv tool upgrade` rebuilds from + scratch. Pulled out of main() — which is pragma-no-cover — so this + derivation itself stays under test. + """ + return os.getcwd() + + # The Main Guts def main(): # pragma: no cover -- interactive CLI entry point; orchestrates # already-independently-tested functions behind input()-driven prompts, # so there's little signal in mocking every prompt/subprocess in one # giant test versus exercising each called function directly. - global dir_path global output_path # Save initial terminal state @@ -5496,7 +5508,7 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates # Get options from configuration file if it exists - dir_path = os.path.dirname(os.path.realpath(__file__)) + dir_path = _operator_dir() if '--cleanup' in sys.argv: _cleanup_cmd(dir_path) # prints result and exits diff --git a/tests/test_spoonmap.py b/tests/test_spoonmap.py index 881eaa2..3406982 100644 --- a/tests/test_spoonmap.py +++ b/tests/test_spoonmap.py @@ -89,6 +89,7 @@ _parse_masscan_ping_xml, _parse_nmap_sn_xml, _parse_result_xml, + _operator_dir, _quarantine_failed_output, _resolve_nse_dir, _aggregate_result_dir, @@ -2536,6 +2537,10 @@ def test_every_missing_key_named_in_one_message(self, capsys): for key in ('banner_scan', 'max_rate', 'target_file', 'output_path'): assert key in out assert 'missing required keys' in out + # config.json.sample is program data next to the module (_DIR), not + # the operator's CWD, so the guidance must name a real path rather + # than a bare filename an installed user has no hope of finding. + assert f'{spoonmap._DIR}/config.json.sample' in out # ---- target_scan validation -------------------------------------------- @@ -2558,6 +2563,7 @@ def test_unrecognised_target_scan_exits(self, capsys): out = capsys.readouterr().out assert 'target_scan' in out assert "'Internal' or 'External'" in out + assert f'{spoonmap._DIR}/config.json.sample' in out def test_null_target_scan_exits_rather_than_scanning(self, capsys): with pytest.raises(SystemExit): @@ -11153,3 +11159,33 @@ def test_no_call_site_reverts_to_dir_relative_nse_path(self): 'referenced via _NSE_DIR so they resolve in an installed wheel: ' + str(offenders) ) + + +class TestOperatorDirResolution: + """Operator data (config.json, exclusions, output) must resolve against + the CWD, never the module's own location — the opposite anchor from + _DIR/_NSE_DIR, which stay module-relative for bundled program data.""" + + def test_operator_dir_is_the_cwd(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert _operator_dir() == str(tmp_path) + + def test_operator_dir_follows_cwd_changes(self, tmp_path, monkeypatch): + # A real behavioural assertion, not a restatement of os.getcwd(): + # confirm the helper tracks a change in CWD rather than caching one + # resolved at import time. + first = tmp_path / 'first' + second = tmp_path / 'second' + first.mkdir() + second.mkdir() + monkeypatch.chdir(first) + assert _operator_dir() == str(first) + monkeypatch.chdir(second) + assert _operator_dir() == str(second) + + def test_operator_dir_is_not_module_relative(self, tmp_path, monkeypatch): + # Regression guard for PR #42: an installed spoonmap's module lives + # inside uv's managed tool venv, which must never be where operator + # data resolves. + monkeypatch.chdir(tmp_path) + assert _operator_dir() != spoonmap._DIR From 2c6af13ecb5e09d241229b315e660c9e724342c8 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 21 Aug 2026 17:59:50 -0400 Subject: [PATCH 2/6] docs: document CWD-relative operator paths and the behaviour change config.json, exclusions.txt, default output, relative config values, and --cleanup now resolve against the CWD the command was run from, not the directory containing spoonmap.py. Document that plainly in both README.md (a new "Where Files Live" section, plus an updated config.json parameter table entry for output_path) and CLAUDE.md (a "Operator path resolution" note tied to _operator_dir()), including an explicit call-out that invoking by absolute/relative path from another directory now behaves differently than before, and the one exception: bundled NSE scripts under nse/ are program data and keep resolving from the module's own location (_DIR/_NSE_DIR) regardless of CWD. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 26 ++++++++++++++++++++++++++ README.md | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index a1a0a9c..cbfe78d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,32 @@ cp config.json.sample config.json ./spoonmap.py ``` +### Operator path resolution + +Every operator-facing path — `config.json`, `exclusions.txt`, the default +output location, relative `target_file`/`output_path`/`exclusions_file` +values read out of `config.json`, and `--cleanup`'s search for a config to +read `output_path` from — resolves against the current working directory the +command was run from, via `_operator_dir()` (a thin wrapper around +`os.getcwd()`, kept as its own module-level helper so it is testable outside +`main()`'s `# pragma: no cover` region). An operator's config and scan output +belong in the directory they ran the engagement from, not wherever the +program happens to be installed — those are frequently different places, and +this is true independent of how (or whether) the tool is installed. + +This is a behaviour change from resolving against `os.path.dirname(os.path.realpath(__file__))`: +invoking by absolute path from another directory (`cd /tmp && +/opt/spoonmap/spoonmap.py`) previously read `/opt/spoonmap/config.json` and +wrote output there; it now resolves against `/tmp`. The documented invocation +— running `./spoonmap.py` or `uv run spoonmap.py` from inside the checkout — +is unaffected, since CWD and checkout are the same directory there. + +This is a different anchor from `_DIR`/`_NSE_DIR` (`spoonmap.py:2459`), which +stay `__file__`-relative on purpose: the bundled NSE scripts under `nse/` are +program data that ships with SpooNMAP itself, not operator data, and must +resolve identically regardless of the caller's CWD. `_operator_dir()` and +`_DIR` are deliberately two separate anchors — do not collapse them into one. + Run the test suite with: ```bash diff --git a/README.md b/README.md index 12b4ec5..37921d7 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,42 @@ uv run spoonmap.py --cleanup ./spoonmap.py --cleanup /path/to/output ``` +## Where Files Live + +Every operator-facing path resolves against the directory you run the command +from — not the directory containing `spoonmap.py`. That covers `config.json`, +`exclusions.txt`, the default output location, any relative `target_file` / +`output_path` / `exclusions_file` value written inside `config.json`, and +`--cleanup`'s search for a config to read `output_path` from. The reasoning is +simple: your config and your scan results belong in the directory where you +ran the engagement from, not wherever the program itself happens to sit on +disk — those are frequently different places, and an operator has no reason to +go looking in the latter for the former. + +**This is a behaviour change.** Previously these paths resolved against the +directory containing `spoonmap.py` itself. If you always run `./spoonmap.py` +or `uv run spoonmap.py` from inside the checkout, nothing changes — your CWD +and the checkout are the same directory. But if you invoke it by an absolute +or relative path from somewhere else — + +```bash +cd /tmp +/opt/spoonmap/spoonmap.py +``` + +— behaviour is different from before: this used to read `/opt/spoonmap/config.json` +and write output under `/opt/spoonmap/`. It now reads `/tmp/config.json` and +writes output under `/tmp/`. If you have a habit of invoking SpooNMAP from +outside its own directory, check where your `config.json` and prior output +actually are before your next run. + +**What does *not* follow this rule:** the bundled NSE scripts under `nse/` +(`.nse` files invoked during `script_scan`) are program data, not operator +data — they ship with SpooNMAP itself and always resolve from the directory +containing `spoonmap.py`, regardless of your CWD. This is the one exception, +and it exists so the script scan works identically no matter where you +happen to run the tool from. + ## Target File (ranges.txt) `ranges.txt` is committed to the repository as an empty placeholder and is marked `skip-worktree`, so git will never stage local edits to it. Fill it with your target ranges freely — they will never be accidentally committed. @@ -171,7 +207,7 @@ git update-index --no-skip-worktree ranges.txt | `target_scan` | `"External"` / `"Internal"` | Selects discovery port lists and NSE script sets; no source-port override is applied | | `max_rate` | Packets/second string | See rate guidance below | | `target_file` | Path | One IP, CIDR, or hostname per line; `ranges.txt` is committed as a blank placeholder (see below) | -| `output_path` | Path | Directory for all output; relative paths resolve to script dir | +| `output_path` | Path | Directory for all output; relative paths resolve to the current working directory (see "Where Files Live" above) | | `exclusions_file` | Path | IPs/CIDRs to exclude; SpooNMAP pre-computes the set intersection with the target file and passes only the net target IPs to masscan (see below) | | `nmap_threads` | Integer | Concurrent nmap processes (default: 5); prompted under "Tune advanced settings" | | `masscan_batch_size` | Integer | Ports per masscan invocation (default: 5); prompted under "Tune advanced settings" | From 4a3dd80909b8dfdc3e6033e4c78905c55268f796 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 21 Aug 2026 18:02:48 -0400 Subject: [PATCH 3/6] fix: drop uv-tool-install framing from _operator_dir() rationale The docstring and a test comment justified CWD-vs-_DIR by citing `uv tool install`'s managed venv, which cites a packaging mechanism this project has not committed to shipping. Reworded to the install-agnostic reasoning already used in README.md: operator data belongs where the command was run from, not wherever the module happens to be installed -- true regardless of install mechanism. Co-Authored-By: Claude Fable 5 --- spoonmap.py | 12 ++++++------ tests/test_spoonmap.py | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/spoonmap.py b/spoonmap.py index 5e1d4f4..4dafb60 100755 --- a/spoonmap.py +++ b/spoonmap.py @@ -5466,12 +5466,12 @@ def _load_config(config_parser, dir_path, resume=False): def _operator_dir(): """Directory operator data (config.json, exclusions, output) resolves against. - Deliberately the CWD, not _DIR (the module's own location): an installed - `spoonmap` (via `uv tool install`) has a module directory inside uv's - managed tool venv, which is not a place an operator can put a config or - would want scan results written, and which `uv tool upgrade` rebuilds from - scratch. Pulled out of main() — which is pragma-no-cover — so this - derivation itself stays under test. + Deliberately the CWD, not _DIR (the module's own location): an operator's + config and scan results belong in the directory they ran the engagement + from, not wherever the module happens to be installed — those are + frequently different directories, regardless of install mechanism. Pulled + out of main() — which is pragma-no-cover — so this derivation itself + stays under test. """ return os.getcwd() diff --git a/tests/test_spoonmap.py b/tests/test_spoonmap.py index 3406982..ba52566 100644 --- a/tests/test_spoonmap.py +++ b/tests/test_spoonmap.py @@ -11184,8 +11184,8 @@ def test_operator_dir_follows_cwd_changes(self, tmp_path, monkeypatch): assert _operator_dir() == str(second) def test_operator_dir_is_not_module_relative(self, tmp_path, monkeypatch): - # Regression guard for PR #42: an installed spoonmap's module lives - # inside uv's managed tool venv, which must never be where operator - # data resolves. + # Regression guard for PR #42: wherever the module is installed must + # never be where operator data resolves, independent of install + # mechanism. monkeypatch.chdir(tmp_path) assert _operator_dir() != spoonmap._DIR From 7fd8867d847663f39766ac72fe1164179756813f Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 21 Aug 2026 18:05:07 -0400 Subject: [PATCH 4/6] docs: add uv tool install walkthrough to README Packaging decision is resolved -- the wheel stays exactly as built by pyproject.toml's hatch config, so the earlier hold on documenting the install path is lifted. Adds an "Installation" section to README.md covering `uv tool install git+https://github.com/trustedsec/spoonmap` (no PyPI package involved -- naming just `spoonmap` will not work), `uv tool upgrade`, and an explicit note that masscan/nmap still need separate installation either way. Cross-references "Where Files Live" rather than restating it, since an installed spoonmap is the case where the CWD rule matters most. CLAUDE.md gets one paragraph noting the wheel is a supported consumption path contributors must keep working (config.json.sample and nse/ landing in the wheel, per the build CI job) -- no end-user walkthrough, since that file is contributor-facing. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 6 ++++++ README.md | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index cbfe78d..a7a7ae8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,12 @@ program data that ships with SpooNMAP itself, not operator data, and must resolve identically regardless of the caller's CWD. `_operator_dir()` and `_DIR` are deliberately two separate anchors — do not collapse them into one. +The wheel built by `pyproject.toml`'s hatch config is a supported consumption +path (`uv tool install git+https://github.com/trustedsec/spoonmap`; see +README.md for the end-user walkthrough), so a change here must keep +`config.json.sample` and every bundled `nse/` script landing in the wheel — +the `build` CI job asserts this. + Run the test suite with: ```bash diff --git a/README.md b/README.md index 37921d7..9623053 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,46 @@ This script is a wrapper for masscan and nmap. nmap handles host discovery and ( Python 3.8+ is required (`requires-python` in `pyproject.toml`; CI floors at 3.8). +## Installation + +SpooNMAP can be run straight from a checkout — no installation step needed, see +"Usage" below — or installed as a standalone command with +[uv](https://docs.astral.sh/uv/): + +```bash +uv tool install git+https://github.com/trustedsec/spoonmap +``` + +This puts a `spoonmap` executable on your `PATH`, so you can invoke it as +`spoonmap` from any directory instead of cloning the repo and running +`./spoonmap.py`. **There is no PyPI package** — the command above installs +directly from the git repository, not from `pypi.org`. `uv tool install +spoonmap` (naming the package instead of the git URL) will not work: the name +`spoonmap` is unclaimed on PyPI and this project has never published to it. +If you see that failure, it means you dropped the `git+...` URL, not that +something is broken. + +To update to the latest commit: + +```bash +uv tool upgrade spoonmap +``` + +Installing this way does not make SpooNMAP a self-contained scanner: +`masscan` and `nmap` are still separate system tools that must be installed +independently (see "Dependencies" above), exactly as when running from a +checkout — `uv tool install` only packages SpooNMAP's own Python code and its +bundled NSE scripts, not the external binaries it shells out to. + +The one thing worth understanding before installing this way: an installed +`spoonmap`'s Python module lives wherever `uv` put its managed tool +environment — not in a directory you would ever think to look in for a +config file or scan output. That is exactly the scenario the "Where Files +Live" section below is about — read that section for what resolves against +your current directory and why; installing via `uv tool install` doesn't +change the rule, it just makes the rule matter, since there is no checkout +directory left for a config or output path to fall back to by habit. + ## Usage Simply executing the script will prompt you for all required options. From 753f176389a6d92f9a26a082a8de1699eaa9f658 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 21 Aug 2026 18:13:15 -0400 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20round-3=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20restore=20hazard,=20fix=20stale=20comments/headings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _operator_dir() docstring: restore the concrete hazard the round-1 override stripped. The packaging decision has since landed on keeping the wheel, so the install-agnostic wording was no longer the right call — an installed spoonmap's module directory lives inside uv's managed tool environment, which `uv tool upgrade` rebuilds from scratch, destroying anything stored there. That's what actually makes CWD the only defensible choice. - pyproject.toml's force-include comment and _load_config's docstring both described dir_path as script-relative, which this branch made false; reworded to describe the operator directory and _DIR correctly (config.json.sample ships at _DIR because that's where the error messages point, not because config.json itself lives there). - CLAUDE.md: "Operator path resolution" was a level-3 heading nested under "Running the Tool" with nothing else at that level between it and "Architecture", so ~135 lines of CI/test documentation read as nested under a path-resolution subsection. Moved it to its own level-2 "Operator Path Resolution" section between "Running the Tool" and "Architecture". - README: name both nse/ (checkout) and spoonmap_nse/ (installed wheel) as the NSE directory forms, since the Installation section now points installed users at this same paragraph. Soften the PyPI guidance to not diagnose a future `uv tool install spoonmap` failure as user error, in case the name is ever claimed on PyPI. Add a one-line forward reference from Usage to "Where Files Live". - tests/test_spoonmap.py: TestOperatorDirResolution's CWD comparisons now compare os.path.realpath() on both sides instead of raw str(tmp_path), since the prior form only passed because pytest happens to hand out an already-resolved tmp_path (macOS's /tmp -> /private/tmp makes this fragile). Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 64 +++++++++++++++++++++--------------------- README.md | 30 ++++++++++++-------- pyproject.toml | 21 +++++++------- spoonmap.py | 17 +++++------ tests/test_spoonmap.py | 10 +++++-- 5 files changed, 77 insertions(+), 65 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a7a7ae8..ec07c11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,38 +16,6 @@ cp config.json.sample config.json ./spoonmap.py ``` -### Operator path resolution - -Every operator-facing path — `config.json`, `exclusions.txt`, the default -output location, relative `target_file`/`output_path`/`exclusions_file` -values read out of `config.json`, and `--cleanup`'s search for a config to -read `output_path` from — resolves against the current working directory the -command was run from, via `_operator_dir()` (a thin wrapper around -`os.getcwd()`, kept as its own module-level helper so it is testable outside -`main()`'s `# pragma: no cover` region). An operator's config and scan output -belong in the directory they ran the engagement from, not wherever the -program happens to be installed — those are frequently different places, and -this is true independent of how (or whether) the tool is installed. - -This is a behaviour change from resolving against `os.path.dirname(os.path.realpath(__file__))`: -invoking by absolute path from another directory (`cd /tmp && -/opt/spoonmap/spoonmap.py`) previously read `/opt/spoonmap/config.json` and -wrote output there; it now resolves against `/tmp`. The documented invocation -— running `./spoonmap.py` or `uv run spoonmap.py` from inside the checkout — -is unaffected, since CWD and checkout are the same directory there. - -This is a different anchor from `_DIR`/`_NSE_DIR` (`spoonmap.py:2459`), which -stay `__file__`-relative on purpose: the bundled NSE scripts under `nse/` are -program data that ships with SpooNMAP itself, not operator data, and must -resolve identically regardless of the caller's CWD. `_operator_dir()` and -`_DIR` are deliberately two separate anchors — do not collapse them into one. - -The wheel built by `pyproject.toml`'s hatch config is a supported consumption -path (`uv tool install git+https://github.com/trustedsec/spoonmap`; see -README.md for the end-user walkthrough), so a change here must keep -`config.json.sample` and every bundled `nse/` script landing in the wheel — -the `build` CI job asserts this. - Run the test suite with: ```bash @@ -152,6 +120,38 @@ commit's in-progress run and erase its green check. Pass `--cleanup [dir]` to remove prior scan output non-interactively (reads `output_path` from `config.json` if no directory is given). +## Operator Path Resolution + +Every operator-facing path — `config.json`, `exclusions.txt`, the default +output location, relative `target_file`/`output_path`/`exclusions_file` +values read out of `config.json`, and `--cleanup`'s search for a config to +read `output_path` from — resolves against the current working directory the +command was run from, via `_operator_dir()` (a thin wrapper around +`os.getcwd()`, kept as its own module-level helper so it is testable outside +`main()`'s `# pragma: no cover` region). An operator's config and scan output +belong in the directory they ran the engagement from, not wherever the +program happens to be installed — those are frequently different places, and +this is true independent of how (or whether) the tool is installed. + +This is a behaviour change from resolving against `os.path.dirname(os.path.realpath(__file__))`: +invoking by absolute path from another directory (`cd /tmp && +/opt/spoonmap/spoonmap.py`) previously read `/opt/spoonmap/config.json` and +wrote output there; it now resolves against `/tmp`. The documented invocation +— running `./spoonmap.py` or `uv run spoonmap.py` from inside the checkout — +is unaffected, since CWD and checkout are the same directory there. + +This is a different anchor from `_DIR`/`_NSE_DIR` (`spoonmap.py:2459`), which +stay `__file__`-relative on purpose: the bundled NSE scripts under `nse/` are +program data that ships with SpooNMAP itself, not operator data, and must +resolve identically regardless of the caller's CWD. `_operator_dir()` and +`_DIR` are deliberately two separate anchors — do not collapse them into one. + +The wheel built by `pyproject.toml`'s hatch config is a supported consumption +path (`uv tool install git+https://github.com/trustedsec/spoonmap`; see +README.md for the end-user walkthrough), so a change here must keep +`config.json.sample` and every bundled `nse/` script landing in the wheel — +the `build` CI job asserts this. + ## Architecture ### Host Discovery (Internal) diff --git a/README.md b/README.md index 9623053..b4dbb26 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,11 @@ uv tool install git+https://github.com/trustedsec/spoonmap This puts a `spoonmap` executable on your `PATH`, so you can invoke it as `spoonmap` from any directory instead of cloning the repo and running -`./spoonmap.py`. **There is no PyPI package** — the command above installs -directly from the git repository, not from `pypi.org`. `uv tool install -spoonmap` (naming the package instead of the git URL) will not work: the name -`spoonmap` is unclaimed on PyPI and this project has never published to it. -If you see that failure, it means you dropped the `git+...` URL, not that -something is broken. +`./spoonmap.py`. **There is no PyPI package** — this project has never +published to `pypi.org`, and the command above installs directly from the +git repository instead. Use the full `git+https://...` form above, not a +bare `uv tool install spoonmap`; whatever that name resolves to on PyPI, now +or in the future, is not this project. To update to the latest commit: @@ -50,6 +49,11 @@ directory left for a config or output path to fall back to by habit. ## Usage Simply executing the script will prompt you for all required options. +`config.json`, target/exclusion files, and scan output all resolve against +the directory you run the command from — see "Where Files Live" below if +your output isn't where you expect it, especially if you're used to invoking +SpooNMAP by path from outside its own directory. + If you use [uv](https://docs.astral.sh/uv/), you can run without a separate virtual environment: ```bash @@ -214,12 +218,14 @@ writes output under `/tmp/`. If you have a habit of invoking SpooNMAP from outside its own directory, check where your `config.json` and prior output actually are before your next run. -**What does *not* follow this rule:** the bundled NSE scripts under `nse/` -(`.nse` files invoked during `script_scan`) are program data, not operator -data — they ship with SpooNMAP itself and always resolve from the directory -containing `spoonmap.py`, regardless of your CWD. This is the one exception, -and it exists so the script scan works identically no matter where you -happen to run the tool from. +**What does *not* follow this rule:** the bundled NSE scripts (`.nse` files +invoked during `script_scan`) are program data, not operator data — they ship +with SpooNMAP itself and always resolve from the directory containing the +`spoonmap` module, regardless of your CWD. That directory holds a folder +named `nse/` in a checkout and `spoonmap_nse/` in a `uv tool install`; which +one you have on disk depends on how you got SpooNMAP, not on anything you +configure. This is the one exception, and it exists so the script scan works +identically no matter where you happen to run the tool from. ## Target File (ranges.txt) diff --git a/pyproject.toml b/pyproject.toml index 71beda6..6b5c427 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,17 +78,18 @@ only-include = ["spoonmap.py"] # `nse/` would be a name collision waiting to happen. # # config.json.sample also force-included, landing next to spoonmap.py at the -# wheel root (same place config.json itself is looked up from, via dir_path = -# os.path.dirname(os.path.realpath(__file__))): spoonmap.py's own error -# messages ("See config.json.sample for the expected keys") name that file by -# bare filename, and it shipped in the sdist already, so a wheel install that -# omitted it left the message pointing at something that doesn't exist. -# exclusions.txt deliberately stays out: its only reference +# wheel root — which is _DIR (os.path.dirname(os.path.realpath(__file__))), +# not the operator's own directory. spoonmap.py's error messages ("See +# {_DIR}/config.json.sample for the expected keys") point there deliberately, +# since that is the one place a sample file is guaranteed to exist regardless +# of the operator's CWD, and it shipped in the sdist already, so a wheel +# install that omitted it left those messages naming a path that doesn't +# exist. exclusions.txt deliberately stays out: its only reference # (`f'{dir_path}/exclusions.txt'`) is an interactive prompt's *suggested -# default path*, not a template any error message tells the operator to open, -# and the checked-in file is empty — shipping it adds no content and the -# prompt's default remains a fine suggestion whether or not a file exists -# there yet. +# default path* built from the operator directory, not a template any error +# message tells the operator to open, and the checked-in file is empty — +# shipping it adds no content and the prompt's default remains a fine +# suggestion whether or not a file exists there yet. [tool.hatch.build.targets.wheel.force-include] "nse" = "spoonmap_nse" "config.json.sample" = "config.json.sample" diff --git a/spoonmap.py b/spoonmap.py index 4dafb60..4fc3c1b 100755 --- a/spoonmap.py +++ b/spoonmap.py @@ -5365,7 +5365,7 @@ def _config_target_scan(value): def _load_config(config_parser, dir_path, resume=False): """Derive every scan setting from an already-parsed config.json dict. - *config_parser* is the JSON dict, *dir_path* the script directory that the + *config_parser* is the JSON dict, *dir_path* the operator directory that the three relative path settings resolve against, and *resume* whatever the --resume CLI flag already produced: the config's own 'resume' key is ORed into it so the flag can never be turned back off by the file. Returns the @@ -5434,7 +5434,7 @@ def _load_config(config_parser, dir_path, resume=False): resume = resume or _config_bool('resume', config_parser.get('resume'), False) config_generated = bool(config_parser.get(_CONFIG_GENERATED_KEY)) - # Resolve relative paths in config relative to the script directory + # Resolve relative paths in config relative to the operator directory if target_file and not os.path.isabs(target_file): target_file = os.path.join(dir_path, target_file) if output_path and not os.path.isabs(output_path): @@ -5466,12 +5466,13 @@ def _load_config(config_parser, dir_path, resume=False): def _operator_dir(): """Directory operator data (config.json, exclusions, output) resolves against. - Deliberately the CWD, not _DIR (the module's own location): an operator's - config and scan results belong in the directory they ran the engagement - from, not wherever the module happens to be installed — those are - frequently different directories, regardless of install mechanism. Pulled - out of main() — which is pragma-no-cover — so this derivation itself - stays under test. + Deliberately the CWD, not _DIR (the module's own location): an installed + `spoonmap`'s module directory lives inside uv's managed tool environment, + which `uv tool upgrade` rebuilds from scratch — anything stored there, + config or scan results, is destroyed on upgrade. That is the failure mode + that makes the CWD the only defensible choice, not merely a stylistic + preference. Pulled out of main() — which is pragma-no-cover — so this + derivation itself stays under test. """ return os.getcwd() diff --git a/tests/test_spoonmap.py b/tests/test_spoonmap.py index ba52566..6be4834 100644 --- a/tests/test_spoonmap.py +++ b/tests/test_spoonmap.py @@ -11167,8 +11167,12 @@ class TestOperatorDirResolution: _DIR/_NSE_DIR, which stay module-relative for bundled program data.""" def test_operator_dir_is_the_cwd(self, tmp_path, monkeypatch): + # Compare realpaths on both sides: os.getcwd() resolves symlinks in + # the path (e.g. macOS's /tmp -> /private/tmp), so comparing directly + # against str(tmp_path) would depend on pytest happening to hand out + # an already-resolved tmp_path rather than on the helper's behaviour. monkeypatch.chdir(tmp_path) - assert _operator_dir() == str(tmp_path) + assert _operator_dir() == os.path.realpath(str(tmp_path)) def test_operator_dir_follows_cwd_changes(self, tmp_path, monkeypatch): # A real behavioural assertion, not a restatement of os.getcwd(): @@ -11179,9 +11183,9 @@ def test_operator_dir_follows_cwd_changes(self, tmp_path, monkeypatch): first.mkdir() second.mkdir() monkeypatch.chdir(first) - assert _operator_dir() == str(first) + assert _operator_dir() == os.path.realpath(str(first)) monkeypatch.chdir(second) - assert _operator_dir() == str(second) + assert _operator_dir() == os.path.realpath(str(second)) def test_operator_dir_is_not_module_relative(self, tmp_path, monkeypatch): # Regression guard for PR #42: wherever the module is installed must From a80ddb41b0596588b461abd182c75f0167972214 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 21 Aug 2026 18:16:43 -0400 Subject: [PATCH 6/6] fix: tighten _operator_dir() hazard claim to survive a no-op upgrade "uv tool upgrade rebuilds from scratch" read as though any invocation destroys the module directory. Verified directly: `uv tool upgrade` with nothing to upgrade prints "Nothing to upgrade" and leaves planted sentinel files intact; only an invocation that actually installs a new version (equivalent to `uv tool install --force`) rebuilds the environment and loses them. Reworded to "rebuilt whenever uv tool upgrade actually installs a new version" so the comment doesn't die to someone testing a no-op upgrade and concluding the rationale is wrong. Co-Authored-By: Claude Fable 5 --- spoonmap.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/spoonmap.py b/spoonmap.py index 4fc3c1b..f30e475 100755 --- a/spoonmap.py +++ b/spoonmap.py @@ -5468,11 +5468,11 @@ def _operator_dir(): Deliberately the CWD, not _DIR (the module's own location): an installed `spoonmap`'s module directory lives inside uv's managed tool environment, - which `uv tool upgrade` rebuilds from scratch — anything stored there, - config or scan results, is destroyed on upgrade. That is the failure mode - that makes the CWD the only defensible choice, not merely a stylistic - preference. Pulled out of main() — which is pragma-no-cover — so this - derivation itself stays under test. + which is rebuilt whenever `uv tool upgrade` actually installs a new + version — anything stored there, config or scan results, is lost at that + point. That is the failure mode that makes the CWD the only defensible + choice, not merely a stylistic preference. Pulled out of main() — which + is pragma-no-cover — so this derivation itself stays under test. """ return os.getcwd()