Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add: standalone plugin for evaluating dependencies with a graph #774

Draft
wants to merge 20 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2cee561
Add: standalone plugin for evaluating dependencies with a graph
NiklasHargarter Dec 11, 2024
f4763b6
Change: dependency_graph codeql mixed returns fix
NiklasHargarter Dec 11, 2024
413b876
Change: Revert "Change: dependency_graph codeql mixed returns fix"
NiklasHargarter Dec 12, 2024
eb6bd0b
Add: standalone dependency_graph unittests
NiklasHargarter Dec 12, 2024
f973706
Add: include when a dependency is gated in the dependency graph
NiklasHargarter Dec 31, 2024
e621f1a
Change: dependency_graph minor restructure and changes
NiklasHargarter Jan 6, 2025
63c0f64
Add: category order, deprecated dependencies to graph standalone plugin
NiklasHargarter Jan 16, 2025
8dc409b
Change: dependency_graph checks result structure and output
NiklasHargarter Jan 20, 2025
bd47514
Change: various refactors in dependency graph standalone
NiklasHargarter Jan 22, 2025
77ea91f
Change: correct dependency graph unittests not fixed in last commit
NiklasHargarter Jan 22, 2025
0fc9cae
Change: dependency graph minor refactors
NiklasHargarter Jan 24, 2025
5b0e94e
Change: dependency graph rename variables
NiklasHargarter Jan 27, 2025
6bbdbd6
Add: dir and file type for argparser that only allow existing
NiklasHargarter Jan 28, 2025
d8693a6
Change: reuse method for splitting dependencies
NiklasHargarter Jan 28, 2025
ad39133
Change: Cleanup dependency_graph
mbrinkhoff Jan 29, 2025
192d38b
Change: use restructured imports in dependency graph test
NiklasHargarter Jan 30, 2025
66fe3de
Merge pull request #789 from greenbone/dependecy_graph_cleanup
NiklasHargarter Jan 31, 2025
3a529e5
Merge branch 'main' into dependency_graph
NiklasHargarter Jan 31, 2025
1cdd101
Change: dependency graph optimize duplicate check
NiklasHargarter Feb 10, 2025
7c2dc21
Change: dependency graph cli incorrect error and remove logging
NiklasHargarter Feb 10, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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/
74 changes: 71 additions & 3 deletions poetry.lock

Large diffs are not rendered by default.

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);
}
76 changes: 76 additions & 0 deletions tests/standalone_plugins/test_dependency_graph.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: 2024 Greenbone AG
import sys
import unittest
from contextlib import redirect_stderr, redirect_stdout
from io import StringIO
from pathlib import Path
from unittest.mock import patch

from troubadix.standalone_plugins.dependency_graph.cli import parse_args
from troubadix.standalone_plugins.dependency_graph.dependency_graph import (
create_graph,
get_feed,
main,
)
from troubadix.standalone_plugins.dependency_graph.models import (
Dependency,
Feed,
Script,
)

NASL_DIR = "tests/standalone_plugins/nasl"


class TestDependencyGraph(unittest.TestCase):

def test_parse_args_ok(self):
test_args = [
"prog",
NASL_DIR,
"--feed",
"feed_22_04",
"--log",
"info",
]
with patch.object(sys, "argv", test_args):
args = parse_args()
self.assertTrue(args)
self.assertEqual(args.root, Path(NASL_DIR))
self.assertEqual(args.feed, [Feed.FEED_22_04])
self.assertEqual(args.log, "info")

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

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

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)

def test_full_run(self):
test_args = [
"prog",
NASL_DIR,
]
with (
redirect_stdout(StringIO()),
redirect_stderr(StringIO()),
patch.object(sys, "argv", test_args),
):
return_code = main()
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
26 changes: 15 additions & 11 deletions troubadix/plugins/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@
)


def split_dependencies(value: str) -> list[str]:
"""
Remove single and/or double quotes, spaces
and create a list by using the comma as a separator
additionally, check and filter for inline comments
"""
dependencies = []
for line in value.splitlines():
subject = line[: line.index("#")] if "#" in line else line
_dependencies = re.sub(r'[\'"\s]', "", subject).split(",")
dependencies += [dep for dep in _dependencies if dep != ""]
return dependencies


class CheckDependencies(FilePlugin):
name = "check_dependencies"

