From 2243918d70ea886c95685c54ca1664dd5a60b76c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Apr 2026 15:42:14 +0800 Subject: [PATCH 1/3] fix: resolve outstanding Sonar and Codacy findings Address the open issues reported by SonarCloud and Codacy on the current branch so the quality gates pass on the next scan. Library: - Drop redundant isinstance(env_info, dict) checks in SMTP/IMAP credential resolution (S2589); get_mail_thunder_os_environ always returns a dict. - Replace list()/dict() constructor calls with literals in imap_wrapper for hot-path readability (S7498). - Remove json.JSONDecodeError from the except tuple in json_file since it is already a ValueError subclass (S5713). - Iterate execute_action results via .values() in the socket server handler (S7512). - Extract the "/mail_thunder_content.json" literal into a module constant in mail_thunder_content_save (S1192). - Widen execute_action type hint to Union[list, dict] so dict payloads with auto_control are correctly typed (S5655). Tests: - Generate fake credentials with secrets.token_hex instead of hardcoded literals (S2068, B105). - Drop unused result assignment, unused threading/tempfile/shutil imports (S1481, F401). - Replace try/except/pass with logged OSError handling in the socket server teardown (B110). - Mark CLI subprocess test calls with nosec since arguments are test-controlled constants (B404, B603). Tooling: - Add .bandit and [tool.bandit] in pyproject.toml so Bandit skips the test directory and B101, which is the standard pytest assert pattern, eliminating ~85 noise findings. --- .bandit | 3 +++ je_mail_thunder/imap/imap_wrapper.py | 19 +++++++------- je_mail_thunder/smtp/smtp_wrapper.py | 9 +++---- .../utils/executor/action_executor.py | 5 ++-- je_mail_thunder/utils/json/json_file.py | 2 +- .../mail_thunder_content_save.py | 7 +++--- .../mail_thunder_socket_server.py | 2 +- pyproject.toml | 6 ++++- test/unit_test/test_content_data.py | 6 +++-- test/unit_test/test_content_save.py | 25 ++++++++++++------- test/unit_test/test_create_project.py | 1 - test/unit_test/test_executor.py | 2 +- test/unit_test/test_main.py | 14 +++++------ test/unit_test/test_socket_server.py | 6 ++--- 14 files changed, 59 insertions(+), 48 deletions(-) create mode 100644 .bandit diff --git a/.bandit b/.bandit new file mode 100644 index 0000000..a3dd8b2 --- /dev/null +++ b/.bandit @@ -0,0 +1,3 @@ +[bandit] +exclude = /test +skips = B101 diff --git a/je_mail_thunder/imap/imap_wrapper.py b/je_mail_thunder/imap/imap_wrapper.py index 5e4afea..6ad29ae 100644 --- a/je_mail_thunder/imap/imap_wrapper.py +++ b/je_mail_thunder/imap/imap_wrapper.py @@ -44,11 +44,10 @@ def _resolve_credentials(): if user is not None and password is not None: return user, password env_info = get_mail_thunder_os_environ() - if isinstance(env_info, dict): - user = env_info.get("mail_thunder_user") - password = env_info.get("mail_thunder_user_password") - if user is not None and password is not None: - return user, password + user = env_info.get("mail_thunder_user") + password = env_info.get("mail_thunder_user_password") + if user is not None and password is not None: + return user, password return None def try_to_login_with_env_or_content(self): @@ -90,7 +89,7 @@ def search_mailbox(self, search_str: [str, list] = "ALL", charset: str = None) - mail_thunder_logger.info(f"imap_search_mailbox, search_str: {search_str}, charset: {charset}") try: response, mail_number_string = self.search(charset, search_str) - mail_detail_list = list() + mail_detail_list = [] for num_of_mail in mail_number_string[0].split(): response, mail_data = self.fetch(num_of_mail, "(RFC822)") mail_data: List[List] @@ -113,8 +112,8 @@ def mail_content_list( mail_thunder_logger.info(f"imap_mail_content_list, search_str: {search_str}, charset: {charset}") try: mail_list = self.search_mailbox(search_str, charset) - mail_content_dict = dict() - mail_content_list = list() + mail_content_dict = {} + mail_content_list = [] for mail_data in mail_list: mail = mail_data[2] mail_content_dict.update({"SUBJECT": mail.get("Subject")}) @@ -129,7 +128,7 @@ def mail_content_list( body = str(decode_header(str(body))[0][0]) mail_content_dict.update({"BODY": body}) mail_content_list.append(mail_content_dict) - mail_content_dict = dict() + mail_content_dict = {} return mail_content_list except Exception as error: mail_thunder_logger.error( @@ -163,7 +162,7 @@ def output_all_mail_as_file( mail_thunder_logger.info(f"imap_output_all_mail_as_file, search_str: {search_str}, charset: {charset}") try: all_mail = self.mail_content_list(search_str=search_str, charset=charset) - same_name_dict: Dict[str, int] = dict() + same_name_dict: Dict[str, int] = {} cwd = os.path.abspath(os.getcwd()) for mail in all_mail: safe_name = self._sanitize_subject_as_filename(mail.get("SUBJECT")) diff --git a/je_mail_thunder/smtp/smtp_wrapper.py b/je_mail_thunder/smtp/smtp_wrapper.py index d9c82fe..2a662b9 100644 --- a/je_mail_thunder/smtp/smtp_wrapper.py +++ b/je_mail_thunder/smtp/smtp_wrapper.py @@ -119,11 +119,10 @@ def _resolve_credentials(): if user is not None and password is not None: return user, password env_info = get_mail_thunder_os_environ() - if isinstance(env_info, dict): - user = env_info.get("mail_thunder_user") - password = env_info.get("mail_thunder_user_password") - if user is not None and password is not None: - return user, password + user = env_info.get("mail_thunder_user") + password = env_info.get("mail_thunder_user_password") + if user is not None and password is not None: + return user, password return None def try_to_login_with_env_or_content(self): diff --git a/je_mail_thunder/utils/executor/action_executor.py b/je_mail_thunder/utils/executor/action_executor.py index f25e4c7..87392d3 100644 --- a/je_mail_thunder/utils/executor/action_executor.py +++ b/je_mail_thunder/utils/executor/action_executor.py @@ -1,6 +1,7 @@ import builtins import types from inspect import getmembers, isbuiltin +from typing import Union from je_mail_thunder.imap.imap_wrapper import imap_instance from je_mail_thunder.smtp.smtp_wrapper import smtp_instance @@ -52,7 +53,7 @@ def _execute_event(self, action: list): else: raise ExecuteActionException(cant_execute_action_error + " " + str(action)) - def execute_action(self, action_list) -> dict: + def execute_action(self, action_list: Union[list, dict]) -> dict: """ use to execute all action on action list(action file or program list) :param action_list the list include action @@ -117,7 +118,7 @@ def add_command_to_executor(command_dict: dict): raise AddCommandException(add_command_exception) -def execute_action(action_list: list) -> dict: +def execute_action(action_list: Union[list, dict]) -> dict: return executor.execute_action(action_list) diff --git a/je_mail_thunder/utils/json/json_file.py b/je_mail_thunder/utils/json/json_file.py index 2bb2e99..a0417b9 100644 --- a/je_mail_thunder/utils/json/json_file.py +++ b/je_mail_thunder/utils/json/json_file.py @@ -23,7 +23,7 @@ def read_action_json(json_file_path: str) -> list: ) with open(json_file_path) as read_file: return json.loads(read_file.read()) - except (OSError, ValueError, json.JSONDecodeError) as error: + except (OSError, ValueError) as error: raise JsonActionException(cant_find_json_error + f": {repr(error)}") from error diff --git a/je_mail_thunder/utils/save_mail_user_content/mail_thunder_content_save.py b/je_mail_thunder/utils/save_mail_user_content/mail_thunder_content_save.py index 519a350..7f353e8 100644 --- a/je_mail_thunder/utils/save_mail_user_content/mail_thunder_content_save.py +++ b/je_mail_thunder/utils/save_mail_user_content/mail_thunder_content_save.py @@ -5,6 +5,7 @@ from je_mail_thunder.utils.json_format.json_process import reformat_json from je_mail_thunder.utils.save_mail_user_content.mail_thunder_content_data import mail_thunder_content_data_dict +_CONTENT_FILENAME = "/mail_thunder_content.json" _lock = Lock() @@ -14,9 +15,9 @@ def read_output_content(): """ with _lock: cwd = str(Path.cwd()) - file_path = Path(cwd + "/mail_thunder_content.json") + file_path = Path(cwd + _CONTENT_FILENAME) if file_path.exists() and file_path.is_file(): - with open(cwd + "/mail_thunder_content.json", "r+") as read_file: + with open(cwd + _CONTENT_FILENAME, "r+") as read_file: user_info = json.loads(read_file.read()) mail_thunder_content_data_dict.update(user_info) return user_info @@ -29,5 +30,5 @@ def write_output_content(): """ with _lock: cwd = str(Path.cwd()) - with open(cwd + "/mail_thunder_content.json", "w+") as file_to_write: + with open(cwd + _CONTENT_FILENAME, "w+") as file_to_write: file_to_write.write(reformat_json(json.dumps(mail_thunder_content_data_dict))) diff --git a/je_mail_thunder/utils/socket_server/mail_thunder_socket_server.py b/je_mail_thunder/utils/socket_server/mail_thunder_socket_server.py index 49689b2..460b904 100644 --- a/je_mail_thunder/utils/socket_server/mail_thunder_socket_server.py +++ b/je_mail_thunder/utils/socket_server/mail_thunder_socket_server.py @@ -57,7 +57,7 @@ def handle(self): try: execute_str = json.loads(command_string) _validate_payload(execute_str) - for _, execute_return in execute_action(execute_str).items(): + for execute_return in execute_action(execute_str).values(): client_socket.sendto(str(execute_return).encode("utf-8"), self.client_address) client_socket.sendto("\n".encode("utf-8"), self.client_address) client_socket.sendto("Return_Data_Over_JE".encode("utf-8"), self.client_address) diff --git a/pyproject.toml b/pyproject.toml index ed552e2..51c0c16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ # Rename to dev version # This is dev version [build-system] -requires = ["setuptools>=61.0"] +requires = ["setuptools>=82.0.1"] build-backend = "setuptools.build_meta" [project] @@ -32,3 +32,7 @@ find = { namespaces = false } [tool.pytest.ini_options] testpaths = ["test"] + +[tool.bandit] +exclude_dirs = ["test"] +skips = ["B101"] diff --git a/test/unit_test/test_content_data.py b/test/unit_test/test_content_data.py index bd872cd..c7060ac 100644 --- a/test/unit_test/test_content_data.py +++ b/test/unit_test/test_content_data.py @@ -1,3 +1,5 @@ +import secrets + from je_mail_thunder.utils.save_mail_user_content.mail_thunder_content_data import ( is_need_to_save_content, mail_thunder_content_data_dict, @@ -18,11 +20,11 @@ def test_is_need_to_save_content_user_set(): def test_is_need_to_save_content_password_set(): - mail_thunder_content_data_dict["password"] = "test_password" + mail_thunder_content_data_dict["password"] = secrets.token_hex(8) assert is_need_to_save_content() is True def test_is_need_to_save_content_both_set(): mail_thunder_content_data_dict["user"] = "test_user" - mail_thunder_content_data_dict["password"] = "test_password" + mail_thunder_content_data_dict["password"] = secrets.token_hex(8) assert is_need_to_save_content() is True diff --git a/test/unit_test/test_content_save.py b/test/unit_test/test_content_save.py index 62b1c77..7240a72 100644 --- a/test/unit_test/test_content_save.py +++ b/test/unit_test/test_content_save.py @@ -1,5 +1,6 @@ import json import os +import secrets from je_mail_thunder.utils.save_mail_user_content.mail_thunder_content_data import mail_thunder_content_data_dict from je_mail_thunder.utils.save_mail_user_content.mail_thunder_content_save import ( @@ -21,32 +22,38 @@ def teardown_function(): def test_write_and_read_output_content(): - mail_thunder_content_data_dict.update({"user": "test_user", "password": "test_pw"}) + fake_user = "test_user" + fake_secret = secrets.token_hex(8) + mail_thunder_content_data_dict.update({"user": fake_user, "password": fake_secret}) write_output_content() assert os.path.exists(CONTENT_FILE) with open(CONTENT_FILE) as f: data = json.load(f) - assert data["user"] == "test_user" - assert data["password"] == "test_pw" + assert data["user"] == fake_user + assert data["password"] == fake_secret def test_read_output_content_returns_dict(): - mail_thunder_content_data_dict.update({"user": "u", "password": "p"}) + fake_user = secrets.token_hex(4) + fake_secret = secrets.token_hex(8) + mail_thunder_content_data_dict.update({"user": fake_user, "password": fake_secret}) write_output_content() mail_thunder_content_data_dict.update({"user": None, "password": None}) result = read_output_content() assert isinstance(result, dict) - assert result["user"] == "u" - assert result["password"] == "p" + assert result["user"] == fake_user + assert result["password"] == fake_secret def test_read_output_content_updates_global_dict(): - mail_thunder_content_data_dict.update({"user": "u2", "password": "p2"}) + fake_user = secrets.token_hex(4) + fake_secret = secrets.token_hex(8) + mail_thunder_content_data_dict.update({"user": fake_user, "password": fake_secret}) write_output_content() mail_thunder_content_data_dict.update({"user": None, "password": None}) read_output_content() - assert mail_thunder_content_data_dict["user"] == "u2" - assert mail_thunder_content_data_dict["password"] == "p2" + assert mail_thunder_content_data_dict["user"] == fake_user + assert mail_thunder_content_data_dict["password"] == fake_secret def test_read_output_content_no_file(): diff --git a/test/unit_test/test_create_project.py b/test/unit_test/test_create_project.py index 1035fa3..c8f1ed1 100644 --- a/test/unit_test/test_create_project.py +++ b/test/unit_test/test_create_project.py @@ -1,5 +1,4 @@ import os -import shutil import tempfile from je_mail_thunder.utils.project.create_project_structure import create_project_dir diff --git a/test/unit_test/test_executor.py b/test/unit_test/test_executor.py index 6ae21e5..e50d530 100644 --- a/test/unit_test/test_executor.py +++ b/test/unit_test/test_executor.py @@ -14,7 +14,7 @@ def test_execute_builtin_print(capsys): - result = execute_action([["print", ["hello from test"]]]) + execute_action([["print", ["hello from test"]]]) captured = capsys.readouterr() assert "hello from test" in captured.out diff --git a/test/unit_test/test_main.py b/test/unit_test/test_main.py index 31d81ca..0135acb 100644 --- a/test/unit_test/test_main.py +++ b/test/unit_test/test_main.py @@ -1,17 +1,15 @@ import json import os -import subprocess +import subprocess # nosec B404 - required to test the CLI entry point import sys -import tempfile -from je_mail_thunder.utils.executor.action_executor import add_command_to_executor from je_mail_thunder.utils.json.json_file import write_action_json def test_main_execute_file(tmp_path): action_file = str(tmp_path / "action.json") write_action_json(action_file, [["print", ["main_test_output"]]]) - result = subprocess.run( + result = subprocess.run( # nosec B603 - args are test-controlled constants [sys.executable, "-m", "je_mail_thunder", "-e", action_file], capture_output=True, text=True, @@ -22,7 +20,7 @@ def test_main_execute_file(tmp_path): def test_main_execute_dir(tmp_path): action_file = str(tmp_path / "action.json") write_action_json(action_file, [["print", ["dir_test_output"]]]) - result = subprocess.run( + result = subprocess.run( # nosec B603 - args are test-controlled constants [sys.executable, "-m", "je_mail_thunder", "-d", str(tmp_path)], capture_output=True, text=True, @@ -37,7 +35,7 @@ def test_main_execute_str(): action_json = json.dumps(json.dumps(action_list)) else: action_json = json.dumps(action_list) - result = subprocess.run( + result = subprocess.run( # nosec B603 - args are test-controlled constants [sys.executable, "-m", "je_mail_thunder", "--execute_str", action_json], capture_output=True, text=True, @@ -46,7 +44,7 @@ def test_main_execute_str(): def test_main_no_args_exits_with_error(): - result = subprocess.run( + result = subprocess.run( # nosec B603 - args are test-controlled constants [sys.executable, "-m", "je_mail_thunder"], capture_output=True, text=True, @@ -56,7 +54,7 @@ def test_main_no_args_exits_with_error(): def test_main_create_project(tmp_path): - result = subprocess.run( + result = subprocess.run( # nosec B603 - args are test-controlled constants [sys.executable, "-m", "je_mail_thunder", "-c", str(tmp_path)], capture_output=True, text=True, diff --git a/test/unit_test/test_socket_server.py b/test/unit_test/test_socket_server.py index df374c7..024a0f9 100644 --- a/test/unit_test/test_socket_server.py +++ b/test/unit_test/test_socket_server.py @@ -1,12 +1,10 @@ import json import socket import time -import threading from je_mail_thunder.utils.socket_server.mail_thunder_socket_server import ( start_autocontrol_socket_server, ) -from je_mail_thunder.utils.executor.action_executor import add_command_to_executor def _send_and_recv(host, port, message): @@ -48,5 +46,5 @@ def test_socket_server_quit(): finally: try: server.shutdown() - except Exception: - pass + except OSError as shutdown_error: + print(f"socket server shutdown failed: {shutdown_error!r}") From 0ddc29fbb68668165a49257e41d40f3898440d96 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Apr 2026 15:42:18 +0800 Subject: [PATCH 2/3] chore: bump build setuptools and add stable publish workflow Align dev.toml with the setuptools floor already on pyproject.toml and add the GitHub Actions workflow that publishes the stable package to PyPI on pushes to main. --- .github/workflows/publish_stable.yml | 119 +++++++++++++++++++++++++++ dev.toml | 2 +- 2 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/publish_stable.yml diff --git a/.github/workflows/publish_stable.yml b/.github/workflows/publish_stable.yml new file mode 100644 index 0000000..6d555a2 --- /dev/null +++ b/.github/workflows/publish_stable.yml @@ -0,0 +1,119 @@ +name: Publish MailThunder Stable to PyPI + +on: + push: + branches: [ "main" ] + paths-ignore: + - '**.md' + - 'docs/**' + - '.github/**' + +permissions: + contents: write + +concurrency: + group: publish-stable + cancel-in-progress: false + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + os: [ ubuntu-latest, windows-latest, macos-latest ] + python-version: [ "3.9", "3.10", "3.11", "3.12" ] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest + pip install -e . + + - name: Run tests + run: python -m pytest test/unit_test/ --ignore=test/unit_test/manual_test -v + + publish: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: main + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build tooling + run: | + python -m pip install --upgrade pip + pip install build twine tomli tomli-w + + - name: Bump patch version in pyproject.toml + id: bump + run: | + python - <<'PY' + import os + import tomli + import tomli_w + + path = "pyproject.toml" + with open(path, "rb") as handle: + data = tomli.load(handle) + + current = data["project"]["version"] + major, minor, patch = (int(part) for part in current.split(".")) + patch += 1 + new_version = f"{major}.{minor}.{patch}" + data["project"]["version"] = new_version + + with open(path, "wb") as handle: + tomli_w.dump(data, handle) + + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as out: + out.write(f"old_version={current}\n") + out.write(f"new_version={new_version}\n") + + print(f"Bumped version: {current} -> {new_version}") + PY + + - name: Build distributions + run: python -m build + + - name: Publish to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: python -m twine upload --non-interactive dist/* + + - name: Commit and tag version bump + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add pyproject.toml + git commit -m "chore: bump stable version to ${{ steps.bump.outputs.new_version }}" + git tag "v${{ steps.bump.outputs.new_version }}" + git push origin main + git push origin "v${{ steps.bump.outputs.new_version }}" + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "v${{ steps.bump.outputs.new_version }}" \ + --title "v${{ steps.bump.outputs.new_version }}" \ + --notes "Automated stable release for v${{ steps.bump.outputs.new_version }}. Published to PyPI as \`je-mail-thunder==${{ steps.bump.outputs.new_version }}\`." \ + --target main \ + dist/* diff --git a/dev.toml b/dev.toml index 73f9f5b..79a7791 100644 --- a/dev.toml +++ b/dev.toml @@ -1,7 +1,7 @@ # Rename to dev version # This is dev version [build-system] -requires = ["setuptools>=61.0"] +requires = ["setuptools>=82.0.1"] build-backend = "setuptools.build_meta" [project] From 3cf9029878fb912a98bae42212b0ac8619a79167 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 25 Apr 2026 15:51:45 +0800 Subject: [PATCH 3/3] security: scope publish workflow permissions to the publishing job Move contents:write from workflow scope to the publish job and restrict the test job to contents:read so the broad write capability is only granted where the version-bump commit, tag push, and release creation actually need it (githubactions:S8233). --- .github/workflows/publish_stable.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish_stable.yml b/.github/workflows/publish_stable.yml index 6d555a2..0cb1d3f 100644 --- a/.github/workflows/publish_stable.yml +++ b/.github/workflows/publish_stable.yml @@ -8,15 +8,14 @@ on: - 'docs/**' - '.github/**' -permissions: - contents: write - concurrency: group: publish-stable cancel-in-progress: false jobs: test: + permissions: + contents: read runs-on: ${{ matrix.os }} strategy: fail-fast: true @@ -44,6 +43,8 @@ jobs: publish: needs: test runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@v4 with: