From d6cd45407f016a80151d11fef7f6632a000de71d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:24:22 -0400 Subject: [PATCH 01/24] chore(deps): bump ruff in the python-minor-patch group (#323) Bumps the python-minor-patch group with 1 update: [ruff](https://github.com/astral-sh/ruff). Updates `ruff` from 0.16.3 to 0.16.4 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.16.3...0.16.4) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.16.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lelia <2418071+lelia@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 44 ++++++++++++++++++++++---------------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bd003e3f..ca154c07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ test = [ "pytest-watch==4.2.0" ] dev = [ - "ruff==0.16.3", + "ruff==0.16.4", "twine==7.0.0", # for building "uv==0.12.5", # for dependency management "pre-commit==4.6.2", diff --git a/uv.lock b/uv.lock index c112a469..ad9ab1a8 100644 --- a/uv.lock +++ b/uv.lock @@ -1213,27 +1213,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, - { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, - { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, - { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, - { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, - { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, - { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, - { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, - { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, - { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, - { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, - { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, - { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, +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]] @@ -1338,7 +1338,7 @@ requires-dist = [ { name = "pytest-watch", marker = "extra == 'test'", specifier = "==4.2.0" }, { name = "python-dotenv", specifier = "==1.2.3" }, { name = "requests", specifier = "==2.34.2" }, - { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.3" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.4" }, { name = "socketdev", specifier = "==3.5.0" }, { name = "twine", marker = "extra == 'dev'", specifier = "==7.0.0" }, { name = "uv", marker = "extra == 'dev'", specifier = "==0.12.5" }, From 1d193c7d8a1137465fbf126cc9465adb70f3f654 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:40:33 -0400 Subject: [PATCH 02/24] Don't render `.socket.facts.json` placeholder as blocking package (#321) Scans with no supported manifest files uploaded a zero-byte `.socket.facts.json` placeholder. The API cannot parse that and responds by adding a synthetic `generic/invalid-socket-facts@1.0.0` artifact, which the CLI then reported as a new blocking package with no manifest file and no introducing dependency, failing the run and posting a pull request comment that could not be acted on. - Write an empty but well-formed facts document as the placeholder. - Give each placeholder its own temp directory, so concurrent runs cannot remove each other's file mid-upload. - Filter the `generic/invalid-socket-facts` marker out of full scan and diff artifacts, logging a warning instead. It is a diagnostic, not a dependency. --- CHANGELOG.md | 18 ++ pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/__init__.py | 130 +++++++++-- tests/core/test_facts_compression.py | 2 +- tests/core/test_invalid_facts_marker.py | 278 ++++++++++++++++++++++++ uv.lock | 2 +- 7 files changed, 407 insertions(+), 27 deletions(-) create mode 100644 tests/core/test_invalid_facts_marker.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f8e72fa..074bb8b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 2.6.12 + +### Fixed: unreadable reachability facts no longer report a blocking package + +- Scans with no supported manifest files uploaded a zero-byte `.socket.facts.json` + placeholder. The API cannot parse that, and answers by adding a + `generic/invalid-socket-facts@1.0.0` artifact to the scan, which the CLI then reported + as a new blocking package with no manifest file and no introducing dependency — + failing the run and, on pull requests, leaving a security comment that could not be + acted on. The placeholder is now an empty but well-formed facts document. +- When the API does report `generic/invalid-socket-facts` (a diagnostic for a facts file + it could not parse, not a real dependency), the CLI now excludes it from scan results + and logs a warning instead. It no longer blocks a run, appears in reports, or triggers + a pull request comment. +- Each placeholder is written to its own temporary directory. Two CLI runs sharing a + temporary directory previously used the same path and could remove each other's + placeholder mid-upload. + ## 2.6.11 ### Changed: bump pinned @coana-tech/cli to 15.10.32 diff --git a/pyproject.toml b/pyproject.toml index ca154c07..b85e8b80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.6.11" +version = "2.6.12" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 343b65a9..c1ba543c 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.6.11' +__version__ = '2.6.12' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index daab5fdd..a5305bee 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -1,3 +1,4 @@ +import atexit import copy import fnmatch import importlib @@ -6,6 +7,7 @@ import os import random import re +import shutil import sys import tarfile import tempfile @@ -69,6 +71,16 @@ # Stream the facts file in 1 MiB chunks so large files aren't held fully in memory. SOCKET_FACTS_BROTLI_CHUNK_SIZE = 1024 * 1024 +# Placeholder facts document (see empty_head_scan_file). A zero-byte file does not parse, +# and the API answers an unparseable facts file with the marker artifact below. +SOCKET_FACTS_EMPTY_DOCUMENT = '{"components": []}' + +# Synthetic artifact the API adds when an uploaded ``.socket.facts.json`` could not be +# parsed. A diagnostic, not a dependency, so it is dropped from scan results and reported +# as a warning instead. +INVALID_FACTS_MARKER_TYPE = "generic" +INVALID_FACTS_MARKER_NAME = "invalid-socket-facts" + # Full application reachability finalize retry policy. The finalize call links the reachability # scan to the full scan and can fail transiently (network/API blips); a few backoff retries make it robust. TIER1_FINALIZE_MAX_ATTEMPTS = 3 @@ -108,6 +120,17 @@ DIFF_SCAN_POLL_BACKOFF_MULTIPLIER = 1.5 DIFF_SCAN_POLL_TIMEOUT_SECONDS = 30 * 60.0 +# Temp dirs holding placeholder facts files (see Core.empty_head_scan_file). Call sites unlink +# the file itself once the upload finishes; the now-empty directory is removed at process exit +# so a run that raises mid-scan doesn't leak one. +_PLACEHOLDER_FACTS_DIRS: List[str] = [] + + +@atexit.register +def _cleanup_placeholder_facts_dirs() -> None: + for placeholder_dir in _PLACEHOLDER_FACTS_DIRS: + shutil.rmtree(placeholder_dir, ignore_errors=True) + def _humanize_alert_type(alert_type: str) -> str: """Convert a camelCase/PascalCase alert type into a Title-Cased label. @@ -209,13 +232,54 @@ def get_sbom_data(self, full_scan_id: str) -> Dict[str, SocketArtifact]: ) if not hasattr(response, "artifacts") or not response.artifacts: return {} - return response.artifacts + artifacts = { + artifact_id: artifact + for artifact_id, artifact in response.artifacts.items() + if not Core.is_invalid_facts_marker(artifact) + } + Core.warn_if_invalid_facts_marker(len(artifacts) != len(response.artifacts)) + return artifacts def get_sbom_data_list(self, artifacts_dict: Dict[str, SocketArtifact]) -> list[SocketArtifact]: """Converts artifacts dictionary to a list.""" return list(artifacts_dict.values()) + @staticmethod + def is_invalid_facts_marker(artifact) -> bool: + """True for the API's ``generic/invalid-socket-facts`` unparseable-facts marker. + + Treated as a package it becomes a blocking alert with an empty "Introduced by" and + "Manifest File" that no developer can act on, so callers drop it and report the parse + failure through ``warn_if_invalid_facts_marker`` instead. + Matches any version; the API pins it to 1.0.0 but the version carries no meaning. + + Args: + artifact: A ``SocketArtifact`` or diff artifact (anything with ``type``/``name``). + + Returns: + True if the artifact is the marker rather than a real package. + """ + return ( + getattr(artifact, "type", None) == INVALID_FACTS_MARKER_TYPE + and getattr(artifact, "name", None) == INVALID_FACTS_MARKER_NAME + ) + + @staticmethod + def warn_if_invalid_facts_marker(found: bool) -> None: + """Log the parse failure that ``is_invalid_facts_marker`` stands for. + + Dropping the marker silently would hide a real, if non-blocking, problem: the scan ran + without the reachability data it was supposed to carry. + """ + if not found: + return + log.warning( + "Socket could not parse the uploaded .socket.facts.json, so reachability facts " + "were not applied to this scan. Ignoring the " + f"{INVALID_FACTS_MARKER_TYPE}/{INVALID_FACTS_MARKER_NAME} marker returned for it; " + "other scan results are unaffected." + ) def create_sbom_output(self, diff: Diff) -> dict: """Creates CycloneDX output for a given diff.""" @@ -809,20 +873,31 @@ def to_case_insensitive_regex(input_string: str) -> str: @staticmethod def empty_head_scan_file() -> List[str]: """ - Creates a temporary empty file for baseline scans when no head scan exists. - + Creates a temporary placeholder manifest for scans with no manifest files. + + Used for baseline scans when a repository has no head scan yet, and for the new scan + when no supported manifest files were found. The API rejects unsupported filenames, so + the placeholder must be named ``.socket.facts.json`` - which means it must also parse + as a facts document. A zero-byte file does not, and the API answers that by adding a + blocking ``generic/invalid-socket-facts@1.0.0`` artifact to the scan. + + Each call gets its own temp directory. The path used to be a fixed + ``$TMPDIR/.socket.facts.json``, so two runs sharing a temp dir could delete or + truncate each other's placeholder mid-upload. + Returns: - List containing path to a temporary empty file + List containing path to a temporary placeholder facts file """ - # Create a temporary directory and then create our specific filename - temp_dir = tempfile.gettempdir() - temp_path = os.path.join(temp_dir, '.socket.facts.json') - - # Create the empty file - with open(temp_path, 'w'): - pass # Creates an empty file - - log.debug(f"Created temporary empty file for baseline scan: {temp_path}") + # Own directory per call so concurrent runs can't clobber each other's placeholder; + # the basename must stay exactly SOCKET_FACTS_FILENAME to pass the API's validator. + temp_dir = tempfile.mkdtemp(prefix='socket_baseline_') + _PLACEHOLDER_FACTS_DIRS.append(temp_dir) + temp_path = os.path.join(temp_dir, SOCKET_FACTS_FILENAME) + + with open(temp_path, 'w') as f: + f.write(SOCKET_FACTS_EMPTY_DOCUMENT) + + log.debug(f"Created temporary placeholder facts file for baseline scan: {temp_path}") return [temp_path] def finalize_tier1_scan(self, full_scan_id: str, facts_file_path: str) -> bool: @@ -959,7 +1034,7 @@ def _compress_facts_files_for_upload(self, files: List[str]) -> Tuple[List[str], exactly ``.socket.facts.json.br``, so compressing here keeps a large facts file under the server's per-file size cap without changing the stored result. Files whose basename is not exactly ``.socket.facts.json`` are left untouched (the server only - matches that exact name), as are empty placeholder files (e.g. baseline scans). + matches that exact name), as are zero-byte files. Compression never blocks an upload: if it fails for any reason (missing optional ``brotli`` dependency, unwritable directory, etc.) the original plain file is used. @@ -1780,16 +1855,25 @@ def get_added_and_removed_packages( diff_end = time.time() log.info(f"Diff Report Gathered in {diff_end - diff_start:.2f} seconds") + + # Left in, the invalid-socket-facts marker reads as a newly added blocking package. + # Drop it from every bucket before the counts below, which should describe what the + # CLI actually reports on. + marker_found = False + buckets: Dict[str, List] = {} + for name in ("added", "removed", "unchanged", "replaced", "updated"): + bucket = getattr(diff_artifacts, name) + buckets[name] = [a for a in bucket if not Core.is_invalid_facts_marker(a)] + marker_found = marker_found or len(buckets[name]) != len(bucket) + Core.warn_if_invalid_facts_marker(marker_found) + log.info("Diff report artifact counts:") - log.info(f"Added: {len(diff_artifacts.added)}") - log.info(f"Removed: {len(diff_artifacts.removed)}") - log.info(f"Unchanged: {len(diff_artifacts.unchanged)}") - log.info(f"Replaced: {len(diff_artifacts.replaced)}") - log.info(f"Updated: {len(diff_artifacts.updated)}") - - added_artifacts = diff_artifacts.added + diff_artifacts.updated - removed_artifacts = diff_artifacts.removed + diff_artifacts.replaced - unchanged_artifacts = diff_artifacts.unchanged + for name, bucket in buckets.items(): + log.info(f"{name.capitalize()}: {len(bucket)}") + + added_artifacts = buckets["added"] + buckets["updated"] + removed_artifacts = buckets["removed"] + buckets["replaced"] + unchanged_artifacts = buckets["unchanged"] added_packages: Dict[str, Package] = {} removed_packages: Dict[str, Package] = {} diff --git a/tests/core/test_facts_compression.py b/tests/core/test_facts_compression.py index ba04efa4..0e71cdc7 100644 --- a/tests/core/test_facts_compression.py +++ b/tests/core/test_facts_compression.py @@ -99,7 +99,7 @@ def test_compress_for_upload_preserves_directory_prefix(tmp_path): def test_empty_facts_file_is_not_compressed(tmp_path): - """Empty placeholder facts files (e.g. baseline scans) are uploaded as-is.""" + """A zero-byte facts file has nothing to compress and is uploaded as-is.""" core = Core.__new__(Core) empty_facts = _write(str(tmp_path / SOCKET_FACTS_FILENAME), b"") diff --git a/tests/core/test_invalid_facts_marker.py b/tests/core/test_invalid_facts_marker.py new file mode 100644 index 00000000..87b9005c --- /dev/null +++ b/tests/core/test_invalid_facts_marker.py @@ -0,0 +1,278 @@ +"""An unparseable `.socket.facts.json` must not block a run or leave a PR comment. + +When the API cannot parse an uploaded facts file it adds a synthetic +`generic/invalid-socket-facts@1.0.0` artifact carrying a blocking alert, which the CLI then +reports as a newly added blocking package with no manifest and no introducer. The CLI was +also handing the API an unparseable file itself: the placeholder it uploads for scans with +no manifest files was zero bytes. + +These tests cover both the placeholder (`empty_head_scan_file`) and the marker filtering. +""" +import copy +import json +import os + +import pytest +from socketdev.fullscans import FullScanStreamResponse, StreamDiffResponse + +from socketsecurity.core import ( + INVALID_FACTS_MARKER_NAME, + INVALID_FACTS_MARKER_TYPE, + SOCKET_FACTS_FILENAME, + Core, +) +from socketsecurity.core.socket_config import SocketConfig + + +@pytest.fixture +def core(mock_sdk_with_responses): + return Core(config=SocketConfig(api_key="test_key"), sdk=mock_sdk_with_responses) + + +def make_marker_artifact(diff_type="added", artifact_id="invalid-facts-1"): + """The artifact the API returns for an unparseable facts file.""" + return { + "diffType": diff_type, + "type": INVALID_FACTS_MARKER_TYPE, + "name": INVALID_FACTS_MARKER_NAME, + "version": "1.0.0", + "id": artifact_id, + "direct": True, + "manifestFiles": [], + "topLevelAncestors": [], + "license": "", + "licenseDetails": [], + "author": [], + "size": 0, + "score": { + "supplyChain": 0, + "quality": 0, + "maintenance": 0, + "vulnerability": 0, + "license": 0, + "overall": 0, + }, + "scores": { + "supplyChain": 0, + "quality": 0, + "maintenance": 0, + "vulnerability": 0, + "license": 0, + "overall": 0, + }, + "alerts": [ + { + "key": "invalid_facts_alert_1", + "type": "generic", + "severity": "high", + "category": "supplyChainRisk", + "action": "error", + } + ], + } + + +# --- The placeholder the CLI uploads ---------------------------------------------------- + + +def test_empty_head_scan_file_is_parseable_json(): + """The placeholder must parse as a facts document, or the API answers with the marker. + + A zero-byte file (the old behaviour) is what produced the invalid-socket-facts artifact + in the first place. + """ + (path,) = Core.empty_head_scan_file() + + assert os.path.basename(path) == SOCKET_FACTS_FILENAME, ( + "the API rejects unsupported filenames, so the placeholder basename is load-bearing" + ) + with open(path) as f: + assert json.load(f) == {"components": []} + + +def test_empty_head_scan_file_is_unique_per_call(): + """Concurrent runs must not share one placeholder path. + + The path used to be a fixed `$TMPDIR/.socket.facts.json`, so two CLI invocations sharing + a temp dir could delete or truncate each other's placeholder mid-upload. + """ + (first,) = Core.empty_head_scan_file() + (second,) = Core.empty_head_scan_file() + + assert first != second + # Deleting one (what the call sites do after upload) leaves the other intact. + os.unlink(first) + assert os.path.isfile(second) + + +# --- The marker predicate --------------------------------------------------------------- + + +class FakeArtifact: + def __init__(self, type, name): + self.type = type + self.name = name + + +@pytest.mark.parametrize( + "artifact_type,artifact_name,expected", + [ + (INVALID_FACTS_MARKER_TYPE, INVALID_FACTS_MARKER_NAME, True), + ("pypi", "requests", False), + # A real generic package, and a same-named package from another ecosystem, are both + # ordinary dependencies - only the exact type+name pair is the API's marker. + (INVALID_FACTS_MARKER_TYPE, "some-tarball", False), + ("npm", INVALID_FACTS_MARKER_NAME, False), + ], +) +def test_is_invalid_facts_marker(artifact_type, artifact_name, expected): + assert Core.is_invalid_facts_marker(FakeArtifact(artifact_type, artifact_name)) is expected + + +def test_is_invalid_facts_marker_ignores_version(): + """The API pins the marker to 1.0.0 today, but the version carries no meaning.""" + + class Versioned(FakeArtifact): + version = "9.9.9" + + assert Core.is_invalid_facts_marker( + Versioned(INVALID_FACTS_MARKER_TYPE, INVALID_FACTS_MARKER_NAME) + ) + + +# --- Filtering: full-scan SBOM path ------------------------------------------------------- + + +def test_get_sbom_data_drops_marker(core, data_dir, load_json, caplog): + """The marker never reaches packages built from a full scan's SBOM.""" + json_data = load_json(data_dir / "fullscans" / "head_scan" / "stream_scan.json") + artifacts = copy.deepcopy(json_data["artifacts"]) + artifacts["invalid-facts-1"] = make_marker_artifact() + core.sdk.fullscans.stream.side_effect = None + core.sdk.fullscans.stream.return_value = FullScanStreamResponse.from_dict({ + "success": True, + "status": 200, + "artifacts": artifacts, + }) + + with caplog.at_level("WARNING"): + result = core.get_sbom_data("head") + + assert "invalid-facts-1" not in result + assert len(result) == len(json_data["artifacts"]) + assert "could not parse the uploaded .socket.facts.json" in caplog.text + + +def test_get_sbom_data_does_not_warn_without_marker(core, caplog): + """A clean scan produces no facts-parse warning.""" + with caplog.at_level("WARNING"): + core.get_sbom_data("head") + + assert "could not parse the uploaded .socket.facts.json" not in caplog.text + + +# --- Filtering: diff path (the flow that posts the PR comment) --------------------------- + + +def _diff_response_with_marker(data_dir, load_json, buckets=("added",)): + json_data = load_json(data_dir / "fullscans" / "diff" / "stream_diff.json") + artifacts = copy.deepcopy(json_data["data"]["artifacts"]) + for index, bucket in enumerate(buckets): + artifacts[bucket].append( + make_marker_artifact(diff_type=bucket, artifact_id=f"invalid-facts-{index}") + ) + return StreamDiffResponse.from_dict({ + "success": json_data["success"], + "status": json_data["status"], + "data": {**json_data["data"], "artifacts": artifacts}, + }) + + +def test_diff_drops_marker_from_added_packages(core, data_dir, load_json, caplog): + """An added marker yields no package and no blocking alert. + + Left in, it surfaces as `NEW blocking issues: 1` and a PR comment for a package the + developer never added. + """ + core.sdk.fullscans.stream_diff.side_effect = None + core.sdk.fullscans.stream_diff.return_value = _diff_response_with_marker( + data_dir, load_json + ) + # Force the legacy streaming diff so the fixture above is the artifact source. + core.sdk.diffscans.create_from_ids.side_effect = Exception("diff-scans unavailable") + + with caplog.at_level("WARNING"): + added, removed, packages = core.get_added_and_removed_packages("head", "new") + + assert not any( + pkg.name == INVALID_FACTS_MARKER_NAME + for pkg in list(added.values()) + list(removed.values()) + list(packages.values()) + ) + diff = core.create_diff_report(added, removed) + assert not any(alert.pkg_name == INVALID_FACTS_MARKER_NAME for alert in diff.new_alerts) + assert "could not parse the uploaded .socket.facts.json" in caplog.text + + +def test_diff_drops_marker_from_every_bucket(core, data_dir, load_json): + """Removed and unchanged markers are dropped too. + + An unchanged marker would otherwise become an existing violation under + --strict-blocking, and a removed one would show up as a resolved alert. + """ + core.sdk.fullscans.stream_diff.side_effect = None + core.sdk.fullscans.stream_diff.return_value = _diff_response_with_marker( + data_dir, load_json, buckets=("added", "removed", "unchanged") + ) + core.sdk.diffscans.create_from_ids.side_effect = Exception("diff-scans unavailable") + + added, removed, packages = core.get_added_and_removed_packages("head", "new") + + assert not any( + pkg.name == INVALID_FACTS_MARKER_NAME + for pkg in list(added.values()) + list(removed.values()) + list(packages.values()) + ) + + +def test_diff_artifact_counts_exclude_marker(core, data_dir, load_json, caplog): + """The logged counts describe what the CLI reports on, not the raw API response. + + "Added: 1" in a run whose only added artifact was the marker sends whoever reads the log + looking for a package that was never there. + """ + core.sdk.fullscans.stream_diff.side_effect = None + core.sdk.fullscans.stream_diff.return_value = _diff_response_with_marker( + data_dir, load_json + ) + core.sdk.diffscans.create_from_ids.side_effect = Exception("diff-scans unavailable") + unfiltered_added = len( + load_json(data_dir / "fullscans" / "diff" / "stream_diff.json")["data"]["artifacts"][ + "added" + ] + ) + + with caplog.at_level("INFO"): + core.get_added_and_removed_packages("head", "new") + + assert f"Added: {unfiltered_added}" in caplog.text + assert f"Added: {unfiltered_added + 1}" not in caplog.text + + +def test_diff_keeps_real_packages(core, data_dir, load_json): + """Filtering the marker leaves genuine packages untouched.""" + core.sdk.fullscans.stream_diff.side_effect = None + unfiltered = load_json(data_dir / "fullscans" / "diff" / "stream_diff.json") + core.sdk.fullscans.stream_diff.return_value = _diff_response_with_marker( + data_dir, load_json + ) + core.sdk.diffscans.create_from_ids.side_effect = Exception("diff-scans unavailable") + + added, removed, _ = core.get_added_and_removed_packages("head", "new") + + expected_added = len(unfiltered["data"]["artifacts"]["added"]) + len( + unfiltered["data"]["artifacts"]["updated"] + ) + expected_removed = len(unfiltered["data"]["artifacts"]["removed"]) + len( + unfiltered["data"]["artifacts"]["replaced"] + ) + assert len(added) == expected_added + assert len(removed) == expected_removed diff --git a/uv.lock b/uv.lock index ad9ab1a8..8e880d32 100644 --- a/uv.lock +++ b/uv.lock @@ -1282,7 +1282,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.6.11" +version = "2.6.12" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From 717d0afd8f3f4534672dd47d2da7619b7ec22c45 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:05:48 -0400 Subject: [PATCH 03/24] Fix pull request comment rendering and disable flags (#322) * Fix orphaned tags and empty tables in PR comments A whitespace-only line closes a CommonMark HTML block. Optional sections that rendered as empty left one behind inside the alerts table, so the indented closing tags after it were rendered as a literal code block reading `` instead of markup. - Drop blank lines from generated comment markup and keep indentation below the four spaces that start a code block. - Collapse alert descriptions, suggestions and license findings onto a single line so multi-line API text cannot break the table either. - Replace the comment body with a short confirmation when no alerts are left to report, instead of keeping the caution banner above a table with no rows. The comment marker is preserved so the same comment is updated later. - Apply ignore-all to the pre-2.0.55 Markdown table format. The check was made once per ignore command and an ignore-all comment produces none, so no rows were removed. Bumps to 2.6.8. * Make --disable-security-issue and --disable-overview suppress comments Both flags were checked only after testing whether a comment of that type was already on the pull request, so they suppressed the first post and then updated that comment on every later run. --disable-security-issue in particular kept refreshing an existing comment with the full alerts table. The flags now mean the CLI does not manage that comment at all. An existing comment is left untouched rather than rewritten, since a body claiming no alerts would be inaccurate when reporting is merely switched off. Moves the decision into should_write_comment() so it is covered by tests directly; main_code() had no harness for this block. Bumps to 2.7.0 rather than a patch, since these flags change behavior. --- CHANGELOG.md | 36 ++- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/messages.py | 110 ++++++++- socketsecurity/core/scm_comments.py | 57 ++++- socketsecurity/socketcli.py | 52 ++-- tests/unit/test_pr_comment_rendering.py | 304 ++++++++++++++++++++++++ tests/unit/test_socketcli.py | 45 +++- uv.lock | 2 +- 9 files changed, 566 insertions(+), 44 deletions(-) create mode 100644 tests/unit/test_pr_comment_rendering.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 074bb8b6..21150734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 2.6.12 +## 2.7.0 ### Fixed: unreadable reachability facts no longer report a blocking package @@ -18,6 +18,39 @@ temporary directory previously used the same path and could remove each other's placeholder mid-upload. +### Fixed: pull request comments no longer show orphaned tags or an empty table + +- Optional sections that rendered as empty, such as the ignore instructions + suppressed by `--disable-ignore`, left a whitespace-only line in the alerts + table. That line closed the surrounding HTML block, and the indented + `` tags after it were rendered as a literal code block. + Generated comment markup now omits blank lines and stays under the indentation + that starts a code block. +- Alert descriptions, suggestions and license findings are collapsed onto a + single line so multi-line API text cannot break the table markup either. +- When a pull request has no alerts left to report, the security comment is + replaced with a short confirmation instead of keeping the "Caution" banner + above a table with no rows. This happens both when a later commit resolves + every alert and when every alert is ignored by comment. The comment marker is + preserved, so a commit that reintroduces an alert updates the same comment + rather than posting a second one. +- `@SocketSecurity ignore-all` now applies to comments written by CLI versions + before 2.0.55, which use the older Markdown alerts table. The check was made + once per ignore command, and an ignore-all comment produces none, so no rows + were removed. + +### Fixed: `--disable-security-issue` and `--disable-overview` now suppress the comment entirely + +- Both flags were checked only after testing whether a comment of that type was + already on the pull request, so they suppressed the first post and then + updated that comment on every later run. `--disable-security-issue` in + particular kept refreshing an existing comment with the full alerts table. +- The flags now mean the CLI does not manage that comment at all. An existing + comment is left untouched rather than being rewritten, since a body claiming + no alerts would be inaccurate when reporting is merely switched off. +- The decision moved into `should_write_comment()` so it is covered directly by + tests. + ## 2.6.11 ### Changed: bump pinned @coana-tech/cli to 15.10.32 @@ -49,7 +82,6 @@ - Bumped the pinned reachability engine (`@coana-tech/cli`) from `15.10.23` to `15.10.25`. See the [Coana changelogs](https://docs.coana.tech/changelogs) for engine changes. - ## 2.6.7 ### Changed: bump pinned @coana-tech/cli to 15.10.23 diff --git a/pyproject.toml b/pyproject.toml index b85e8b80..293bbd13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.6.12" +version = "2.7.0" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index c1ba543c..d72ecc6e 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.6.12' +__version__ = '2.7.0' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/messages.py b/socketsecurity/core/messages.py index 319e454b..673dde5c 100644 --- a/socketsecurity/core/messages.py +++ b/socketsecurity/core/messages.py @@ -806,6 +806,90 @@ def create_security_comment_gitlab(diff: Diff) -> dict: return gitlab_report + # A blank line terminates a CommonMark HTML block. When that happens inside + # the alerts table the closing tags that follow are no longer treated as + # markup, and because they are indented four or more spaces they render as a + # literal code block containing `` instead. + MAX_HTML_INDENT = 3 + + @staticmethod + def inline_html_text(value) -> str: + """ + Collapses API supplied text onto a single line. + + Alert descriptions and suggestions are interpolated into the comment HTML, + so an embedded newline would otherwise be able to close the surrounding + HTML block early. + + :param value: The value to flatten. ``None`` becomes an empty string. + :return: str - The value with all whitespace runs collapsed to a single space. + """ + if value is None: + return "" + return " ".join(str(value).split()) + + @staticmethod + def normalize_comment_html(comment: str) -> str: + """ + Makes generated comment markup safe for the CommonMark renderers used by + GitHub and GitLab. + + Drops whitespace-only lines (an optional section that rendered as empty + leaves one behind) and caps indentation below the four spaces that would + start an indented code block. Intentional separators - lines that are + genuinely empty - are preserved so markdown blocks still break apart. + + :param comment: str - The generated comment body. + :return: str - The comment body with unrenderable whitespace removed. + """ + lines = [] + for line in comment.split("\n"): + if line and not line.strip(): + continue + stripped = line.lstrip() + indent = min(len(line) - len(stripped), Messages.MAX_HTML_INDENT) + lines.append(" " * indent + stripped) + return "\n".join(lines) + + @staticmethod + def security_comment_no_alerts_template(view_report_url: str = "") -> str: + """ + Generates the body used when there is nothing left to report. + + Alerts raised on an early commit are frequently resolved later in the same + pull request. Rewriting the comment to this body keeps the Socket comment + in place - so a later commit that reintroduces an alert updates it rather + than posting a second comment - without leaving the "Caution" banner above + an empty alerts table. + + :param view_report_url: str - Optional link to the full Socket report. + :return: str - The formatted Markdown/HTML string. + """ + lines = [ + "", + "", + "> **✅ Socket Security** ", + "> No dependency alerts to report. Any alerts previously reported on this " + "pull request have been resolved or ignored.", + ] + if view_report_url: + lines += ["", f"[View full report]({view_report_url})"] + return "\n".join(lines) + "\n" + + @staticmethod + def get_view_report_url(diff: Diff) -> str: + """ + Resolves the report link for a diff, preferring the PR/MR diff view. + + :param diff: Diff - Diff report to pull the URL from. + :return: str - The report URL, or an empty string when neither is set. + """ + if getattr(diff, "diff_url", None): + return diff.diff_url + if getattr(diff, "report_url", None): + return diff.report_url + return "" + @staticmethod def security_comment_template(diff: Diff, config=None) -> str: """ @@ -819,7 +903,7 @@ def security_comment_template(diff: Diff, config=None) -> str: # Group license policy violations by PURL (ecosystem/package@version) license_groups = {} security_alerts = [] - + for alert in diff.new_alerts: if alert.type == "licenseSpdxDisj": purl_key = f"{alert.pkg_type}/{alert.pkg_name}@{alert.pkg_version}" @@ -829,6 +913,13 @@ def security_comment_template(diff: Diff, config=None) -> str: else: security_alerts.append(alert) + view_report_url = Messages.get_view_report_url(diff) + + # Without this the caution banner would sit above a table with no rows, + # which is how a comment looks once every alert it raised is resolved. + if not security_alerts and not license_groups: + return Messages.security_comment_no_alerts_template(view_report_url) + # Start of the comment comment = """ @@ -875,15 +966,15 @@ def security_comment_template(diff: Diff, config=None) -> str:
- {alert.pkg_name}@{alert.pkg_version} - {alert.title} -

Note: {alert.description}

+ {alert.pkg_name}@{alert.pkg_version} - {Messages.inline_html_text(alert.title)} +

Note: {Messages.inline_html_text(alert.description)}

Source: Manifest File

â„šī¸ Read more on: This package | This alert | What is known malware?

-

Suggestion: {alert.suggestion}

+

Suggestion: {Messages.inline_html_text(alert.suggestion)}

{ignore_html}
@@ -917,7 +1008,7 @@ def security_comment_template(diff: Diff, config=None) -> str: