From 038dd474e908c5f66b79eebd56f0bf3865b150d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicklas=20K=C3=B6rtge?= Date: Thu, 8 Oct 2026 14:34:06 +0200 Subject: [PATCH 1/3] fix: include Sonar analyzer redistribution materials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicklas Körtge --- .github/workflows/maven.yml | 2 + README.md | 17 +- sonar-cryptography-plugin/pom.xml | 57 +++++- .../META-INF/THIRD-PARTY-NOTICES.txt | 63 ++++++ .../resources/META-INF/third-party/SSALv1.txt | 184 ++++++++++++++++++ .../test/python/verify_analyzer_packaging.py | 80 ++++++++ 6 files changed, 398 insertions(+), 5 deletions(-) create mode 100644 sonar-cryptography-plugin/src/main/resources-filtered/META-INF/THIRD-PARTY-NOTICES.txt create mode 100644 sonar-cryptography-plugin/src/main/resources/META-INF/third-party/SSALv1.txt create mode 100644 sonar-cryptography-plugin/src/test/python/verify_analyzer_packaging.py diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index feaab41e4..83ef9ae63 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -31,6 +31,8 @@ jobs: cache: maven - name: Build with Maven run: mvn -B clean package + - name: Verify analyzer redistribution materials + run: python3 sonar-cryptography-plugin/src/test/python/verify_analyzer_packaging.py sonar-cryptography-plugin/target/sonar-cryptography-plugin-*.jar # The build must not write into the source tree. It used to: the mapper # module regenerated ciphersuites.json and JsonCipherSuites.java from a live # API on every compile. Fail here if that ever comes back. diff --git a/README.md b/README.md index 56f24ec60..7b35ec152 100644 --- a/README.md +++ b/README.md @@ -327,4 +327,19 @@ start a discussion using [GitHub Discussions](https://github.com/cbomkit/sonar-c ## License -[Apache License 2.0](LICENSE.txt) +The independently authored code in this repository is licensed under the +[Apache License 2.0](LICENSE.txt). The packaged plugin also contains third-party +code under its own licences, including sonar-java, sonar-python and sonar-go +under the [Sonar Source-Available License v1.0](https://www.sonarsource.com/license/ssal-1-0-0/), +subject to their individual-file licence notices. + +The plugin JAR includes `META-INF/THIRD-PARTY-NOTICES.txt`, the SSAL text, and +each resolved analyser module's original legal files under +`META-INF/third-party/analyzers/`. Matching upstream Maven source JARs are included +under `META-INF/third-party/analyzer-sources/`, including the Java and Python +frontends. The notices describe the versions, source locations and packaging +transformations. + +These packaging materials do not establish that every use is permitted by SSAL. +The permitted-purpose restrictions and the completeness of upstream source +distributions, including the native Go bridge, still require review before release. diff --git a/sonar-cryptography-plugin/pom.xml b/sonar-cryptography-plugin/pom.xml index fee4f5a70..0ecc6e7bb 100644 --- a/sonar-cryptography-plugin/pom.xml +++ b/sonar-cryptography-plugin/pom.xml @@ -21,6 +21,7 @@ UTF-8 17 + org.sonarsource.java,org.sonarsource.python,org.sonarsource.go @@ -64,9 +65,58 @@ src/main/resources-filtered true + + ${project.build.directory}/analyzer-resources + + + ${project.basedir}/.. + + LICENSE.txt + + META-INF + + + org.apache.maven.plugins + maven-dependency-plugin + 3.11.0 + + + preserve-analyzer-notices + generate-resources + + unpack-dependencies + + + ${sonar.analyzer.groups} + runtime + + true + **/*LICENSE*,**/*NOTICE*,**/*license*,**/*notice*,**/license/**,**/licenses/**,about_files/** + ${project.build.directory}/analyzer-resources/META-INF/third-party/analyzers + + + + include-analyzer-sources + generate-resources + + copy-dependencies + + + + ${sonar.analyzer.groups} + runtime + sources + true + ${project.build.directory}/analyzer-resources/META-INF/third-party/analyzer-sources + + + + org.sonarsource.sonar-packaging-maven-plugin sonar-packaging-maven-plugin @@ -102,15 +152,14 @@ *:* - META-INF/LICENSE* - META-INF/NOTICE* META-INF/*.RSA META-INF/*.SF - LICENSE* - NOTICE* + + + diff --git a/sonar-cryptography-plugin/src/main/resources-filtered/META-INF/THIRD-PARTY-NOTICES.txt b/sonar-cryptography-plugin/src/main/resources-filtered/META-INF/THIRD-PARTY-NOTICES.txt new file mode 100644 index 000000000..9cfe8e72d --- /dev/null +++ b/sonar-cryptography-plugin/src/main/resources-filtered/META-INF/THIRD-PARTY-NOTICES.txt @@ -0,0 +1,63 @@ +Sonar Cryptography Plugin ${project.version} + +Independently authored Sonar Cryptography Plugin code is licensed under +Apache License 2.0. See META-INF/LICENSE.txt. Third-party components retain +their own licences; the Apache licence does not relicense their code. + +This JAR contains portions of the following SonarSource analyzers: + +sonar-java ${sonar.java.version} + Maven coordinate: org.sonarsource.java:sonar-java-plugin:${sonar.java.version} + Project: https://github.com/SonarSource/sonar-java + Release source tree: https://github.com/SonarSource/sonar-java/tree/${sonar.java.version} + Published plugin sources: + https://repo.maven.apache.org/maven2/org/sonarsource/java/sonar-java-plugin/${sonar.java.version}/sonar-java-plugin-${sonar.java.version}-sources.jar + Original legal files: META-INF/third-party/analyzers/sonar-java-plugin-${sonar.java.version}-jar/ + +sonar-python ${sonar.python.version} + Maven coordinate: org.sonarsource.python:sonar-python-plugin:${sonar.python.version} + Published plugin sources: + https://repo.maven.apache.org/maven2/org/sonarsource/python/sonar-python-plugin/${sonar.python.version}/sonar-python-plugin-${sonar.python.version}-sources.jar + Original legal files: META-INF/third-party/analyzers/sonar-python-plugin-${sonar.python.version}-jar/ + +sonar-go ${sonar.go.version} + Maven coordinate: org.sonarsource.go:sonar-go-plugin:${sonar.go.version} + Published plugin sources: + https://repo.maven.apache.org/maven2/org/sonarsource/go/sonar-go-plugin/${sonar.go.version}/sonar-go-plugin-${sonar.go.version}-sources.jar + Original legal files: META-INF/third-party/analyzers/sonar-go-plugin-${sonar.go.version}-jar/ + +The SSAL-covered portions and their source are available under the Sonar +Source-Available License v1.0 (SSALv1). The sonar-java licence text is included at +META-INF/third-party/SSALv1.txt. The Python and Go licence texts are preserved +in licenses/LICENSE.txt within their original-legal-files directories above. +Consult each component's own text; the upstream texts differ in the named +SonarSource legal entity. Individual files and embedded third-party +components may have other licences; retain and consult their original notices. +Copyright statements are preserved in the source JARs and original legal files. + +Obtaining source + +Extract the source JARs from META-INF/third-party/analyzer-sources/ in this JAR. +They are the unmodified sources classifiers published on Maven Central for the +resolved Java, Python and Go analyzer modules, including Java and Python +frontends, checks and other resolved modules in those Maven groups. Each +filename identifies the module and its exact version. They can also be fetched +from https://repo.maven.apache.org/maven2/ using each module's Maven coordinate. +Source-specific legal notices remain within each source JAR. + +The upstream plugin source JARs are not complete repository source trees. In +particular, the Go plugin source JAR does not include the native Go-to-Slang +bridge implementation. Its matching source availability must be resolved before +claiming full source compliance for the bundled native executables. + +Packaging + +Analyzer bytecode is combined with this plugin using Maven Shade with +minimizeJar enabled. Unused classes are removed; package names are not relocated. +Analyzer source JARs and the namespaced legal files are copied without changes. +The build configuration is sonar-cryptography-plugin/pom.xml in the matching +Sonar Cryptography Plugin release at https://github.com/cbomkit/sonar-cryptography. + +This notice documents redistribution materials. It does not grant additional +rights or establish that the plugin's functionality or every downstream use +satisfies SSAL's permitted-purpose restrictions. diff --git a/sonar-cryptography-plugin/src/main/resources/META-INF/third-party/SSALv1.txt b/sonar-cryptography-plugin/src/main/resources/META-INF/third-party/SSALv1.txt new file mode 100644 index 000000000..28723feb3 --- /dev/null +++ b/sonar-cryptography-plugin/src/main/resources/META-INF/third-party/SSALv1.txt @@ -0,0 +1,184 @@ +SONAR Source-Available License v1.0 +Last Updated November 13, 2024 + +1. DEFINITIONS + +"Agreement" means this Sonar Source-Available License v1.0 + +"Competing" means marketing a product or service as a substitute for the +functionality or value of SonarQube. A product or service may compete regardless +of how it is designed or deployed. For example, a product or service may compete +even if it provides its functionality via any kind of interface (including +services, libraries, or plug-ins), even if it is ported to a different platform +or programming language, and even if it is provided free of charge. + +"Contribution" means: + + a) in the case of the initial Contributor, the initial content Distributed under +this Agreement, and + + b) in the case of each subsequent Contributor: + i) changes to the Program, and + ii) additions to the Program; + +where such changes and/or additions to the Program originate from and are +Distributed by that particular Contributor. A Contribution "originates" from a +Contributor if it was added to the Program by such Contributor itself or anyone +acting on such Contributor's behalf. Contributions do not include changes or +additions to the Program that are not Modified Works. + +"Contributor" means any person or entity that Distributes the Program. + +"Derivative Works" shall mean any work, whether in Source Code or other form, +that is based on (or derived from) the Program and for which the editorial +revisions, annotations, elaborations, or other modifications represent, as a +whole, an original work of authorship. + +"Distribute" means the acts of a) distributing or b) making available in any +manner that enables the transfer of a copy. + +"Licensed Patents" mean patent claims licensable by a Contributor that are +necessarily infringed by the use or sale of its Contribution alone or when +combined with the Program. + +"Modified Works" shall mean any work in Source Code or other form that results +from an addition to, deletion from, or modification of the contents of the +Program, including, for purposes of clarity, any new file in Source Code form +that contains any contents of the Program. Modified Works shall not include +works that contain only declarations, interfaces, types, classes, structures, or +files of the Program solely in each case in order to link to, bind by name, or +subclass the Program or Modified Works thereof. + +"Non-competitive Purpose" means any purpose except for (a) providing to others +any product or service that includes or offers the same or substantially similar +functionality as SonarQube, (b) Competing with SonarQube, and/or (c) employing, +using, or engaging artificial intelligence technology that is not part of the +Program to ingest, interpret, analyze, train on, or interact with the data +provided by the Program, or to engage with the Program in any manner. + +"Notices" means any legal statements or attributions included with the Program, +including, without limitation, statements concerning copyright, patent, +trademark, disclaimers of warranty, or limitations of liability + +"Program" means the Contributions Distributed in accordance with this Agreement. + +"Recipient" means anyone who receives the Program under this Agreement, +including Contributors. + +"SonarQube" means an open-source or commercial edition of software offered by +SonarSource that is branded "SonarQube". + +"SonarSource" means SonarSource SA, a Swiss company registered in Switzerland +under UID No. CHE-114.587.664. + +"Source Code" means the form of a Program preferred for making modifications, +including but not limited to software source code, documentation source, and +configuration files. + +2. GRANT OF RIGHTS + + a) Subject to the terms of this Agreement, each Contributor hereby grants +Recipient a non-exclusive, worldwide, royalty-free copyright license, for any +Non-competitive Purpose, to reproduce, prepare Derivative Works of, publicly +display, publicly perform, Distribute and sublicense the Contribution of such +Contributor, if any, and such Derivative Works. + + b) Subject to the terms of this Agreement, each Contributor hereby grants +Recipient a non-exclusive, worldwide, royalty-free patent license under Licensed +Patents, for any Non-competitive Purpose, to make, use, sell, offer to sell, +import, and otherwise transfer the Contribution of such Contributor, if any, in +Source Code or other form. This patent license shall apply to the combination of +the Contribution and the Program if, at the time the Contribution is added by +the Contributor, such addition of the Contribution causes such combination to be +covered by the Licensed Patents. The patent license shall not apply to any other +combinations that include the Contribution. + + c) Recipient understands that although each Contributor grants the licenses to +its Contributions set forth herein, no assurances are provided by any +Contributor that the Program does not infringe the patent or other intellectual +property rights of any other entity. Each Contributor disclaims any liability to +Recipient for claims brought by any other entity based on infringement of +intellectual property rights or otherwise. As a condition to exercising the +rights and licenses granted hereunder, each Recipient hereby assumes sole +responsibility to secure any other intellectual property rights needed, if any. +For example, if a third-party patent license is required to allow Recipient to +Distribute the Program, it is Recipient's responsibility to acquire that license +before distributing the Program. + + d) Each Contributor represents that to its knowledge it has sufficient copyright +rights in its Contribution, if any, to grant the copyright license set forth in +this Agreement. + +3. REQUIREMENTS + +3.1 If a Contributor Distributes the Program in any form, then the Program must +also be made available as Source Code, in accordance with section 3.2, and the +Contributor must accompany the Program with a statement that the Source Code for +the Program is available under this Agreement, and inform Recipients how to +obtain it in a reasonable manner on or through a medium customarily used for +software exchange; and + +3.2 When the Program is Distributed as Source Code: + + a) it must be made available under this Agreement, and + + b) a copy of this Agreement must be included with each copy of the Program. + +3.3 Contributors may not remove or alter any Notices contained within the +Program from any copy of the Program which they Distribute, provided that +Contributors may add their own appropriate Notices. + +4. NO WARRANTY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT PERMITTED BY +APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED INCLUDING, WITHOUT +LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, +MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Each Recipient is solely +responsible for determining the appropriateness of using and distributing the +Program and assumes all risks associated with its exercise of rights under this +Agreement, including but not limited to the risks and costs of program errors, +compliance with applicable laws, damage to or loss of data, programs or +equipment, and unavailability or interruption of operations. + +5. DISCLAIMER OF LIABILITY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT PERMITTED BY +APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS SHALL HAVE ANY LIABILITY +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING WITHOUT LIMITATION LOST PROFITS), HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF +THE PROGRAM OR THE EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF +THE POSSIBILITY OF SUCH DAMAGES. + +6. GENERAL + +If any provision of this Agreement is invalid or unenforceable under applicable +law, it shall not affect the validity or enforceability of the remainder of the +terms of this Agreement, and without further action by the parties hereto, such +provision shall be reformed to the minimum extent necessary to make such +provision valid and enforceable. + +If Recipient institutes patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Program itself +(excluding combinations of the Program with other software or hardware) +infringes such Recipient’s patent(s), then such Recipient’s rights granted under +Section 2(b) shall terminate as of the date such litigation is filed. + +All Recipient’s rights under this Agreement shall terminate if it fails to +comply with any of the material terms or conditions of this Agreement and does +not cure such failure in a reasonable period of time after becoming aware of +such noncompliance. If all Recipient’s rights under this Agreement terminate, +Recipient agrees to cease use and distribution of the Program as soon as +reasonably practicable. However, Recipient’s obligations under this Agreement +and any licenses granted by Recipient relating to the Program shall continue and +survive. + +Except as expressly stated in Sections 2(a) and 2(b) above, Recipient receives +no rights or licenses to the intellectual property of any Contributor under this +Agreement, whether expressly, by implication, estoppel, or otherwise. All rights +in the Program not expressly granted under this Agreement are reserved. Nothing +in this Agreement is intended to be enforceable by any entity that is not a +Contributor or Recipient. No third-party beneficiary rights are created under +this Agreement. diff --git a/sonar-cryptography-plugin/src/test/python/verify_analyzer_packaging.py b/sonar-cryptography-plugin/src/test/python/verify_analyzer_packaging.py new file mode 100644 index 000000000..ad2afc330 --- /dev/null +++ b/sonar-cryptography-plugin/src/test/python/verify_analyzer_packaging.py @@ -0,0 +1,80 @@ +"""Verify the legal materials and source distributions in the shaded release JAR.""" + +import argparse +from pathlib import Path +import xml.etree.ElementTree as ET +from zipfile import ZipFile + + +def verify(plugin_jar, repository): + root = Path(__file__).resolve().parents[4] + namespace = {"m": "http://maven.apache.org/POM/4.0.0"} + properties = ET.parse(root / "pom.xml").find("m:properties", namespace) + with ZipFile(plugin_jar) as packaged: + names = packaged.namelist() + assert len(names) == len(set(names)), "Duplicate JAR entries" + notice = packaged.read("META-INF/THIRD-PARTY-NOTICES.txt").decode() + assert "${" not in notice, "Unresolved Maven properties in notices" + assert packaged.read("META-INF/LICENSE.txt") == (root / "LICENSE.txt").read_bytes() + java_license = ( + root / "sonar-cryptography-plugin/src/main/resources" + / "META-INF/third-party/SSALv1.txt" + ) + assert packaged.read("META-INF/third-party/SSALv1.txt") == java_license.read_bytes() + + for language in ("java", "python", "go"): + version = properties.find("m:sonar." + language + ".version", namespace).text + artifact = "sonar-" + language + "-plugin" + coordinate = "org.sonarsource." + language + ":" + artifact + ":" + version + assert coordinate in notice, "Missing resolved coordinate: " + coordinate + source_name = artifact + "-" + version + "-sources.jar" + assert source_name in notice, "Missing matching source URL: " + source_name + artifact_directory = ( + repository / "org/sonarsource" / language / artifact / version + ) + binary = artifact_directory / (artifact + "-" + version + ".jar") + prefix = "META-INF/third-party/analyzers/" + artifact + "-" + version + "-jar/" + assert prefix in notice, "Incorrect legal-file directory: " + prefix + with ZipFile(binary) as original: + legal_files = [ + name + for name in original.namelist() + if not name.endswith("/") and ( + "license" in name.lower() + or "notice" in name.lower() + or name.startswith("about_files/") + ) + ] + assert legal_files, "No upstream legal files found: " + artifact + for name in legal_files: + assert packaged.read(prefix + name) == original.read(name), ( + "Missing or changed legal file: " + prefix + name + ) + # Check the published source archives, not just their filenames. + sources = artifact_directory / source_name + assert packaged.read( + "META-INF/third-party/analyzer-sources/" + source_name + ) == sources.read_bytes() + + # These implementations are absent from the plugin-only source JARs. + for language in ("java", "python"): + version = properties.find("m:sonar." + language + ".version", namespace).text + source_name = language + "-frontend-" + version + "-sources.jar" + assert "META-INF/third-party/analyzer-sources/" + source_name in names, ( + "Missing parser implementation sources: " + source_name + ) + assert "does not include the native Go-to-Slang" in notice, ( + "Missing upstream source gap" + ) + print( + "Verified analyzer notices, licences and published source JARs in " + + str(plugin_jar) + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("plugin_jar", type=Path) + parser.add_argument("--repository", type=Path, default=Path.home() / ".m2/repository") + arguments = parser.parse_args() + verify(arguments.plugin_jar, arguments.repository) From b75f9e754c7b7d148867745536fab16512c9b3aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicklas=20K=C3=B6rtge?= Date: Thu, 8 Oct 2026 14:53:55 +0200 Subject: [PATCH 2/3] fix: include complete sonar-go release sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicklas Körtge --- README.md | 10 +++-- pom.xml | 4 ++ sonar-cryptography-plugin/pom.xml | 24 +++++++++++ .../META-INF/THIRD-PARTY-NOTICES.txt | 18 +++++--- .../test/python/verify_analyzer_packaging.py | 43 +++++++++++++++++-- 5 files changed, 87 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7b35ec152..87bf85ebc 100644 --- a/README.md +++ b/README.md @@ -337,9 +337,11 @@ The plugin JAR includes `META-INF/THIRD-PARTY-NOTICES.txt`, the SSAL text, and each resolved analyser module's original legal files under `META-INF/third-party/analyzers/`. Matching upstream Maven source JARs are included under `META-INF/third-party/analyzer-sources/`, including the Java and Python -frontends. The notices describe the versions, source locations and packaging -transformations. +frontends. A pinned complete sonar-go release source ZIP in the same directory +also provides the native Go bridge, shaded Go modules and build scripts. Its +public commit maps to the plugin's build revision through `GitOrigin-RevId`. +The notices describe the versions, source locations and packaging transformations. These packaging materials do not establish that every use is permitted by SSAL. -The permitted-purpose restrictions and the completeness of upstream source -distributions, including the native Go bridge, still require review before release. +The permitted-purpose restrictions and the provenance and licence notices of +any adapted implementation code still require review before release. diff --git a/pom.xml b/pom.xml index b19a69050..17923d698 100644 --- a/pom.xml +++ b/pom.xml @@ -54,6 +54,10 @@ 8.22.0.41895 5.29.0.35837 1.32.0.5128 + + a7de104eea09927509592bf68fa7ac69a7ecc075 + f05477beb84a17dfed5c3ffbe57038b1fa260702 2.30.0.5193 1.28.0 diff --git a/sonar-cryptography-plugin/pom.xml b/sonar-cryptography-plugin/pom.xml index 0ecc6e7bb..c12384d73 100644 --- a/sonar-cryptography-plugin/pom.xml +++ b/sonar-cryptography-plugin/pom.xml @@ -117,6 +117,30 @@ + + org.apache.maven.plugins + maven-antrun-plugin + 3.2.0 + + + include-go-release-source + generate-resources + + run + + + + + + + + + + + org.sonarsource.sonar-packaging-maven-plugin sonar-packaging-maven-plugin diff --git a/sonar-cryptography-plugin/src/main/resources-filtered/META-INF/THIRD-PARTY-NOTICES.txt b/sonar-cryptography-plugin/src/main/resources-filtered/META-INF/THIRD-PARTY-NOTICES.txt index 9cfe8e72d..d00dbd25f 100644 --- a/sonar-cryptography-plugin/src/main/resources-filtered/META-INF/THIRD-PARTY-NOTICES.txt +++ b/sonar-cryptography-plugin/src/main/resources-filtered/META-INF/THIRD-PARTY-NOTICES.txt @@ -22,6 +22,11 @@ sonar-python ${sonar.python.version} sonar-go ${sonar.go.version} Maven coordinate: org.sonarsource.go:sonar-go-plugin:${sonar.go.version} + Release source tree: https://github.com/SonarSource/sonar-go/tree/${sonar.go.source.revision} + Complete release source: + https://codeload.github.com/SonarSource/sonar-go/zip/${sonar.go.source.revision} + Public source revision: ${sonar.go.source.revision} + GitOrigin-RevId / plugin Implementation-Build: ${sonar.go.source.origin-revision} Published plugin sources: https://repo.maven.apache.org/maven2/org/sonarsource/go/sonar-go-plugin/${sonar.go.version}/sonar-go-plugin-${sonar.go.version}-sources.jar Original legal files: META-INF/third-party/analyzers/sonar-go-plugin-${sonar.go.version}-jar/ @@ -45,16 +50,19 @@ filename identifies the module and its exact version. They can also be fetched from https://repo.maven.apache.org/maven2/ using each module's Maven coordinate. Source-specific legal notices remain within each source JAR. -The upstream plugin source JARs are not complete repository source trees. In -particular, the Go plugin source JAR does not include the native Go-to-Slang -bridge implementation. Its matching source availability must be resolved before -claiming full source compliance for the bundled native executables. +The Go plugin source JAR omits the native Go-to-Slang bridge and shaded modules. +The complete release tree is additionally included in the same directory as +sonar-go-${sonar.go.version}-${sonar.go.source.revision}-source.zip. +It includes the native bridge, generated-source inputs, GoConverter and the +build scripts. The public commit's GitOrigin-RevId matches Implementation-Build +in the original plugin JAR; its published Java sources match the source JAR. +Original legal notices remain within the complete release archive. Packaging Analyzer bytecode is combined with this plugin using Maven Shade with minimizeJar enabled. Unused classes are removed; package names are not relocated. -Analyzer source JARs and the namespaced legal files are copied without changes. +Analyzer source archives and the namespaced legal files are copied without changes. The build configuration is sonar-cryptography-plugin/pom.xml in the matching Sonar Cryptography Plugin release at https://github.com/cbomkit/sonar-cryptography. diff --git a/sonar-cryptography-plugin/src/test/python/verify_analyzer_packaging.py b/sonar-cryptography-plugin/src/test/python/verify_analyzer_packaging.py index ad2afc330..eca43cbe2 100644 --- a/sonar-cryptography-plugin/src/test/python/verify_analyzer_packaging.py +++ b/sonar-cryptography-plugin/src/test/python/verify_analyzer_packaging.py @@ -1,6 +1,7 @@ """Verify the legal materials and source distributions in the shaded release JAR.""" import argparse +from io import BytesIO from pathlib import Path import xml.etree.ElementTree as ET from zipfile import ZipFile @@ -63,11 +64,47 @@ def verify(plugin_jar, repository): assert "META-INF/third-party/analyzer-sources/" + source_name in names, ( "Missing parser implementation sources: " + source_name ) - assert "does not include the native Go-to-Slang" in notice, ( - "Missing upstream source gap" + version = properties.find("m:sonar.go.version", namespace).text + revision = properties.find("m:sonar.go.source.revision", namespace).text + origin = properties.find("m:sonar.go.source.origin-revision", namespace).text + assert revision in notice and origin in notice, "Missing Go source provenance" + artifact_directory = ( + repository / "org/sonarsource/go/sonar-go-plugin" / version ) + with ZipFile(artifact_directory / ("sonar-go-plugin-" + version + ".jar")) as go: + manifest = go.read("META-INF/MANIFEST.MF").decode().replace("\r\n ", "") + assert "Implementation-Build: " + origin in manifest, ( + "Go dependency changed: update the matching release source revisions" + ) + + archive_name = "sonar-go-" + version + "-" + revision + "-source.zip" + archive = packaged.read("META-INF/third-party/analyzer-sources/" + archive_name) + with ZipFile(BytesIO(archive)) as source: + assert source.comment.decode() == revision, "Wrong Go source revision" + prefix = "sonar-go-" + revision + "/" + for path in ( + "LICENSE.txt", + "sonar-go-commons/src/main/java/org/sonar/go/converter/GoConverter.java", + "sonar-go-to-slang/main.go", + "sonar-go-to-slang/goparser.go", + "sonar-go-to-slang/generate_source.go", + "sonar-go-to-slang/mapping_generated.go", + "sonar-go-to-slang/go.mod", + "sonar-go-to-slang/go.sum", + "sonar-go-to-slang/make.sh", + "build-logic/common/build.gradle.kts", + ): + assert source.read(prefix + path), "Missing Go release source: " + path + source_jar = artifact_directory / ("sonar-go-plugin-" + version + "-sources.jar") + with ZipFile(source_jar) as published: + java_files = [name for name in published.namelist() if name.endswith(".java")] + assert java_files, "No published Go Java sources to compare" + for name in java_files: + assert source.read( + prefix + "sonar-go-plugin/src/main/java/" + name + ) == published.read(name), "Go release source mismatch: " + name print( - "Verified analyzer notices, licences and published source JARs in " + "Verified analyzer notices, licences, source JARs and complete Go source in " + str(plugin_jar) ) From 4f7f4d8d15cba1cfc549a78717b5ef2274019cf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicklas=20K=C3=B6rtge?= Date: Thu, 8 Oct 2026 15:14:40 +0200 Subject: [PATCH 3/3] fix: replace upstream-derived matcher and Go sensor logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicklas Körtge --- .../ibm/engine/detection/MethodMatcher.java | 248 ++++------- .../detection/MethodMatcherBehaviorTest.java | 218 ++++++++++ .../java/com/ibm/plugin/CryptoGoSensor.java | 392 ++++++++---------- .../com/ibm/plugin/CryptoGoSensorTest.java | 324 +++++++++++++++ 4 files changed, 803 insertions(+), 379 deletions(-) create mode 100644 engine/src/test/java/com/ibm/engine/detection/MethodMatcherBehaviorTest.java create mode 100644 go/src/test/java/com/ibm/plugin/CryptoGoSensorTest.java diff --git a/engine/src/main/java/com/ibm/engine/detection/MethodMatcher.java b/engine/src/main/java/com/ibm/engine/detection/MethodMatcher.java index dc59a5884..3e216da0d 100644 --- a/engine/src/main/java/com/ibm/engine/detection/MethodMatcher.java +++ b/engine/src/main/java/com/ibm/engine/detection/MethodMatcher.java @@ -21,227 +21,143 @@ import com.ibm.engine.language.ILanguageTranslation; import java.util.Arrays; +import java.util.Iterator; import java.util.List; -import java.util.Optional; -import java.util.function.Function; -import java.util.function.Predicate; import javax.annotation.Nonnull; -import org.sonarsource.analyzer.commons.collections.SetUtils; +/** Matches a call signature against the owner, name and argument choices in a detection rule. */ public final class MethodMatcher { public static final String ANY = "*"; - @Nonnull private final Predicate invokedObjectTypeString; - @Nonnull private final Predicate methodName; - @Nonnull private final Predicate> parameterTypes; + private final boolean anyOwner; + private final boolean anyName; + private final boolean anyArguments; + private final String[] argumentSignature; - /* - * The following attributes are only used for serializing the MethodMatcher class (see - * `MethodMatcherSerializer.java`). This is indeed required to then obtain a graph representation - * of the detection rules. One can access their value through the public getter functions below. - */ + // Keep the rule's original values available to MethodMatcherSerializer. @Nonnull private final List invokedObjectTypeStringsSerializable; @Nonnull private final List methodNamesSerializable; @Nonnull private final List parameterTypesSerializable; public MethodMatcher( - @Nonnull String invokedObjectTypeString, - @Nonnull String methodName, - @Nonnull List parameterTypes) { - - this.invokedObjectTypeStringsSerializable = List.of(invokedObjectTypeString); - this.methodNamesSerializable = List.of(methodName); - this.parameterTypesSerializable = parameterTypes; - - this.invokedObjectTypeString = - createPredicate(invokedObjectTypeString, (type1 -> (iType -> iType.is(type1)))); - - this.methodName = createPredicate(methodName, (methodName1 -> (methodName1::equals))); - - List> types = - parameterTypes.stream() - .>map( - parameterType -> - substituteAny( - type -> type.is(parameterType), parameterType)) - .toList(); - this.parameterTypes = - (List actualTypes) -> exactMatchesParameters(types, actualTypes); + @Nonnull String owner, @Nonnull String name, @Nonnull List arguments) { + this(List.of(owner), List.of(name), arguments, false); } public MethodMatcher( - @Nonnull String[] invokedObjectTypeStrings, - @Nonnull String[] methodNames, - @Nonnull List parameterTypes) { - - this.invokedObjectTypeStringsSerializable = Arrays.asList(invokedObjectTypeStrings); - this.methodNamesSerializable = Arrays.asList(methodNames); - this.parameterTypesSerializable = parameterTypes; - - this.invokedObjectTypeString = - createPredicate( - invokedObjectTypeStrings, - types -> (type -> types.stream().anyMatch(type::is))); - - this.methodName = - createPredicate( - methodNames, names -> (name -> names.stream().anyMatch(name::equals))); - - List> types = - parameterTypes.stream() - .>map( - parameterType -> - substituteAny( - type -> type.is(parameterType), parameterType)) - .toList(); - this.parameterTypes = - (List actualTypes) -> exactMatchesParameters(types, actualTypes); - } - - public MethodMatcher( - @Nonnull String[] invokedObjectTypeStrings, @Nonnull String[] methodNames) { - - this.invokedObjectTypeStringsSerializable = Arrays.asList(invokedObjectTypeStrings); - this.methodNamesSerializable = Arrays.asList(methodNames); - this.parameterTypesSerializable = List.of(); - - this.invokedObjectTypeString = - createPredicate( - invokedObjectTypeStrings, - types -> (type -> types.stream().anyMatch(type::is))); - - this.methodName = - createPredicate( - methodNames, names -> (name -> names.stream().anyMatch(name::equals))); - this.parameterTypes = (List actualTypes) -> true; + @Nonnull String[] owners, @Nonnull String[] names, @Nonnull List arguments) { + this(Arrays.asList(owners), Arrays.asList(names), arguments, false); } - private static Predicate substituteAny(Predicate predicate, String... elements) { - if (SetUtils.immutableSetOf(elements).contains(ANY)) { - if (elements.length > 1) { - throw new IllegalStateException( - "Incompatible MethodMatchers.ANY with other predicates."); - } - return e -> true; - } - return predicate; + public MethodMatcher(@Nonnull String[] owners, @Nonnull String[] names) { + this(Arrays.asList(owners), Arrays.asList(names), List.of(), true); } - private static Predicate createPredicate( - @Nonnull String element, - @Nonnull Function> singleElementPredicate) { - return substituteAny(singleElementPredicate.apply(element), element); + private MethodMatcher( + List owners, List names, List arguments, boolean anyArguments) { + anyOwner = acceptsEveryChoice(owners); + anyName = acceptsEveryChoice(names); + this.anyArguments = anyArguments; + argumentSignature = arguments.toArray(String[]::new); + invokedObjectTypeStringsSerializable = owners; + methodNamesSerializable = names; + parameterTypesSerializable = arguments; } - private static Predicate createPredicate( - @Nonnull String[] elements, - @Nonnull Function, Predicate> multiElementsPredicate) { - List multiElements = Arrays.asList(elements); - return substituteAny(multiElementsPredicate.apply(multiElements), elements); - } - - private boolean exactMatchesParameters( - @Nonnull List> expectedTypes, @Nonnull List actualTypes) { - return actualTypes.size() == expectedTypes.size() - && matchesParameters(expectedTypes, actualTypes); - } - - private boolean matchesParameters( - @Nonnull List> expectedTypes, @Nonnull List actualTypes) { - for (int i = 0; i < expectedTypes.size(); i++) { - if (!expectedTypes.get(i).test(actualTypes.get(i))) { - return false; + private static boolean acceptsEveryChoice(List choices) { + for (String choice : choices) { + if (ANY.equals(choice)) { + if (choices.size() != 1) { + throw new IllegalStateException( + "A wildcard must be the only owner or method name choice."); + } + return true; } } - return true; + return false; } public boolean match( @Nonnull T expression, @Nonnull ILanguageTranslation translation, - @Nonnull MatchContext matchContext) { - Optional invokedObjectType = - translation.getInvokedObjectTypeString(matchContext, expression); - Optional invokedMethodName = translation.getMethodName(matchContext, expression); - List param = translation.getMethodParameterTypes(matchContext, expression); - - if (invokedObjectType.isEmpty() || invokedMethodName.isEmpty()) { - return false; - } - - return matchKeysInternal( - invokedObjectType.get(), - invokedMethodName.get(), - param, - translation.supportsSubsetParameterMatching()); + @Nonnull MatchContext context) { + var owner = translation.getInvokedObjectTypeString(context, expression); + var name = translation.getMethodName(context, expression); + var arguments = translation.getMethodParameterTypes(context, expression); + return owner.isPresent() + && name.isPresent() + && acceptsCall( + owner.get(), + name.get(), + arguments, + translation.supportsSubsetParameterMatching()); } /** - * Tree-free equivalent of {@link #match}: matches against the invoked-object type, method name - * and parameter types extracted at record time, so a detached recorded call can be matched - * without retaining its AST. Subset (constructor) parameter matching is not applicable to - * detached calls, so it is disabled here. + * Matches a detached call, which has an ordered argument list rather than constructor fields. */ public boolean matchKeys( - @Nonnull IType invokedObjectType, - @Nonnull String methodName, - @Nonnull List parameterTypes) { - return matchKeysInternal(invokedObjectType, methodName, parameterTypes, false); + @Nonnull IType owner, @Nonnull String name, @Nonnull List arguments) { + return acceptsCall(owner, name, arguments, false); } - private boolean matchKeysInternal( - @Nonnull IType invokedObjectType, - @Nonnull String invokedMethodName, - @Nonnull List param, - boolean supportsSubsetParameterMatching) { - boolean typeMatches = this.invokedObjectTypeString.test(invokedObjectType); - boolean nameMatches = this.methodName.test(invokedMethodName); - - if (!typeMatches || !nameMatches) { + private boolean acceptsCall( + IType owner, String name, List arguments, boolean subsetConstructors) { + if (!acceptsOwner(owner) || !(anyName || methodNamesSerializable.contains(name))) { return false; } - - // For languages supporting subset parameter matching (e.g., Go composite literals), - // the rule matches if at least one expected parameter exists in the actual fields. - if (invokedMethodName.equals("") - && !parameterTypesSerializable.isEmpty() - && supportsSubsetParameterMatching) { - return anyParameterMatches(param); + if (subsetConstructors && "".equals(name) && !parameterTypesSerializable.isEmpty()) { + return acceptsConstructorFields(arguments); } - - return this.parameterTypes.test(param); + if (anyArguments) { + return true; + } + if (arguments.size() != argumentSignature.length) { + return false; + } + Iterator supplied = arguments.iterator(); + for (String expected : argumentSignature) { + IType actual = supplied.next(); + if (!ANY.equals(expected) && !actual.is(expected)) { + return false; + } + } + return true; } - /** - * Checks that at least one expected parameter type exists in the actual parameter types. This - * supports named parameter matching (e.g., Go struct field names in composite literals) where - * the composite literal may only specify a subset of the fields the rule can detect. - */ - private boolean anyParameterMatches(@Nonnull List actualTypes) { - for (String expectedType : parameterTypesSerializable) { - if (ANY.equals(expectedType)) { - return true; - } - boolean found = actualTypes.stream().anyMatch(actual -> actual.is(expectedType)); - if (found) { + private boolean acceptsOwner(IType actual) { + if (anyOwner) { + return true; + } + for (String candidate : invokedObjectTypeStringsSerializable) { + if (actual.is(candidate)) { return true; } } return false; } + private boolean acceptsConstructorFields(List supplied) { + return parameterTypesSerializable.stream() + .anyMatch( + expected -> + ANY.equals(expected) + || supplied.stream() + .anyMatch(actual -> actual.is(expected))); + } + @Nonnull public List getInvokedObjectTypeStringsSerializable() { - return this.invokedObjectTypeStringsSerializable; + return invokedObjectTypeStringsSerializable; } @Nonnull public List getMethodNamesSerializable() { - return this.methodNamesSerializable; + return methodNamesSerializable; } @Nonnull public List getParameterTypesSerializable() { - return this.parameterTypesSerializable; + return parameterTypesSerializable; } } diff --git a/engine/src/test/java/com/ibm/engine/detection/MethodMatcherBehaviorTest.java b/engine/src/test/java/com/ibm/engine/detection/MethodMatcherBehaviorTest.java new file mode 100644 index 000000000..6b705c1f0 --- /dev/null +++ b/engine/src/test/java/com/ibm/engine/detection/MethodMatcherBehaviorTest.java @@ -0,0 +1,218 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2024 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.engine.detection; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.ibm.engine.language.ILanguageTranslation; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class MethodMatcherBehaviorTest { + private static IType type(String name) { + return name::equals; + } + + @Test + void matchesAlternativeOwnersAndNames() { + var matcher = + new MethodMatcher( + new String[] {"Cipher", "Mac"}, + new String[] {"create", "getInstance"}, + List.of("String")); + for (String owner : List.of("Cipher", "Mac")) { + for (String name : List.of("create", "getInstance")) { + assertThat(matcher.matchKeys(type(owner), name, List.of(type("String")))).isTrue(); + } + } + assertThat(matcher.matchKeys(type("Other"), "create", List.of(type("String")))).isFalse(); + assertThat(matcher.matchKeys(type("Mac"), "other", List.of(type("String")))).isFalse(); + } + + @Test + void wildcardsMatchAnyOwnerNameAndOneParameter() { + var matcher = new MethodMatcher("*", "*", List.of("*")); + assertThat(matcher.matchKeys(type("Anything"), "anything", List.of(type("Unknown")))) + .isTrue(); + assertThat(matcher.matchKeys(type("Anything"), "anything", List.of())).isFalse(); + assertThat(matcher.matchKeys(type("Anything"), "anything", List.of(type("A"), type("B")))) + .isFalse(); + } + + @Test + void parameterWildcardsCanBeRepeatedAndMixedWithConcreteTypes() { + var matcher = new MethodMatcher("Cipher", "create", List.of("*", "String", "*")); + assertThat( + matcher.matchKeys( + type("Cipher"), + "create", + List.of(type("A"), type("String"), type("B")))) + .isTrue(); + assertThat( + matcher.matchKeys( + type("Cipher"), + "create", + List.of(type("A"), type("int"), type("B")))) + .isFalse(); + } + + @Test + void parametersMustMatchInOrderAndWithExactArity() { + var matcher = new MethodMatcher("Cipher", "create", List.of("String", "int")); + assertThat( + matcher.matchKeys( + type("Cipher"), "create", List.of(type("String"), type("int")))) + .isTrue(); + assertThat( + matcher.matchKeys( + type("Cipher"), "create", List.of(type("int"), type("String")))) + .isFalse(); + assertThat(matcher.matchKeys(type("Cipher"), "create", List.of(type("String")))).isFalse(); + assertThat( + matcher.matchKeys( + type("Cipher"), + "create", + List.of(type("String"), type("int"), type("long")))) + .isFalse(); + } + + @Test + void distinguishesUnrestrictedParametersFromAnExplicitEmptyList() { + var unrestricted = new MethodMatcher(new String[] {"*"}, new String[] {"*"}); + var noArguments = new MethodMatcher("*", "*", List.of()); + assertThat(unrestricted.matchKeys(type("A"), "m", List.of(type("int")))).isTrue(); + assertThat(unrestricted.matchKeys(type("A"), "m", List.of())).isTrue(); + assertThat(noArguments.matchKeys(type("A"), "m", List.of())).isTrue(); + assertThat(noArguments.matchKeys(type("A"), "m", List.of(type("int")))).isFalse(); + } + + @Test + void rejectsWildcardsCombinedWithOwnerOrNameAlternatives() { + for (String[] choices : + List.of( + new String[] {"*", "Cipher"}, + new String[] {"Cipher", "*"}, + new String[] {"*", "*"})) { + assertThatThrownBy(() -> new MethodMatcher(choices, new String[] {"create"})) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy( + () -> + new MethodMatcher( + new String[] {"Cipher"}, choices, List.of())) + .isInstanceOf(IllegalStateException.class); + } + } + + @Test + void emptyOwnerOrNameAlternativesDoNotMatch() { + assertThat( + new MethodMatcher(new String[] {}, new String[] {"*"}) + .matchKeys(type("Cipher"), "create", List.of())) + .isFalse(); + assertThat( + new MethodMatcher(new String[] {"*"}, new String[] {}) + .matchKeys(type("Cipher"), "create", List.of())) + .isFalse(); + } + + @Test + void preservesSerializableValuesAndParameterSnapshot() { + var parameters = new ArrayList<>(List.of("String")); + var matcher = + new MethodMatcher( + new String[] {"Cipher", "Mac"}, new String[] {"create"}, parameters); + assertThat(matcher.getInvokedObjectTypeStringsSerializable()) + .containsExactly("Cipher", "Mac"); + assertThat(matcher.getMethodNamesSerializable()).containsExactly("create"); + assertThat(matcher.getParameterTypesSerializable()).containsExactly("String"); + parameters.set(0, "int"); + assertThat(matcher.matchKeys(type("Cipher"), "create", List.of(type("String")))).isTrue(); + } + + @Test + void subsetMatchingOnlyAppliesToTranslatedConstructors() { + var matcher = new MethodMatcher("Cipher", "", List.of("Key", "IV")); + var translation = translation("", List.of(type("Other"), type("IV")), true); + assertThat(matcher.match(this, translation, MatchContext.createForHookContext())).isTrue(); + assertThat(matcher.matchKeys(type("Cipher"), "", List.of(type("Other"), type("IV")))) + .isFalse(); + assertThat( + matcher.match( + this, + translation("", List.of(type("Other")), true), + MatchContext.createForHookContext())) + .isFalse(); + assertThat( + matcher.match( + this, + translation("", List.of(type("IV")), false), + MatchContext.createForHookContext())) + .isFalse(); + var regular = new MethodMatcher("Cipher", "create", List.of("Key", "IV")); + assertThat( + regular.match( + this, + translation("create", List.of(type("IV")), true), + MatchContext.createForHookContext())) + .isFalse(); + } + + @Test + void constructorSubsetWildcardAlsoMatchesAnEmptyFieldList() { + var matcher = new MethodMatcher("Cipher", "", List.of("*", "IV")); + assertThat( + matcher.match( + this, + translation("", List.of(), true), + MatchContext.createForHookContext())) + .isTrue(); + } + + @Test + void unresolvedOwnerOrMethodNeverMatchesEvenWithWildcards() { + var matcher = new MethodMatcher("*", "*", List.of()); + var translation = translation("create", List.of(), false); + when(translation.getInvokedObjectTypeString(MatchContext.createForHookContext(), this)) + .thenReturn(Optional.empty()); + assertThat(matcher.match(this, translation, MatchContext.createForHookContext())).isFalse(); + translation = translation("create", List.of(), false); + when(translation.getMethodName(MatchContext.createForHookContext(), this)) + .thenReturn(Optional.empty()); + assertThat(matcher.match(this, translation, MatchContext.createForHookContext())).isFalse(); + } + + @SuppressWarnings("unchecked") + private ILanguageTranslation translation( + String name, List parameters, boolean subset) { + ILanguageTranslation translation = mock(ILanguageTranslation.class); + var context = MatchContext.createForHookContext(); + when(translation.getInvokedObjectTypeString(context, this)) + .thenReturn(Optional.of(type("Cipher"))); + when(translation.getMethodName(context, this)).thenReturn(Optional.of(name)); + when(translation.getMethodParameterTypes(context, this)).thenReturn(parameters); + when(translation.supportsSubsetParameterMatching()).thenReturn(subset); + return translation; + } +} diff --git a/go/src/main/java/com/ibm/plugin/CryptoGoSensor.java b/go/src/main/java/com/ibm/plugin/CryptoGoSensor.java index 9bb0c1c16..0d4a8f86a 100644 --- a/go/src/main/java/com/ibm/plugin/CryptoGoSensor.java +++ b/go/src/main/java/com/ibm/plugin/CryptoGoSensor.java @@ -20,18 +20,15 @@ package com.ibm.plugin; import java.io.IOException; -import java.util.Collection; -import java.util.HashMap; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; -import java.util.stream.Collectors; -import java.util.stream.StreamSupport; import javax.annotation.Nonnull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.sonar.api.batch.fs.FileSystem; import org.sonar.api.batch.fs.InputFile; import org.sonar.api.batch.rule.CheckFactory; import org.sonar.api.batch.sensor.Sensor; @@ -52,31 +49,16 @@ import org.sonar.go.report.GoProgressReport; import org.sonar.go.visitors.TreeVisitor; import org.sonar.plugins.go.api.ASTConverter; -import org.sonar.plugins.go.api.Tree; import org.sonar.plugins.go.api.TreeOrError; -import org.sonar.plugins.go.api.checks.GoCheck; /** - * Custom sensor for executing cryptography detection rules on Go source files. Implements Sensor - * directly (rather than extending SlangSensor) to avoid classloader conflicts with the sonar-go - * plugin and to avoid duplicate metric computation. - * - *

