Skip to content

Commit

Permalink
Merge pull request #774 from greenbone/dependency_graph
Browse files Browse the repository at this point in the history
Add: standalone plugin for evaluating dependencies with a graph
  • Loading branch information
amy-gb authored Mar 3, 2025
2 parents 787abc8 + cc8d77e commit a1a048b
Show file tree
Hide file tree
Showing 24 changed files with 760 additions and 79 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,4 @@ link.sh

# vts/nasl folder used for testing
nasl
!tests/standalone_plugins/nasl/
29 changes: 24 additions & 5 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ chardet = ">=4,<6"
validators = "^0.34.0"
gitpython = "^3.1.31"
charset-normalizer = "^3.2.0"
networkx = "^3.4.2"

[tool.poetry.group.dev.dependencies]
autohooks = ">=21.7.0"
Expand Down Expand Up @@ -81,6 +82,7 @@ troubadix-changed-cves = 'troubadix.standalone_plugins.changed_cves:main'
troubadix-allowed-rev-diff = 'troubadix.standalone_plugins.allowed_rev_diff:main'
troubadix-file-extensions = 'troubadix.standalone_plugins.file_extensions:main'
troubadix-deprecate-vts = 'troubadix.standalone_plugins.deprecate_vts:main'
troubadix-dependency-graph = 'troubadix.standalone_plugins.dependency_graph.dependency_graph:main'

[build-system]
requires = ["poetry-core>=1.0.0"]
Expand Down
6 changes: 6 additions & 0 deletions tests/standalone_plugins/nasl/21.04/21_script.nasl
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
if(description)
{
script_category(ACT_GATHER_INFO);
script_dependencies( "foo.nasl" );
exit(0);
}
6 changes: 6 additions & 0 deletions tests/standalone_plugins/nasl/22.04/22_script.nasl
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
if(description)
{
script_category(ACT_GATHER_INFO);
script_dependencies( "foo.nasl" );
exit(0);
}
10 changes: 10 additions & 0 deletions tests/standalone_plugins/nasl/common/bar.nasl
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
if(description)
{
script_category(ACT_GATHER_INFO);
script_dependencies( "foo.nasl", "foo.nasl" );

if(FEED_NAME == "GSF" || FEED_NAME == "GEF" || FEED_NAME == "SCM")
script_dependencies("gsf/enterprise_script.nasl");

exit(0);
}
8 changes: 8 additions & 0 deletions tests/standalone_plugins/nasl/common/foo.nasl
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
if(description)
{
script_category(ACT_ATTACK);
script_dependencies( "foobar.nasl", "gsf/enterprise_script.nasl" );
exit(0);
}

