From 6fb82cbddc0f6316648e05bbfc8c00748623ce39 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 21 Aug 2026 13:10:15 -0400 Subject: [PATCH] ci: add ruff lint and bandit SAST, and SHA-pin all actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopts the three patterns worth taking from trustedsec/hate_crack's CI. ruff: a new `lint` job runs `ruff check spoonmap.py tests/` against a deliberately narrow ruleset (E4, E7, E9, F) with ruff exact-pinned in the dev group, so a ruff release that grows an opinion cannot fail an unrelated PR. `ruff format` is not adopted: reformatting a 4.6k-line module and an 11k-line test file would bury every future diff, and E501 alone flags 288 existing lines. Its first run found 17 real issues, all fixed here rather than suppressed: - spoonmap.py: an ambiguous `l` loop variable and two f-strings with no placeholders - tests: 5 unused imports, 4 multiple-imports-on-one-line, a lambda bound to a name, another ambiguous `l`, and 3 dead locals Three of those dead locals were worth a second look rather than a blind --fix. Two adjacent tests each bound both `mock_m` and `mock_u` but asserted on only one, so the unused binding is dropped from each while the patch stays in place; the third discarded a mass_scan() return value the test never read. bandit: a new `bandit` job runs SAST against a committed baseline. A bare run reports 32 findings on spoonmap.py (12x B603 list-form subprocess, 12x B314 xml.etree on output from tools we invoked ourselves, plus B110/B112/B404/ B405/B607) — all reviewed and inherent to a scanner wrapper. Baselining them means only a NEW finding fails the build, which is the difference between a gate and a wall of noise. Verified both directions: clean against the baseline, and exit 1 with a fresh B307 when an eval() was temporarily added. Also verified the baseline is not line-number fragile, so unrelated edits that shift lines do not produce phantom findings. Actions are now pinned to commit SHAs with the tag in a trailing comment instead of mutable `@v4`/`@v6` tags, and every checkout sets `persist-credentials: false`. The pinned versions (checkout v7.0.1, setup-uv v10.0.1) were verified against the upstream tag refs, and being current also clears the Node 20 deprecation warning the old tags emitted. Verified locally: ruff clean; bandit clean against baseline; actionlint clean; uv lock --check clean; 971 passed / 5 skipped at 100% coverage on the locked 3.12 path and 99.91% on the unlocked 3.8 path. Co-Authored-By: Claude Fable 5 --- .bandit-baseline.json | 719 +++++++++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 62 +++- CLAUDE.md | 13 + README.md | 25 ++ pyproject.toml | 23 ++ spoonmap.py | 10 +- tests/test_spoonmap.py | 31 +- uv.lock | 27 ++ 8 files changed, 886 insertions(+), 24 deletions(-) create mode 100644 .bandit-baseline.json diff --git a/.bandit-baseline.json b/.bandit-baseline.json new file mode 100644 index 0000000..c621826 --- /dev/null +++ b/.bandit-baseline.json @@ -0,0 +1,719 @@ +{ + "errors": [], + "generated_at": "2026-08-21T17:07:47Z", + "metrics": { + "./spoonmap.py": { + "CONFIDENCE.HIGH": 32, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 20, + "SEVERITY.MEDIUM": 12, + "SEVERITY.UNDEFINED": 0, + "loc": 4730, + "nosec": 0, + "skipped_tests": 0 + }, + "_totals": { + "CONFIDENCE.HIGH": 32, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 20, + "SEVERITY.MEDIUM": 12, + "SEVERITY.UNDEFINED": 0, + "loc": 4730, + "nosec": 0, + "skipped_tests": 0 + } + }, + "results": [ + { + "code": "18 import socket\n19 import subprocess\n20 import sys\n", + "col_offset": 0, + "end_col_offset": 17, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Consider possible security implications associated with the subprocess module.", + "line_number": 19, + "line_range": [ + 19 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "code": "26 from queue import Queue\n27 import xml.etree.ElementTree as etree\n28 \n", + "col_offset": 0, + "end_col_offset": 37, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 20, + "link": "https://cwe.mitre.org/data/definitions/20.html" + }, + "issue_severity": "LOW", + "issue_text": "Using xml.etree.ElementTree to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree with the equivalent defusedxml package, or make sure defusedxml.defuse_stdlib() is called.", + "line_number": 27, + "line_range": [ + 27 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_imports.html#b405-import-xml-etree", + "test_id": "B405", + "test_name": "blacklist" + }, + { + "code": "81 try:\n82 subprocess.run(['stty', 'sane'], check=False, stderr=subprocess.DEVNULL)\n83 except (OSError, subprocess.SubprocessError):\n", + "col_offset": 8, + "end_col_offset": 80, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 82, + "line_range": [ + 82 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "81 try:\n82 subprocess.run(['stty', 'sane'], check=False, stderr=subprocess.DEVNULL)\n83 except (OSError, subprocess.SubprocessError):\n", + "col_offset": 8, + "end_col_offset": 80, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 82, + "line_range": [ + 82 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "709 try:\n710 root = etree.parse(xml_file)\n711 for host in root.findall('host'):\n", + "col_offset": 15, + "end_col_offset": 36, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 20, + "link": "https://cwe.mitre.org/data/definitions/20.html" + }, + "issue_severity": "MEDIUM", + "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", + "line_number": 710, + "line_range": [ + 710 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", + "test_id": "B314", + "test_name": "blacklist" + }, + { + "code": "731 try:\n732 root = etree.parse(xml_file)\n733 for host in root.findall('host'):\n", + "col_offset": 15, + "end_col_offset": 36, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 20, + "link": "https://cwe.mitre.org/data/definitions/20.html" + }, + "issue_severity": "MEDIUM", + "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", + "line_number": 732, + "line_range": [ + 732 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", + "test_id": "B314", + "test_name": "blacklist" + }, + { + "code": "813 try:\n814 proc = subprocess.Popen(masscan_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,\n815 preexec_fn=_raise_fd_limit)\n816 progress_thread = threading.Thread(target=_stream_masscan_progress, args=(proc,), daemon=True)\n", + "col_offset": 15, + "end_col_offset": 59, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 814, + "line_range": [ + 814, + 815 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "871 try:\n872 proc = subprocess.Popen(masscan_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,\n873 preexec_fn=_raise_fd_limit)\n874 progress_thread = threading.Thread(target=_stream_masscan_progress, args=(proc,), daemon=True)\n", + "col_offset": 15, + "end_col_offset": 59, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 872, + "line_range": [ + 872, + 873 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1032 try:\n1033 proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n1034 proc.wait()\n", + "col_offset": 15, + "end_col_offset": 90, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1033, + "line_range": [ + 1033 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1051 try:\n1052 root = etree.parse(output_xml)\n1053 for host in root.findall('host'):\n", + "col_offset": 15, + "end_col_offset": 38, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 20, + "link": "https://cwe.mitre.org/data/definitions/20.html" + }, + "issue_severity": "MEDIUM", + "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", + "line_number": 1052, + "line_range": [ + 1052 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", + "test_id": "B314", + "test_name": "blacklist" + }, + { + "code": "1102 try:\n1103 masscan_process = subprocess.Popen(\n1104 masscan_cmd,\n1105 stdout=subprocess.DEVNULL,\n1106 stderr=subprocess.PIPE,\n1107 preexec_fn=_raise_fd_limit,\n1108 )\n1109 progress_thread = threading.Thread(target=run_progress_and_capture, args=(masscan_process,), daemon=True)\n", + "col_offset": 26, + "end_col_offset": 9, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1103, + "line_range": [ + 1103, + 1104, + 1105, + 1106, + 1107, + 1108 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1156 try:\n1157 root = etree.parse(output_file)\n1158 for host in root.findall('host'):\n", + "col_offset": 15, + "end_col_offset": 39, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 20, + "link": "https://cwe.mitre.org/data/definitions/20.html" + }, + "issue_severity": "MEDIUM", + "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", + "line_number": 1157, + "line_range": [ + 1157 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", + "test_id": "B314", + "test_name": "blacklist" + }, + { + "code": "1316 try:\n1317 proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n1318 proc.wait()\n", + "col_offset": 15, + "end_col_offset": 90, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1317, + "line_range": [ + 1317 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1335 try:\n1336 root = etree.parse(output_file)\n1337 for host in root.findall('host'):\n", + "col_offset": 15, + "end_col_offset": 39, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 20, + "link": "https://cwe.mitre.org/data/definitions/20.html" + }, + "issue_severity": "MEDIUM", + "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", + "line_number": 1336, + "line_range": [ + 1336 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", + "test_id": "B314", + "test_name": "blacklist" + }, + { + "code": "1488 try:\n1489 proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,\n1490 stderr=subprocess.PIPE, text=True)\n1491 _t = threading.Thread(target=_progress_reader, args=(proc.stdout,), daemon=True)\n", + "col_offset": 15, + "end_col_offset": 66, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1489, + "line_range": [ + 1489, + 1490 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1523 try:\n1524 root = etree.parse(output_file)\n1525 for host in root.findall('host'):\n", + "col_offset": 15, + "end_col_offset": 39, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 20, + "link": "https://cwe.mitre.org/data/definitions/20.html" + }, + "issue_severity": "MEDIUM", + "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", + "line_number": 1524, + "line_range": [ + 1524 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", + "test_id": "B314", + "test_name": "blacklist" + }, + { + "code": "2226 # non-None and work_queue.join() in nmap_scan() hangs forever.\n2227 nmap_process = subprocess.Popen(\n2228 nmap_cmd,\n2229 stdout=subprocess.DEVNULL,\n2230 stderr=subprocess.PIPE,\n2231 text=True,\n2232 start_new_session=True,\n2233 )\n2234 nmap_err_thread, nmap_err_lines = _start_stderr_reader(nmap_process.stderr)\n", + "col_offset": 31, + "end_col_offset": 17, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 2227, + "line_range": [ + 2227, + 2228, + 2229, + 2230, + 2231, + 2232, + 2233 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "2286 )\n2287 nse_process = subprocess.Popen(\n2288 nse_cmd,\n2289 stdout=subprocess.DEVNULL,\n2290 stderr=subprocess.PIPE,\n2291 text=True,\n2292 start_new_session=True,\n2293 )\n2294 # Same concurrent drain as the banner pass: an NSE run\n", + "col_offset": 38, + "end_col_offset": 25, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 2287, + "line_range": [ + 2287, + 2288, + 2289, + 2290, + 2291, + 2292, + 2293 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "2785 try:\n2786 root = etree.parse(fpath)\n2787 for host in root.findall('host'):\n", + "col_offset": 19, + "end_col_offset": 37, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 20, + "link": "https://cwe.mitre.org/data/definitions/20.html" + }, + "issue_severity": "MEDIUM", + "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", + "line_number": 2786, + "line_range": [ + 2786 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", + "test_id": "B314", + "test_name": "blacklist" + }, + { + "code": "2823 try:\n2824 proc = subprocess.Popen([\n2825 'nmap', '-T4', '-sS', '-sV', '--version-intensity', '0',\n2826 '-Pn', '-p', port,\n2827 *(['--source-port', source_port] if source_port else []),\n2828 ip, '-oX', out_file\n2829 ])\n2830 proc.wait()\n", + "col_offset": 27, + "end_col_offset": 22, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 2824, + "line_range": [ + 2824, + 2825, + 2826, + 2827, + 2828, + 2829 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "2823 try:\n2824 proc = subprocess.Popen([\n2825 'nmap', '-T4', '-sS', '-sV', '--version-intensity', '0',\n2826 '-Pn', '-p', port,\n2827 *(['--source-port', source_port] if source_port else []),\n2828 ip, '-oX', out_file\n2829 ])\n2830 proc.wait()\n", + "col_offset": 27, + "end_col_offset": 22, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 2824, + "line_range": [ + 2824, + 2825, + 2826, + 2827, + 2828, + 2829 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "2846 try:\n2847 proc = subprocess.Popen([\n2848 'nmap', '-T4', '-sS', '-Pn', '-p', port,\n2849 '--script', f'{_DIR}/nse/azure-sql-detect.nse',\n2850 '--script-timeout', '30s',\n2851 *(['--source-port', source_port] if source_port else []),\n2852 ip, '-oX', nse_out_file\n2853 ])\n2854 proc.wait()\n", + "col_offset": 23, + "end_col_offset": 18, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 2847, + "line_range": [ + 2847, + 2848, + 2849, + 2850, + 2851, + 2852, + 2853 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "2846 try:\n2847 proc = subprocess.Popen([\n2848 'nmap', '-T4', '-sS', '-Pn', '-p', port,\n2849 '--script', f'{_DIR}/nse/azure-sql-detect.nse',\n2850 '--script-timeout', '30s',\n2851 *(['--source-port', source_port] if source_port else []),\n2852 ip, '-oX', nse_out_file\n2853 ])\n2854 proc.wait()\n", + "col_offset": 23, + "end_col_offset": 18, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 2847, + "line_range": [ + 2847, + 2848, + 2849, + 2850, + 2851, + 2852, + 2853 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "2868 try:\n2869 tree = etree.parse(xml_file)\n2870 except Exception:\n", + "col_offset": 19, + "end_col_offset": 40, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 20, + "link": "https://cwe.mitre.org/data/definitions/20.html" + }, + "issue_severity": "MEDIUM", + "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", + "line_number": 2869, + "line_range": [ + 2869 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", + "test_id": "B314", + "test_name": "blacklist" + }, + { + "code": "2869 tree = etree.parse(xml_file)\n2870 except Exception:\n2871 continue\n2872 for host_elem in tree.findall('.//host'):\n", + "col_offset": 8, + "end_col_offset": 20, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Continue detected.", + "line_number": 2870, + "line_range": [ + 2870, + 2871 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", + "test_id": "B112", + "test_name": "try_except_continue" + }, + { + "code": "2904 ]\n2905 result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)\n2906 if 'Valid credentials' in result.stdout:\n", + "col_offset": 29, + "end_col_offset": 92, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 2905, + "line_range": [ + 2905 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "3076 try:\n3077 root = etree.parse(f'{nmap_dir}/{fname}')\n3078 except etree.ParseError:\n", + "col_offset": 19, + "end_col_offset": 53, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 20, + "link": "https://cwe.mitre.org/data/definitions/20.html" + }, + "issue_severity": "MEDIUM", + "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", + "line_number": 3077, + "line_range": [ + 3077 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", + "test_id": "B314", + "test_name": "blacklist" + }, + { + "code": "3226 try:\n3227 root = etree.parse(fpath)\n3228 except Exception:\n", + "col_offset": 19, + "end_col_offset": 37, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 20, + "link": "https://cwe.mitre.org/data/definitions/20.html" + }, + "issue_severity": "MEDIUM", + "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", + "line_number": 3227, + "line_range": [ + 3227 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", + "test_id": "B314", + "test_name": "blacklist" + }, + { + "code": "3227 root = etree.parse(fpath)\n3228 except Exception:\n3229 continue\n3230 \n", + "col_offset": 8, + "end_col_offset": 20, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Continue detected.", + "line_number": 3228, + "line_range": [ + 3228, + 3229 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", + "test_id": "B112", + "test_name": "try_except_continue" + }, + { + "code": "4571 try:\n4572 return etree.parse(path)\n4573 except etree.ParseError as e:\n", + "col_offset": 15, + "end_col_offset": 32, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 20, + "link": "https://cwe.mitre.org/data/definitions/20.html" + }, + "issue_severity": "MEDIUM", + "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", + "line_number": 4572, + "line_range": [ + 4572 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", + "test_id": "B314", + "test_name": "blacklist" + }, + { + "code": "4876 readline.set_completer(None) # type: ignore[name-defined]\n4877 except Exception:\n4878 pass\n4879 \n", + "col_offset": 8, + "end_col_offset": 16, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 4877, + "line_range": [ + 4877, + 4878 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "4914 try:\n4915 tree = etree.parse(nmap_xml)\n4916 root_elem = tree.getroot()\n", + "col_offset": 19, + "end_col_offset": 40, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 20, + "link": "https://cwe.mitre.org/data/definitions/20.html" + }, + "issue_severity": "MEDIUM", + "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", + "line_number": 4915, + "line_range": [ + 4915 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", + "test_id": "B314", + "test_name": "blacklist" + } + ] +} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f78775..dc9f6de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,11 @@ jobs: - os: macos-latest python-version: '3.12' steps: - - uses: actions/checkout@v4 + # Actions are pinned to a commit SHA, not a tag: a tag is mutable, so + # `@v4` is an unaudited third party with write access to this build. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # tests/test_nse_integration.py shells out to real nmap against local stub # servers. Without nmap the whole module skips itself, so installing it is @@ -41,7 +45,7 @@ jobs: sudo apt-get install -y --no-install-recommends nmap - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: ${{ matrix.python-version }} enable-cache: true @@ -68,7 +72,11 @@ jobs: matrix: python-version: ['3.8', '3.9'] steps: - - uses: actions/checkout@v4 + # Actions are pinned to a commit SHA, not a tag: a tag is mutable, so + # `@v4` is an unaudited third party with write access to this build. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install nmap run: | @@ -76,7 +84,7 @@ jobs: sudo apt-get install -y --no-install-recommends nmap - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true @@ -85,3 +93,49 @@ jobs: uv run --isolated --no-project --python ${{ matrix.python-version }} --with pytest --with pytest-cov pytest tests/ -v + + # Separate job, not a step on `test`: a lint failure and a test failure are + # different questions, and neither should hide the other behind a red X. + # Ruleset and pinned ruff version live in pyproject.toml. + lint: + name: ruff + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + python-version: '3.12' + enable-cache: true + + - name: Ruff + run: uv run --frozen ruff check spoonmap.py tests/ + + # SAST against a committed baseline: spoonmap shells out to masscan/nmap and + # parses their XML, so a bare run reports 32 reviewed findings (subprocess + # list-form calls, xml.etree on tool output we invoked ourselves) every time. + # Baselining them means only a NEW finding fails the build. Regenerate with + # uvx --from 'bandit[toml]==1.9.4' bandit -r spoonmap.py -c pyproject.toml \ + # -f json -o .bandit-baseline.json + # and say in the commit message why each added finding is acceptable. + bandit: + name: bandit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + python-version: '3.12' + enable-cache: true + + - name: Bandit + run: > + uvx --from "bandit[toml]==1.9.4" bandit + -r spoonmap.py -c pyproject.toml -b .bandit-baseline.json diff --git a/CLAUDE.md b/CLAUDE.md index e3ab155..b96efef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,19 @@ for 3.8/3.9; `requires-python` stays `>=3.8` because `spoonmap.py` itself is dependency-free stdlib. The 95% coverage floor is enforced by pytest's `addopts`, so every job inherits it without restating it. +Two more jobs guard style and security: `lint` runs `ruff check spoonmap.py +tests/` against a narrow `E4`/`E7`/`E9`/`F` ruleset with ruff exact-pinned in the +`dev` group (a linter that grows an opinion should not fail an unrelated PR), and +`bandit` runs SAST against the committed `.bandit-baseline.json`. `ruff format` is +deliberately **not** adopted — reformatting the module and the 11k-line test file +would bury every future diff, and `E501` alone flags 288 existing lines. The +bandit baseline holds 32 reviewed findings (list-form `subprocess` calls, +`xml.etree` parsing of masscan/nmap output we invoked ourselves), so only a *new* +finding fails; regenerate it deliberately and justify additions in the commit +message rather than adding inline `# nosec` suppressions. Actions are pinned to +commit SHAs with the tag in a trailing comment, and every checkout sets +`persist-credentials: false`. + Pass `--cleanup [dir]` to remove prior scan output non-interactively (reads `output_path` from `config.json` if no directory is given). ## Architecture diff --git a/README.md b/README.md index e3232fa..829fb46 100644 --- a/README.md +++ b/README.md @@ -505,6 +505,31 @@ run: with `pyproject.toml`. - **`test-legacy`** — Python 3.8 and 3.9, resolving pytest fresh outside the project (`uv run --isolated --no-project`). +- **`lint`** — `ruff check spoonmap.py tests/`. Run it locally with + `uv run ruff check spoonmap.py tests/`. +- **`bandit`** — SAST against the committed `.bandit-baseline.json`, so only a + *new* finding fails the build. + +Actions are pinned to commit SHAs (with the tag in a trailing comment) rather +than mutable tags, and every checkout uses `persist-credentials: false`. + +The ruff ruleset is deliberately narrow — `E4`, `E7`, `E9`, `F` — and the ruff +version is exact-pinned in the `dev` group so a ruff release cannot fail CI on +its own. `ruff format` is **not** used: reformatting a 4.6k-line module and an +11k-line test file would bury every future diff, and `E501` alone would flag +288 existing lines. + +`bandit` reports 32 reviewed findings on `spoonmap.py` — list-form `subprocess` +calls and `xml.etree` parsing of output from tools we invoked ourselves — which +is why they are baselined instead of suppressed inline. To re-baseline after an +intentional change: + +```bash +uvx --from 'bandit[toml]==1.9.4' bandit -r spoonmap.py -c pyproject.toml \ + -f json -o .bandit-baseline.json +``` + +Say in the commit message why each newly added finding is acceptable. The split exists because `pytest` only ships the fix for CVE-2025-71176 in 9.0.3+, which requires Python 3.10+. `uv.lock` therefore resolves for 3.10+ diff --git a/pyproject.toml b/pyproject.toml index a267378..d2409e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,9 @@ spoonmap = "spoonmap:main" dev = [ "pytest>=9.0.3", "pytest-cov>=5.0.0", + # Exact-pinned: a ruff bump can add rules, and CI failing because the linter + # grew an opinion is noise, not a signal. Bump deliberately. + "ruff==0.16.4", ] [tool.uv] @@ -27,6 +30,26 @@ dev = [ # and CI tests 3.8/3.9 without the lock. See .github/workflows/ci.yml. environments = ["python_version >= '3.10'"] +[tool.ruff] +# Match the oldest interpreter the tool claims, so ruff never suggests syntax +# that would break on the jumpbox this gets run from. +target-version = "py38" + +[tool.ruff.lint] +# Deliberately narrow: pyflakes (F) catches undefined names, unused imports and +# dead locals — real bugs — while E4/E7/E9 cover import placement, statement +# style and syntax errors. Formatting rules (E1/E5/W) are excluded on purpose: +# `ruff format` is not adopted here, because reformatting an 11k-line test file +# and a 4.6k-line module would bury every future diff under churn, and E501 +# alone would flag 288 existing lines. +select = ["E4", "E7", "E9", "F"] + +[tool.bandit] +# The scan targets spoonmap.py only. tests/ is full of deliberately hostile +# fixtures (fake XML, patched subprocess) that a SAST tool has nothing useful +# to say about. +exclude_dirs = ["tests", ".venv"] + [tool.pytest.ini_options] testpaths = ["tests"] addopts = "--cov=spoonmap --cov-report=term-missing --cov-fail-under=95" diff --git a/spoonmap.py b/spoonmap.py index 2fae327..0fe769a 100755 --- a/spoonmap.py +++ b/spoonmap.py @@ -4437,9 +4437,9 @@ def _write_findings_txt(output_path, target_scan, findings): lines.append(f' {cmd}') for extra in repro.get('extra_cmds', []): base_dn = next( - (l[len('Base DN:'):].strip() - for l in grp['detail'].splitlines() - if l.startswith('Base DN:')), + (line[len('Base DN:'):].strip() + for line in grp['detail'].splitlines() + if line.startswith('Base DN:')), '', ) lines.append(f' {extra.format(host=hosts[0], base_dn=base_dn, port=pnum)}') @@ -5572,7 +5572,7 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates full_n = len(category_names) + 1 print(f'\t({full_n}) Full Port Scan [1-65535, TCP only — no UDP]') print(f'\t(a) All Categories [{", ".join(category_names)}]') - print(f'\t(c) Custom Port Scan [enter your own comma-separated ports]') + print('\t(c) Custom Port Scan [enter your own comma-separated ports]') print(_COLOR_INFO + '\nNote: Full Port Scan covers TCP 1-65535 only. It runs NO UDP ' 'discovery, so the U: ports in the categories above (SNMP U:161, ' @@ -5894,7 +5894,7 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates if effective_host_count > 0: print(_COLOR_INFO + f'Work units ({work_units:,}) > threshold ({nmap_threshold:,}): ' - + f'using masscan for port discovery' + + 'using masscan for port discovery' + _COLOR_RESET) status_summary = mass_scan( scan_type, dest_ports, source_port, max_rate, diff --git a/tests/test_spoonmap.py b/tests/test_spoonmap.py index 2a3f325..e078ead 100644 --- a/tests/test_spoonmap.py +++ b/tests/test_spoonmap.py @@ -21,18 +21,14 @@ DISCOVERY_MASSCAN_PORTS_INTERNAL, DISCOVERY_TCP_PORTS_INTERNAL, EXTERNAL_PORT_SCRIPTS, - EXTERNAL_PROBE_PORT_PRIORITY, EXTERNAL_SENSITIVE_PORTS, HONEYPOT_MIN_PORTS_SCANNED, - HONEYPOT_MIN_UNMATCHED_PORTS, HONEYPOT_OPEN_PORT_FRACTION, HOST_DISCOVERY_NMAP_THRESHOLD, INTERNAL_DISCOVERY_MAX_RATE, INTERNAL_DISCOVERY_STATE_CEILING, SLOW_PORTS, - SQL_SERVER_EOL, INTERNAL_PORT_SCRIPTS, - PROBE_PORT_PRIORITY, _build_discovery_target_file, _build_interactive_config, _build_nmap_cmd, @@ -101,7 +97,6 @@ _scan_extra_sql_ports, _validate_snmp_any_community, _SMB_COUPLED_PORTS, - _SMB_PORTS, SERVICE_CATEGORIES, _calc_scan_wait, _cleanup_cmd, @@ -4930,7 +4925,8 @@ def test_completed_batch_skipped_when_resume_true(self, tmp_path): targets_file.parent.mkdir(parents=True, exist_ok=True) targets_file.write_text('10.0.0.1\n') # Make batch XML newer than targets file - import os, time as _time + import os + import time as _time os.utime(str(targets_file), (0, 0)) os.utime(str(batch_xml), (_time.time(), _time.time())) @@ -4982,7 +4978,8 @@ def test_live_hosts_loaded_from_file_when_batch_skipped(self, tmp_path): targets_file = tmp_path / 'discovery' / 'resolved_targets.txt' targets_file.parent.mkdir(parents=True, exist_ok=True) targets_file.write_text('10.0.0.1\n') - import os, time as _time + import os + import time as _time os.utime(str(targets_file), (0, 0)) os.utime(str(batch_xml), (_time.time(), _time.time())) @@ -5005,7 +5002,8 @@ def test_partial_resume_only_skips_completed_batches(self, tmp_path): targets_file = tmp_path / 'discovery' / 'resolved_targets.txt' targets_file.parent.mkdir(parents=True, exist_ok=True) targets_file.write_text('10.0.0.1\n') - import os, time as _time + import os + import time as _time os.utime(str(targets_file), (0, 0)) os.utime(str(batch0_xml), (_time.time(), _time.time())) @@ -5037,7 +5035,8 @@ def test_batch_not_skipped_when_targets_file_is_newer(self, tmp_path): targets_file.parent.mkdir(parents=True, exist_ok=True) targets_file.write_text('10.0.0.1\n') - import os, time as _time + import os + import time as _time # Make batch XML *older* than targets (simulates ranges.txt change) os.utime(str(batch_xml), (0, 0)) os.utime(str(targets_file), (_time.time(), _time.time())) @@ -6319,7 +6318,8 @@ def _run(self, tmp_path, ip_to_hostname=None, script_scan=False, interrupt_event = threading.Event() if popen_side_effect is None: - popen_side_effect = lambda *a, **k: self._make_finished_proc() + def popen_side_effect(*a, **k): + return self._make_finished_proc() with patch('spoonmap._build_nmap_cmd', return_value=['nmap', 'fake']) as mock_build, \ patch('spoonmap._get_scripts_for_port', return_value=scripts_for_port), \ @@ -8673,12 +8673,13 @@ def test_hosts_on_139_propagate_to_445(self, tmp_path): } with patch('spoonmap._run_masscan_batch', side_effect=self._make_batch_side_effect(responses)): - result = mass_scan('All', ['139', '445'], '88', '1000', - '/fake/targets.txt', '', batch_size=5) + mass_scan('All', ['139', '445'], '88', '1000', + '/fake/targets.txt', '', batch_size=5) port445_file = tmp_path / 'discovery' / 'live_hosts' / 'port445.txt' if port445_file.exists(): - written = {l.strip() for l in port445_file.read_text().splitlines() if l.strip()} + written = {line.strip() for line in port445_file.read_text().splitlines() + if line.strip()} assert written == {'10.0.0.1', '10.0.0.2', '10.0.0.3'} def test_smb_coupled_ports_constant(self): @@ -9477,7 +9478,7 @@ def test_udp_ports_not_passed_to_masscan(self, tmp_path): """dest_ports with U:500 → masscan never called with U:500.""" spoonmap.output_path = str(tmp_path) with patch('spoonmap._run_masscan_batch', return_value={}) as mock_m, \ - patch('spoonmap._nmap_udp_discovery', return_value=set()) as mock_u: + patch('spoonmap._nmap_udp_discovery', return_value=set()): mass_scan('All', ['443', 'U:500'], '53', '10000', '/fake/targets.txt', '', batch_size=1) for call in mock_m.call_args_list: @@ -9487,7 +9488,7 @@ def test_udp_ports_not_passed_to_masscan(self, tmp_path): def test_udp_ports_trigger_nmap_udp_discovery(self, tmp_path): """dest_ports with U:500 → _nmap_udp_discovery called with 'U:500'.""" spoonmap.output_path = str(tmp_path) - with patch('spoonmap._run_masscan_batch', return_value={}) as mock_m, \ + with patch('spoonmap._run_masscan_batch', return_value={}), \ patch('spoonmap._nmap_udp_discovery', return_value=set()) as mock_u: mass_scan('All', ['443', 'U:500'], '53', '10000', '/fake/targets.txt', '', batch_size=1) diff --git a/uv.lock b/uv.lock index e32a61f..c9d2104 100644 --- a/uv.lock +++ b/uv.lock @@ -200,6 +200,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "ruff" +version = "0.16.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" }, + { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" }, + { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" }, + { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" }, + { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" }, + { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" }, + { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, +] + [[package]] name = "spoonmap" version = "0.1.0" @@ -209,6 +234,7 @@ source = { editable = "." } dev = [ { name = "pytest", marker = "python_full_version >= '3.10'" }, { name = "pytest-cov", marker = "python_full_version >= '3.10'" }, + { name = "ruff", marker = "python_full_version >= '3.10'" }, ] [package.metadata] @@ -217,6 +243,7 @@ dev = [ dev = [ { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-cov", specifier = ">=5.0.0" }, + { name = "ruff", specifier = "==0.16.4" }, ] [[package]]