The sonar-go plugin does not expose a custom rule repository interface (unlike Java's - * CheckRegistrar or Python's PythonCustomRuleRepository), so this sensor handles parsing and check - * execution independently. - * - *

This implementation is heavily inspired by: - * - *

+ * Runs cryptography checks on Go packages through the analyzer's converter and visitor APIs. + * Package parsing keeps cross-file type information available to our checks. The official Go sensor + * remains responsible for metrics, highlighting and its own rule repositories. */ public class CryptoGoSensor implements Sensor { - private static final Logger LOG = LoggerFactory.getLogger(CryptoGoSensor.class); + private static final Pattern BLANK_SOURCE = Pattern.compile("[ \\t\\r\\n\\f\\x0B]*"); protected DurationStatistics durationStatistics; protected MemoryMonitor memoryMonitor; @@ -85,241 +67,225 @@ public class CryptoGoSensor implements Sensor { private final GoConverter goConverter; public CryptoGoSensor(@Nonnull CheckFactory checkFactory, @Nonnull TempFolder tempFolder) { - this.checks = - new GoChecks(checkFactory) - .addChecks(GoScannerRuleDefinition.REPOSITORY_KEY, GoRuleList.getChecks()); - this.goConverter = new GoConverter(tempFolder.newDir()); + goConverter = new GoConverter(tempFolder.newDir()); + checks = new GoChecks(checkFactory); + checks.addChecks(GoScannerRuleDefinition.REPOSITORY_KEY, GoRuleList.getChecks()); } @Override public void describe(@Nonnull SensorDescriptor descriptor) { - descriptor.onlyOnLanguage("go").name("Cryptography for Go"); + descriptor.name("Cryptography for Go").onlyOnLanguage("go"); } @Override public void execute(@Nonnull SensorContext context) { - execute(context, this.goConverter, this.checks); + execute(context, goConverter, checks); } public static void execute( @Nonnull SensorContext context, GoConverter goConverter, GoChecks checks) { - DurationStatistics durationStatistics = new DurationStatistics(context.config()); - - Collection activeChecks = checks.all(); - if (activeChecks.isEmpty()) { + if (checks.all().isEmpty()) { return; } - - // Find and group Go files by directory (Go packages span directories) - FileSystem fs = context.fileSystem(); - List inputFiles = - StreamSupport.stream( - fs.inputFiles( - fs.predicates() - .and( - fs.predicates().hasLanguage("go"), - fs.predicates() - .hasType( - InputFile.Type - .MAIN))) - .spliterator(), - false) - .toList(); - - var goProgressReport = + var statistics = new DurationStatistics(context.config()); + var fs = context.fileSystem(); + var selection = fs.predicates(); + List files = new ArrayList<>(); + fs.inputFiles( + selection.and( + selection.hasType(InputFile.Type.MAIN), + selection.hasLanguage("go"))) + .forEach(files::add); + var folders = groupFilesByDirectory(files); + ASTConverter converter = ASTConverterValidation.wrap(goConverter, context.config()); + var progress = new GoProgressReport( "Progress of the Golang analysis", TimeUnit.SECONDS.toMillis(10)); - - var converter = ASTConverterValidation.wrap(goConverter, context.config()); - var goModFileDataStore = new GoModFileAnalyzer(context).analyzeGoModFiles(); - - boolean success = false; + boolean started = false; + boolean completed = false; try { - // Only run ChecksVisitor with our crypto checks. - // Other visitors (SymbolVisitor, CpdVisitor, SyntaxHighlighter, - // IssueSuppressionVisitor) - // are handled by the official sonar-go sensor and would cause NoSuchMethodError due to - // shading conflicts between internal types and API types. + progress.start(folders); + started = true; + var modules = new GoModFileAnalyzer(context).analyzeGoModFiles(); List> visitors = - List.of(new ChecksVisitor(checks, durationStatistics, goModFileDataStore)); - success = - CryptoGoSensor.analyseFiles( - converter, - context, - inputFiles, - goProgressReport, - visitors, - durationStatistics, - goModFileDataStore); + List.of(new ChecksVisitor(checks, statistics, modules)); + completed = + new PackageAnalysis(context, converter, visitors, statistics, progress) + .run(folders, modules); } finally { - if (success) { - goProgressReport.stop(); - } else { - goProgressReport.cancel(); - } - converter.terminate(); - } - } - - static boolean analyseFiles( - ASTConverter converter, - @Nonnull SensorContext sensorContext, - @Nonnull List inputFiles, - GoProgressReport goProgressReport, - List> visitors, - DurationStatistics statistics, - GoModFileDataStore goModFileDataStore) { - var filesByDirectory = groupFilesByDirectory(inputFiles); - goProgressReport.start(filesByDirectory); - - for (var goFolder : filesByDirectory) { - if (sensorContext.isCancelled()) { - return false; - } - - var filesToAnalyse = - goFolder.files().stream() - .map(inputFile -> new InputFileContext(sensorContext, inputFile)) - .toList(); - - var moduleName = - goModFileDataStore.retrieveClosestGoModFileData(goFolder.name()).moduleName(); - LOG.debug( - "Parse directory '{}', number of files: {}, nodule name: '{}'", - goFolder.name(), - filesToAnalyse.size(), - moduleName); - try { - analyseDirectory( - converter, - filesToAnalyse, - visitors, - goProgressReport, - statistics, - sensorContext, - moduleName); - } catch (RuntimeException | IOException e) { - LOG.warn("Unable to parse directory '{}'.", goFolder.name(), e); - if (GoSensor.isFailFast(sensorContext)) { - throw new RuntimeException(e); + if (started) { + if (completed) { + progress.stop(); + } else { + progress.cancel(); + } } + } finally { + converter.terminate(); } - goProgressReport.nextFolder(); } - return true; } @Nonnull - static List groupFilesByDirectory(@Nonnull List inputFiles) { - Map> filesByDirectory = - inputFiles.stream() - .collect( - Collectors.groupingBy( - (InputFile inputFile) -> { - var path = inputFile.uri().getPath(); - int lastSeparatorIndex = path.lastIndexOf("/"); - if (lastSeparatorIndex == -1) { - return ""; - } - return path.substring(0, lastSeparatorIndex); - })); + static List groupFilesByDirectory(@Nonnull List files) { + Map> packages = new LinkedHashMap<>(); + for (InputFile file : files) { + // URI paths are slash-separated on every scanner platform. + String parent = file.uri().getPath().replaceFirst("[^/]*$", "").replaceFirst("/$", ""); + packages.computeIfAbsent(parent, unused -> new ArrayList<>()).add(file); + } + List folders = new ArrayList<>(packages.size()); + packages.forEach((parent, members) -> folders.add(new GoFolder(parent, members))); + return folders; + } - return filesByDirectory.entrySet().stream() - .map(entry -> new GoFolder(entry.getKey(), entry.getValue())) - .toList(); + static boolean analyseFiles( + ASTConverter converter, + @Nonnull SensorContext context, + @Nonnull List files, + GoProgressReport progress, + List> visitors, + DurationStatistics statistics, + GoModFileDataStore modules) { + var folders = groupFilesByDirectory(files); + progress.start(folders); + return new PackageAnalysis(context, converter, visitors, statistics, progress) + .run(folders, modules); } static void analyseDirectory( ASTConverter converter, - List inputFileContextList, + List files, List> visitors, - @Nonnull GoProgressReport goProgressReport, + @Nonnull GoProgressReport progress, DurationStatistics statistics, - SensorContext sensorContext, - String moduleName) + SensorContext context, + String module) throws IOException { + new PackageAnalysis(context, converter, visitors, statistics, progress) + .parsePackage(files, module); + } - final Pattern emptyFilePattern = Pattern.compile("\\s*+"); - Map filenameToContext = new HashMap<>(); - Map filenameToContentMap = new HashMap<>(); - - for (InputFileContext inputFileContext : inputFileContextList) { - String fileName = inputFileContext.inputFile.toString(); - String content = inputFileContext.inputFile.contents(); - if (!emptyFilePattern.matcher(content).matches()) { - filenameToContext.put(fileName, inputFileContext); - filenameToContentMap.put(fileName, content); - } - } - - if (filenameToContentMap.isEmpty()) { - return; - } + private record SourceFile(InputFileContext context, String content) {} - goProgressReport.setStep(GoProgressReport.Step.PARSING); - Map treeOrErrorMap = converter.parse(filenameToContentMap, moduleName); + private record ParsedFile(InputFileContext context, TreeOrError result) {} - goProgressReport.setStep(GoProgressReport.Step.HANDLING_PARSE_ERRORS); - handleParsingErrors(sensorContext, treeOrErrorMap, filenameToContext); + private static final class PackageAnalysis { + private final SensorContext context; + private final ASTConverter converter; + private final List> visitors; + private final DurationStatistics statistics; + private final GoProgressReport progress; - goProgressReport.setStep(GoProgressReport.Step.ANALYZING); - visitTrees(visitors, statistics, treeOrErrorMap, filenameToContext); - } + private PackageAnalysis( + SensorContext context, + ASTConverter converter, + List> visitors, + DurationStatistics statistics, + GoProgressReport progress) { + this.context = context; + this.converter = converter; + this.visitors = visitors; + this.statistics = statistics; + this.progress = progress; + } - private static void handleParsingErrors( - SensorContext sensorContext, - Map treeOrErrorMap, - Map filenameToContext) { - var isAnyError = false; - for (Map.Entry filenameToTree : treeOrErrorMap.entrySet()) { - var treeOrError = filenameToTree.getValue(); - if (treeOrError.isError() && treeOrError.error() != null) { - isAnyError = true; - String fileName = filenameToTree.getKey(); - LOG.warn("Unable to parse file: {}. {}", fileName, treeOrError.error()); - filenameToContext - .get(fileName) - .reportAnalysisParseError( - GoScannerRuleDefinition.REPOSITORY_KEY, treeOrError.error()); + private boolean run(List folders, GoModFileDataStore modules) { + for (GoFolder folder : folders) { + if (context.isCancelled()) { + return false; + } + String module = modules.retrieveClosestGoModFileData(folder.name()).moduleName(); + LOG.debug( + "Cryptography package '{}': {} files, module '{}'", + folder.name(), + folder.files().size(), + module); + try { + List inputs = new ArrayList<>(folder.files().size()); + folder.files().forEach(file -> inputs.add(new InputFileContext(context, file))); + parsePackage(inputs, module); + } catch (IOException | RuntimeException failure) { + LOG.warn( + "Cryptography analysis failed for package '{}'.", + folder.name(), + failure); + if (GoSensor.isFailFast(context)) { + throw new RuntimeException(failure); + } + } + progress.nextFolder(); } + return true; } - if (isAnyError && GoSensor.isFailFast(sensorContext)) { - throw new IllegalStateException( - "Exception when analyzing files. See logs above for details."); - } - } - private static void visitTrees( - List> visitors, - DurationStatistics statistics, - Map treeOrErrorMap, - Map filenameToContext) { - for (Map.Entry filenameToTree : treeOrErrorMap.entrySet()) { - var treeOrError = filenameToTree.getValue(); - if (treeOrError.isTree()) { - var filename = filenameToTree.getKey(); - var inputFileContext = filenameToContext.get(filename); - visitTree(visitors, statistics, inputFileContext, treeOrError.tree()); + private void parsePackage(List inputs, String module) throws IOException { + Map sources = new LinkedHashMap<>(); + for (InputFileContext input : inputs) { + String text = input.inputFile.contents(); + if (BLANK_SOURCE.matcher(text).matches()) { + continue; + } + sources.put(input.inputFile.toString(), new SourceFile(input, text)); } + if (sources.isEmpty()) { + return; + } + Map request = new LinkedHashMap<>(); + sources.forEach((name, source) -> request.put(name, source.content())); + progress.setStep(GoProgressReport.Step.PARSING); + List parsed = + converter.parse(request, module).entrySet().stream() + .map( + entry -> + new ParsedFile( + sources.get(entry.getKey()).context(), + entry.getValue())) + .toList(); + progress.setStep(GoProgressReport.Step.HANDLING_PARSE_ERRORS); + reportSyntaxErrors(parsed); + progress.setStep(GoProgressReport.Step.ANALYZING); + parsed.stream().filter(file -> file.result().isTree()).forEach(this::runChecks); } - } - private static void visitTree( - List> visitors, - DurationStatistics statistics, - InputFileContext inputFileContext, - Tree tree) { - for (TreeVisitor visitor : visitors) { - try { - String visitorId = visitor.getClass().getSimpleName(); - statistics.time(visitorId, () -> visitor.scan(inputFileContext, tree)); - } catch (RuntimeException e) { - inputFileContext.reportAnalysisError(e.getMessage(), null); - var message = - "Cannot analyse '" + inputFileContext.inputFile + "': " + e.getMessage(); - LOG.warn(message, e); + private void reportSyntaxErrors(List parsed) { + List failures = + parsed.stream() + .filter( + file -> + file.result().isError() + && file.result().error() != null) + .toList(); + failures.forEach( + file -> { + String detail = file.result().error(); + LOG.warn("Go syntax error in '{}': {}", file.context().inputFile, detail); + file.context() + .reportAnalysisParseError( + GoScannerRuleDefinition.REPOSITORY_KEY, detail); + }); + if (!failures.isEmpty() && GoSensor.isFailFast(context)) { + throw new IllegalStateException( + "Cryptography analysis stopped after Go syntax errors."); } } + + private void runChecks(ParsedFile file) { + visitors.forEach( + visitor -> { + try { + statistics.time( + visitor.getClass().getSimpleName(), + () -> visitor.scan(file.context(), file.result().tree())); + } catch (RuntimeException failure) { + file.context().reportAnalysisError(failure.getMessage(), null); + LOG.warn( + "Cryptography check failed in '{}'.", + file.context().inputFile, + failure); + } + }); + } } } diff --git a/go/src/test/java/com/ibm/plugin/CryptoGoSensorTest.java b/go/src/test/java/com/ibm/plugin/CryptoGoSensorTest.java new file mode 100644 index 000000000..b0352660e --- /dev/null +++ b/go/src/test/java/com/ibm/plugin/CryptoGoSensorTest.java @@ -0,0 +1,324 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2024 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.sonar.api.batch.fs.InputFile; +import org.sonar.api.batch.fs.internal.TestInputFileBuilder; +import org.sonar.api.batch.sensor.internal.SensorContextTester; +import org.sonar.go.converter.GoConverter; +import org.sonar.go.plugin.DurationStatistics; +import org.sonar.go.plugin.GoChecks; +import org.sonar.go.plugin.GoModFileDataStore; +import org.sonar.go.plugin.GoSensor; +import org.sonar.go.plugin.InputFileContext; +import org.sonar.go.report.GoProgressReport; +import org.sonar.go.visitors.TreeVisitor; +import org.sonar.plugins.go.api.ASTConverter; +import org.sonar.plugins.go.api.Tree; +import org.sonar.plugins.go.api.TreeOrError; +import org.sonar.plugins.go.api.checks.GoCheck; +import org.sonar.plugins.go.api.checks.GoModFileData; + +class CryptoGoSensorTest { + @TempDir Path directory; + private SensorContextTester context; + private final ASTConverter converter = mock(ASTConverter.class); + private final GoProgressReport progress = mock(GoProgressReport.class); + private DurationStatistics statistics; + + @BeforeEach + void setUp() { + context = SensorContextTester.create(directory); + statistics = new DurationStatistics(context.config()); + } + + private InputFile file(String name, String content) { + var file = + new TestInputFileBuilder("test", name) + .setModuleBaseDir(directory) + .setProjectBaseDir(directory) + .setLanguage("go") + .setType(InputFile.Type.MAIN) + .setContents(content) + .build(); + context.fileSystem().add(file); + return file; + } + + @Test + void groupsPackageFilesTogetherAndPreservesOrderWithinEachPackage() { + var first = file("pkg/first.go", "package pkg"); + var second = file("pkg/second.go", "package pkg"); + var other = file("other/third.go", "package other"); + var folders = CryptoGoSensor.groupFilesByDirectory(List.of(first, other, second)); + assertThat(folders).hasSize(2); + assertThat( + folders.stream() + .filter(f -> f.name().equals(directory.resolve("pkg").toString())) + .findFirst() + .orElseThrow() + .files()) + .containsExactly(first, second); + } + + @Test + void parsesWholePackageWithModuleNameButOmitsWhitespaceOnlyFiles() throws IOException { + var first = file("pkg/first.go", "package pkg"); + var second = file("pkg/second.go", "package pkg\nvar key = 1"); + var blank = file("pkg/blank.go", " \t\r\n\f\u000b"); + when(converter.parse(any(), anyString())).thenReturn(Map.of()); + analyse(List.of(first, second, blank), List.of(), "example.org/pkg"); + verify(converter) + .parse( + Map.of( + first.toString(), + first.contents(), + second.toString(), + second.contents()), + "example.org/pkg"); + } + + @Test + void skipsConverterForEmptyPackages() throws IOException { + analyse(List.of(file("blank.go", " \n\t")), List.of(), "example.org/pkg"); + verifyNoInteractions(converter); + } + + @Test + void reportsParseErrorsAndStillVisitsSuccessfullyParsedFiles() throws IOException { + var good = file("pkg/good.go", "package pkg"); + var bad = file("pkg/bad.go", "broken"); + var tree = mock(Tree.class); + var visitor = visitor(); + when(converter.parse(any(), anyString())) + .thenReturn( + Map.of( + good.toString(), + TreeOrError.of(tree), + bad.toString(), + TreeOrError.of("syntax error"))); + analyse(List.of(good, bad), List.of(visitor), "example.org/pkg"); + assertThat(context.allAnalysisErrors()).hasSize(1); + verify(visitor).scan(any(InputFileContext.class), org.mockito.ArgumentMatchers.eq(tree)); + } + + @Test + void failFastReportsEveryParseErrorBeforeRejectingThePackage() { + context.settings().setProperty(GoSensor.FAIL_FAST_PROPERTY_NAME, true); + var first = file("pkg/first.go", "broken"); + var second = file("pkg/second.go", "broken"); + var good = file("pkg/good.go", "package pkg"); + var visitor = visitor(); + when(converter.parse(any(), anyString())) + .thenReturn( + Map.of( + first.toString(), + TreeOrError.of("first error"), + second.toString(), + TreeOrError.of("second error"), + good.toString(), + TreeOrError.of(mock(Tree.class)))); + assertThatThrownBy( + () -> + analyse( + List.of(first, second, good), + List.of(visitor), + "example.org/pkg")) + .isInstanceOf(IllegalStateException.class); + assertThat(context.allAnalysisErrors()).hasSize(2); + verifyNoInteractions(visitor); + } + + @Test + void visitorFailureIsReportedAndRemainingVisitorsStillRun() throws IOException { + var file = file("pkg/good.go", "package pkg"); + var tree = mock(Tree.class); + var failing = visitor(); + var succeeding = visitor(); + doThrow(new IllegalStateException("check failure")).when(failing).scan(any(), any()); + when(converter.parse(any(), anyString())) + .thenReturn(Map.of(file.toString(), TreeOrError.of(tree))); + analyse(List.of(file), List.of(failing, succeeding), "example.org/pkg"); + assertThat(context.allAnalysisErrors()).hasSize(1); + verify(succeeding).scan(any(), org.mockito.ArgumentMatchers.eq(tree)); + } + + @Test + void cancellationPreventsPackageParsing() { + context.setCancelled(true); + var input = file("pkg/source.go", "package pkg"); + assertThat( + CryptoGoSensor.analyseFiles( + converter, + context, + List.of(input), + progress, + List.of(), + statistics, + new GoModFileDataStore())) + .isFalse(); + verifyNoInteractions(converter); + verify(progress, never()).nextFolder(); + } + + @Test + void resolvesModuleNamesAndContinuesAfterPackageIoFailure() throws IOException { + var bad = mock(InputFile.class); + when(bad.uri()).thenReturn(directory.resolve("bad/source.go").toUri()); + when(bad.contents()).thenThrow(new IOException("unreadable")); + var good = file("good/source.go", "package good"); + var modules = mock(GoModFileDataStore.class); + when(modules.retrieveClosestGoModFileData(anyString())) + .thenReturn(new GoModFileData("example.org/project", null, List.of(), "go.mod")); + when(converter.parse(any(), anyString())).thenReturn(Map.of()); + assertThat( + CryptoGoSensor.analyseFiles( + converter, + context, + List.of(bad, good), + progress, + List.of(), + statistics, + modules)) + .isTrue(); + verify(converter).parse(Map.of(good.toString(), good.contents()), "example.org/project"); + verify(progress, org.mockito.Mockito.times(2)).nextFolder(); + } + + @Test + void converterIsNotStartedWhenNoChecksAreActive() { + var converter = mock(GoConverter.class); + var checks = mock(GoChecks.class); + when(checks.all()).thenReturn(List.of()); + CryptoGoSensor.execute(context, converter, checks); + verifyNoInteractions(converter); + } + + @Test + void converterIsTerminatedWhenFailFastAbortsAnalysis() { + context.settings().setProperty(GoSensor.FAIL_FAST_PROPERTY_NAME, true); + file("pkg/source.go", "package pkg"); + var converter = mock(GoConverter.class); + var checks = mock(GoChecks.class); + when(checks.all()).thenReturn(List.of(mock(GoCheck.class))); + when(checks.ruleKey(any())).thenReturn(org.sonar.api.rule.RuleKey.of("test", "crypto")); + when(converter.parse(any(), anyString())) + .thenThrow(new IllegalStateException("converter failure")); + assertThatThrownBy(() -> CryptoGoSensor.execute(context, converter, checks)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("converter failure"); + verify(converter).terminate(); + } + + @Test + void executionSelectsOnlyMainGoFilesAndTerminatesTheConverter() throws IOException { + var main = file("pkg/main.go", "package pkg"); + var test = + new TestInputFileBuilder("test", "pkg/main_test.go") + .setModuleBaseDir(directory) + .setProjectBaseDir(directory) + .setLanguage("go") + .setType(InputFile.Type.TEST) + .setContents("package pkg") + .build(); + var java = + new TestInputFileBuilder("test", "Other.java") + .setModuleBaseDir(directory) + .setProjectBaseDir(directory) + .setLanguage("java") + .setType(InputFile.Type.MAIN) + .setContents("class Other {}") + .build(); + context.fileSystem().add(test).add(java); + var nativeConverter = mock(GoConverter.class); + var checks = mock(GoChecks.class); + when(checks.all()).thenReturn(List.of(mock(GoCheck.class))); + when(checks.ruleKey(any())).thenReturn(org.sonar.api.rule.RuleKey.of("test", "crypto")); + when(nativeConverter.parse(any(), anyString())).thenReturn(Map.of()); + + CryptoGoSensor.execute(context, nativeConverter, checks); + + verify(nativeConverter) + .parse( + org.mockito.ArgumentMatchers.eq(Map.of(main.toString(), main.contents())), + anyString()); + verify(nativeConverter).terminate(); + } + + @Test + void cancellationAfterAParsedPackageSkipsTheRemainingPackages() { + var first = file("first/source.go", "package first"); + var second = file("second/source.go", "package second"); + when(converter.parse(any(), anyString())) + .thenAnswer( + invocation -> { + context.setCancelled(true); + return Map.of(); + }); + assertThat( + CryptoGoSensor.analyseFiles( + converter, + context, + List.of(first, second), + progress, + List.of(), + statistics, + new GoModFileDataStore())) + .isFalse(); + verify(converter).parse(any(), anyString()); + verify(progress).nextFolder(); + } + + private void analyse( + List files, List> visitors, String module) + throws IOException { + CryptoGoSensor.analyseDirectory( + converter, + files.stream().map(f -> new InputFileContext(context, f)).toList(), + visitors, + progress, + statistics, + context, + module); + } + + @SuppressWarnings("unchecked") + private TreeVisitor visitor() { + return mock(TreeVisitor.class); + } +}