script_dependencies( "missing.nasl" );
7 changes: 7 additions & 0 deletions tests/standalone_plugins/nasl/common/foobar.nasl
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
if(description)
{
script_category(ACT_GATHER_INFO);
script_dependencies( "bar.nasl" );
exit(0);
script_tag(name:"deprecated", value:TRUE);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
if(description)
{
script_category(ACT_GATHER_INFO);
exit(0);
}
201 changes: 201 additions & 0 deletions tests/standalone_plugins/test_dependency_graph.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: 2024 Greenbone AG
import os
import unittest
from io import StringIO
from pathlib import Path
from unittest.mock import patch

from troubadix.plugins.dependency_category_order import VTCategory
from troubadix.standalone_plugins.dependency_graph.cli import parse_args
from troubadix.standalone_plugins.dependency_graph.dependency_graph import (
Reporter,
create_graph,
determine_feed,
extract_category,
extract_dependencies,
get_feed,
get_scripts,
main,
)
from troubadix.standalone_plugins.dependency_graph.models import (
Dependency,
Feed,
Result,
Script,
)


class TestReporter(unittest.TestCase):
def setUp(self):
self.result = Result(
name="TestScript",
warnings=["duplicate dependencies"],
errors=["missing dependencies"],
)

@patch("sys.stdout", new_callable=StringIO)
def test_report_verbosity_2(self, mock_stdout):
reporter = Reporter(verbosity=2)
reporter.report([self.result])

output = mock_stdout.getvalue()

self.assertIn("TestScript - warnings: 1, errors: 1", output)
self.assertIn("warning: duplicate dependencies", output)
self.assertIn("error: missing dependencies", output)


class TestCLIArgs(unittest.TestCase):
@patch(
"sys.argv",
[
"prog",
"--root",
"tests/standalone_plugins/nasl",
"--feed",
"feed_22_04",
"--log",
"info",
],
)
def test_parse_args_ok(self):
args = parse_args()
self.assertEqual(args.root, Path("tests/standalone_plugins/nasl"))
self.assertEqual(args.feed, [Feed.FEED_22_04])
self.assertEqual(args.log, "info")

@patch("sys.stderr", new_callable=StringIO)
@patch("sys.argv", ["prog", "--root", "not_real_dir"])
def test_parse_args_no_dir(self, mock_stderr):
with self.assertRaises(SystemExit):
parse_args()
self.assertRegex(mock_stderr.getvalue(), "invalid directory_type")

@patch("sys.stderr", new_callable=StringIO)
@patch(
"sys.argv",
[
"prog",
"--root",
"tests/standalone_plugins/nasl",
"--feed",
"invalid_feed",
],
)
def test_parse_args_invalid_feed(self, mock_stderr):
with self.assertRaises(SystemExit):
parse_args()
self.assertRegex(mock_stderr.getvalue(), "Invalid Feed value")

@patch.dict(os.environ, {"VTDIR": "/mock/env/path"})
@patch("sys.argv", ["prog"])
def test_parse_args_with_env(self):
args = parse_args()
self.assertEqual(args.root, Path("/mock/env/path"))

@patch("sys.argv", ["prog", "--root", "tests/standalone_plugins/nasl"])
def test_parse_args_defaults(self):
args = parse_args()
self.assertEqual(args.log, "WARNING")
self.assertEqual(args.feed, [Feed.FULL])


class TestDependencyGraph(unittest.TestCase):

def setUp(self) -> None:
self.local_root = "tests/standalone_plugins/nasl"
self.script_content = """
if(description)
{
script_category(ACT_GATHER_INFO);
script_dependencies( "foo.nasl", "foo.nasl" );
if(FEED_NAME == "GSF" || FEED_NAME == "GEF" || FEED_NAME == "SCM")
script_dependencies("gsf/enterprise_script.nasl");
exit(0);
}
"""

def test_get_feed(self):
feed = [Feed.FULL]
scripts = get_feed(Path(self.local_root), feed)
self.assertEqual(len(scripts), 6)

@patch("pathlib.Path.read_text")
def test_get_scripts(self, mock_read_text):
mock_read_text.return_value = self.script_content
scripts = get_scripts(Path(self.local_root) / "common")
self.assertEqual(len(scripts), 4)
self.assertEqual(scripts[0].feed, "community")
self.assertEqual(len(scripts[0].dependencies), 3)
self.assertEqual(scripts[0].category, VTCategory.ACT_GATHER_INFO)
self.assertEqual(scripts[0].deprecated, False)

def test_determine_feed(self):
community_script = Path("foo/script.nasl")
enterprise_script = Path("gsf/script.nasl")

self.assertEqual(determine_feed(community_script), "community")
self.assertEqual(determine_feed(enterprise_script), "enterprise")

def test_extract_dependencies(self):
dependencies = extract_dependencies(self.script_content)
self.assertEqual(len(dependencies), 3)
self.assertEqual(dependencies[0].name, "foo.nasl")
self.assertEqual(dependencies[1].name, "foo.nasl")
self.assertEqual(dependencies[2].name, "gsf/enterprise_script.nasl")
self.assertEqual(dependencies[0].is_enterprise_feed, False)
self.assertEqual(dependencies[1].is_enterprise_feed, False)
self.assertEqual(dependencies[2].is_enterprise_feed, True)

def test_extract_category(self):
category = extract_category(self.script_content)
self.assertEqual(category, VTCategory.ACT_GATHER_INFO)

def test_create_graph(self):
dependency1 = Dependency("bar.nasl", False)
scripts = [
Script("foo.nasl", "community", [dependency1], 0, False),
Script("bar.nasl", "enterprise", [], 0, False),
]
graph = create_graph(scripts)
self.assertEqual(len(list(graph.nodes)), 2)

@patch("sys.stdout", new_callable=StringIO) # mock_stdout (second argument)
@patch("sys.stderr", new_callable=StringIO) # mock_stderr (first argument)
@patch(
"sys.argv", ["prog", "--root", "tests/standalone_plugins/nasl", "-v"]
) # no argument
def test_full_run(self, mock_stderr, mock_stdout):
return_code = main()
output = mock_stdout.getvalue()

self.assertIn("error: missing dependency file: missing.nasl:", output)
self.assertIn(
"error: cyclic dependency: ", # order is random so can't match the output
output,
)
self.assertIn(
"error: unchecked cross-feed-dependency: foo.nasl(community feed) depends on"
" gsf/enterprise_script.nasl(enterprise feed), but the"
" current feed is not properly checked",
output,
)
self.assertIn(
"error: bar.nasl depends on foo.nasl which has a lower category order",
output,
)
self.assertIn(
"error: foo.nasl depends on deprecated script foobar.nasl", output
)
self.assertIn(
"warning: Duplicate dependencies in bar.nasl: foo.nasl", output
)
self.assertIn(
"warning: cross-feed-dependency: bar.nasl(community feed)"
" depends on gsf/enterprise_script.nasl(enterprise feed)",
output,
)
self.assertEqual(return_code, 1)
1 change: 1 addition & 0 deletions tests/test_naslinter.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ def test_generate_file_list_with_exclude_patterns(self):
"**/templates/*/*.nasl",
"**/test_files/*",
"**/test_files/**/*.nasl",
"**/tests/standalone_plugins/**/*.nasl",
],
include_patterns=["**/*.nasl", "**/*.inc"],
)
Expand Down
18 changes: 18 additions & 0 deletions troubadix/argparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,38 @@
from pontos.terminal import Terminal


# allows non existent paths and directory paths
def directory_type(string: str) -> Path:
directory_path = Path(string)
if directory_path.exists() and not directory_path.is_dir():
raise ValueError(f"{string} is not a directory.")
return directory_path


# allows only existing directory paths
def directory_type_existing(string: str) -> Path:
directory_path = Path(string)
if not directory_path.is_dir():
raise ValueError(f"{string} is not a directory.")
return directory_path


# allows non existent paths and file paths
def file_type(string: str) -> Path:
file_path = Path(string)
if file_path.exists() and not file_path.is_file():
raise ValueError(f"{string} is not a file.")
return file_path


# allows only existing file paths
def file_type_existing(string: str) -> Path:
file_path = Path(string)
if not file_path.is_file():
raise ValueError(f"{string} is not a file.")
return file_path


def check_cpu_count(number: str) -> int:
"""Make sure this value is valid
Default: use half of the available cores to not block the machine"""
Expand Down
Loading

0 comments on commit a1a048b

Please sign in to comment.