Expand Down Expand Up @@ -60,17 +74,7 @@ def run(

for match in matches:
if match:
# Remove single and/or double quotes, spaces
# and create a list by using the comma as a separator
# additionally, check and filter for inline comments
dependencies = []

for line in match.group("value").splitlines():
subject = line[: line.index("#")] if "#" in line else line
_dependencies = re.sub(r'[\'"\s]', "", subject).split(",")
dependencies += [dep for dep in _dependencies if dep != ""]

for dep in dependencies:
for dep in split_dependencies(match.group("value")):
if not any(
(root / vers / dep).exists() for vers in FEED_VERSIONS
):
Expand Down
10 changes: 2 additions & 8 deletions troubadix/standalone_plugins/changed_oid.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,10 @@
from pathlib import Path
from typing import Iterable

from troubadix.argparser import file_type_existing
from troubadix.standalone_plugins.common import git


def file_type(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 parse_args(args: Iterable[str]) -> Namespace:
parser = ArgumentParser(
description="Check for changed oid",
Expand All @@ -52,7 +46,7 @@ def parse_args(args: Iterable[str]) -> Namespace:
"-f",
"--files",
nargs="+",
type=file_type,
type=file_type_existing,
default=[],
help=(
"List of files to diff. "
Expand Down
2 changes: 2 additions & 0 deletions troubadix/standalone_plugins/dependency_graph/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: 2025 Greenbone AG
128 changes: 128 additions & 0 deletions troubadix/standalone_plugins/dependency_graph/checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: 2025 Greenbone AG


from collections import Counter

import networkx as nx

from .models import Result, Script


def check_duplicates(scripts: list[Script]) -> Result:
"""
checks for a script depending on a script multiple times
"""
warnings = []
for script in scripts:
counter = Counter(dep.name for dep in script.dependencies)
duplicates = [dep for dep, count in counter.items() if count > 1]

if duplicates:
msg = f"Duplicate dependencies in {script.name}: {', '.join(duplicates)}"
warnings.append(msg)

return Result(name="check_duplicates", warnings=warnings)


def check_missing_dependencies(
scripts: list[Script], graph: nx.DiGraph
) -> Result:
"""
Checks if any scripts that are depended on are missing from
the list of scripts created from the local file system,
logs the scripts dependending on the missing script
"""
errors = []
dependencies = {
dep.name for script in scripts for dep in script.dependencies
}
script_names = {script.name for script in scripts}
missing_dependencies = dependencies - script_names

for missing in missing_dependencies:
depending_scripts = graph.predecessors(missing)
msg = f"missing dependency file: {missing}:"
for script in depending_scripts:
msg += f"\n - used by: {script}"
errors.append(msg)

return Result(name="missing_dependencies", errors=errors)


def check_cycles(graph) -> Result:
"""
checks for cyclic dependencies
"""
if nx.is_directed_acyclic_graph(graph):
return Result(name="check_cycles")

cycles = nx.simple_cycles(graph)

errors = [f"cyclic dependency: {cycle}" for cycle in cycles]
return Result(name="check_cycles", errors=errors)


def cross_feed_dependencies(
graph, is_enterprise_checked: bool
) -> list[tuple[str, str]]:
"""
creates a list of script and dependency for scripts
in community feed that depend on scripts in enterprise folders
"""
cross_feed_dependencies = [
(u, v)
for u, v, is_enterprise_feed in graph.edges.data("is_enterprise_feed")
if graph.nodes[u]["feed"] == "community"
and graph.nodes[v].get("feed", "unknown") == "enterprise"
and is_enterprise_feed == is_enterprise_checked
] # unknown as standard value due to non existend nodes not having a feed value
return cross_feed_dependencies


def check_cross_feed_dependecies(graph) -> Result:
"""
Checks if scripts in the community feed have dependencies to enterprise scripts,
and if they are correctly contained within a is_enterprise_feed check.
"""
gated_cfd = cross_feed_dependencies(graph, is_enterprise_checked=True)
warnings = [
f"cross-feed-dependency: {dependent}(community feed) "
f"depends on {dependency}(enterprise feed)"
for dependent, dependency in gated_cfd
]

ungated_cfd = cross_feed_dependencies(graph, is_enterprise_checked=False)
errors = [
f"unchecked cross-feed-dependency: {dependent}(community feed) "
f"depends on {dependency}(enterprise feed), but the current feed is not properly checked"
for dependent, dependency in ungated_cfd
]

return Result(
name="check_cross_feed_dependencies", warnings=warnings, errors=errors
)


def check_category_order(graph) -> Result:
problematic_edges = [
(dependent, dependency)
for dependent, dependency in graph.edges()
if graph.nodes[dependent]["category"]
< graph.nodes[dependency].get("category", -1)
]

errors = [
f"{dependent} depends on {dependency} which has a lower category order"
for dependent, dependency in problematic_edges
]
return Result(name="check_category_order", errors=errors)


def check_deprecated_dependencies(graph) -> Result:
errors = [
f"{dependent} depends on deprectated script {dependency}"
for dependent, dependency in graph.edges()
if graph.nodes[dependency].get("deprecated", False)
]
return Result(name="check_deprecated_dependencies", errors=errors)
Loading