diff --git a/docs/command-line-interface.rst b/docs/command-line-interface.rst index 216fae52d8..5200d1c52d 100644 --- a/docs/command-line-interface.rst +++ b/docs/command-line-interface.rst @@ -651,3 +651,35 @@ instead of the default JSON output. $ run scan_codebase codebase/ --format cyclonedx > bom.json See the :ref:`cli_output` for more information about supported output formats. + +`$ scanpipe verify-project --project PROJECT` +--------------------------------------------- + +Verifies the analysis results of a project against expected package and dependency +counts. +This command is designed to ensure that a project’s scan results meet specific +expectations — for example, that a minimum number of packages or dependencies were +discovered, and that no unexpected vulnerabilities were introduced. + +Optional arguments: + +- ``--packages`` Minimum number of discovered packages expected. + +- ``--vulnerable-packages`` Minimum number of vulnerable packages expected. + +- ``--dependencies`` Minimum number of discovered dependencies expected. + +- ``--vulnerable-dependencies`` Minimum number of vulnerable dependencies expected. + +If any of these expectations are not met, the command exits with a non-zero status +and prints a summary of all issues found. + +Example usage: + +.. code-block:: bash + + $ scanpipe verify-project --project my_project --packages 100 --dependencies 50 + +.. tip:: + This command is particularly useful for **CI/CD pipelines** that need to validate + SBOM or vulnerability scan results against known baselines. diff --git a/scanpipe/management/commands/verify-project.py b/scanpipe/management/commands/verify-project.py new file mode 100644 index 0000000000..941e04996e --- /dev/null +++ b/scanpipe/management/commands/verify-project.py @@ -0,0 +1,99 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# http://nexb.com and https://github.com/aboutcode-org/scancode.io +# The ScanCode.io software is licensed under the Apache License version 2.0. +# Data generated with ScanCode.io is provided as-is without warranties. +# ScanCode is a trademark of nexB Inc. +# +# You may not use this software except in compliance with the License. +# You may obtain a copy of the License at: http://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. +# +# Data Generated with ScanCode.io is provided on an "AS IS" BASIS, WITHOUT WARRANTIES +# OR CONDITIONS OF ANY KIND, either express or implied. No content created from +# ScanCode.io should be considered or used as legal advice. Consult an Attorney +# for any legal advice. +# +# ScanCode.io is a free software code scanning tool from nexB Inc. and others. +# Visit https://github.com/aboutcode-org/scancode.io for support and download. + +from django.core.management import CommandError + +from scanpipe.management.commands import ProjectCommand + + +class Command(ProjectCommand): + help = "Verify project analysis results against expected counts" + + def add_arguments(self, parser): + super().add_arguments(parser) + parser.add_argument( + "--packages", + type=int, + default=0, + help="Minimum number of packages expected (default: 0)", + ) + parser.add_argument( + "--vulnerable-packages", + type=int, + default=0, + help="Minimum number of vulnerable packages expected (default: 0)", + ) + parser.add_argument( + "--dependencies", + type=int, + default=0, + help="Minimum number of dependencies expected (default: 0)", + ) + parser.add_argument( + "--vulnerable-dependencies", + type=int, + default=0, + help="Minimum number of vulnerable dependencies expected (default: 0)", + ) + + def handle(self, *args, **options): + super().handle(*args, **options) + + expected_packages = options["packages"] + expected_vulnerable_packages = options["vulnerable_packages"] + expected_dependencies = options["dependencies"] + expected_vulnerable_dependencies = options["vulnerable_dependencies"] + + project = self.project + packages = project.discoveredpackages + package_count = packages.count() + vulnerable_package_count = packages.vulnerable().count() + dependencies = project.discovereddependencies.all() + dependency_count = dependencies.count() + vulnerable_dependency_count = dependencies.vulnerable().count() + + errors = [] + + if package_count < expected_packages: + errors.append( + f"Expected at least {expected_packages} packages, found {package_count}" + ) + if vulnerable_package_count < expected_vulnerable_packages: + errors.append( + f"Expected at least {expected_vulnerable_packages} vulnerable packages," + f" found {vulnerable_package_count}" + ) + if dependency_count < expected_dependencies: + errors.append( + f"Expected at least {expected_dependencies} dependencies, " + f"found {dependency_count}" + ) + if vulnerable_dependency_count < expected_vulnerable_dependencies: + errors.append( + f"Expected at least {expected_vulnerable_dependencies} " + f"vulnerable dependencies, found {vulnerable_dependency_count}" + ) + + if errors: + raise CommandError("Project verification failed:\n" + "\n".join(errors)) + + self.stdout.write("Project verification passed.", self.style.SUCCESS) diff --git a/scanpipe/tests/test_commands.py b/scanpipe/tests/test_commands.py index 1b2d6f688e..e2e9e09b70 100644 --- a/scanpipe/tests/test_commands.py +++ b/scanpipe/tests/test_commands.py @@ -1371,6 +1371,49 @@ def test_scanpipe_management_command_report(self): expected = ("project1", "file.ext", "file", "file.ext", "requires-review") self.assertEqual(expected, row1[0:5]) + def test_scanpipe_management_command_verify_project(self): + project = make_project(name="my_project") + make_package(project, package_url="pkg:generic/name@1.0") + make_dependency(project) + + out = StringIO() + call_command( + "verify-project", + "--project", + project.name, + "--packages", + "1", + "--vulnerable-packages", + "0", + "--dependencies", + "1", + "--vulnerable-dependencies", + "0", + stdout=out, + ) + self.assertIn("Project verification passed.", out.getvalue()) + + out = StringIO() + expected = ( + "Project verification failed:\n" + "Expected at least 5 packages, found 1\n" + "Expected at least 10 vulnerable packages, found 0\n" + "Expected at least 5 dependencies, found 1" + ) + with self.assertRaisesMessage(CommandError, expected): + call_command( + "verify-project", + "--project", + project.name, + "--packages", + "5", + "--vulnerable-packages", + "10", + "--dependencies", + "5", + stdout=out, + ) + class ScanPipeManagementCommandMixinTest(TestCase): class CreateProjectCommand(