Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ 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'
troubadix-affected-scripts = 'troubadix.standalone_plugins.affected_scripts:main'

[build-system]
requires = ["poetry-core>=1.0.0"]
Expand Down
6 changes: 6 additions & 0 deletions tests/standalone_plugins/nasl_feed/bar.nasl
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
if(description)
{
script_category(ACT_GATHER_INFO);
script_dependencies("barfoo.nasl");
exit(0);
}
6 changes: 6 additions & 0 deletions tests/standalone_plugins/nasl_feed/barfoo.nasl
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
if(description)
{
script_category(ACT_ATTACK);
include("lib.inc");
exit(0);
}
7 changes: 7 additions & 0 deletions tests/standalone_plugins/nasl_feed/gsf/foo.nasl
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
if(description)
{
script_category(ACT_ATTACK);
script_dependencies("bar.nasl", "gsf/foobar.nasl");
include("lib.inc");
exit(0);
}
5 changes: 5 additions & 0 deletions tests/standalone_plugins/nasl_feed/gsf/foobar.nasl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
if(description)
{
script_category(ACT_GATHER_INFO);
exit(0);
}
2 changes: 2 additions & 0 deletions tests/standalone_plugins/nasl_feed/lib.inc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Dummy NASL include file for testing

6 changes: 6 additions & 0 deletions tests/standalone_plugins/nasl_feed/standalone.nasl
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Dummy NASL file with no dependencies and not depended on
if(description)
{
script_category(ACT_ATTACK);
exit(0);
}
108 changes: 108 additions & 0 deletions tests/standalone_plugins/test_affected_scripts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import unittest
from pathlib import Path

from troubadix.standalone_plugins.affected_scripts import run


class TestAffectedScriptsStandalone(unittest.TestCase):
def setUp(self):
self.root = Path("tests/standalone_plugins/nasl_feed")
self.input_file = self.root.parent / "changed_files.txt"
self.output_file = self.root.parent / "affected_files.txt"

self.input_file.parent.mkdir(parents=True, exist_ok=True)
# ensure files exist
self.input_file.touch()
self.output_file.touch()

def tearDown(self):
if self.input_file.exists():
self.input_file.unlink()
if self.output_file.exists():
self.output_file.unlink()

def test_standalone_file(self):
self.input_file.write_text("nasl/common/standalone.nasl\n")
run(self.root, self.input_file, self.output_file)
affected = self.output_file.read_text().splitlines()
# Only the standalone file should be affected
self.assertEqual(affected, ["standalone.nasl"])

def test_foo_changed(self):
self.input_file.write_text("nasl/22.04/gsf/foo.nasl\n")
run(self.root, self.input_file, self.output_file)
affected = self.output_file.read_text().splitlines()
# Only foo.nasl should be affected
self.assertEqual(affected, ["gsf/foo.nasl"])

def test_bar_changed(self):
self.input_file.write_text("bar.nasl\n")
run(self.root, self.input_file, self.output_file)
affected = self.output_file.read_text().splitlines()
# bar.nasl and foo.nasl should be affected (foo depends on bar)
self.assertIn("bar.nasl", affected)
self.assertIn("gsf/foo.nasl", affected)
self.assertEqual(len(affected), 2)

def test_foobar_changed(self):
self.input_file.write_text("nasl/common/gsf/foobar.nasl\n")
run(self.root, self.input_file, self.output_file)
affected = self.output_file.read_text().splitlines()
# foobar.nasl and foo.nasl should be affected (foo depends on foobar)
self.assertIn("gsf/foobar.nasl", affected)
self.assertIn("gsf/foo.nasl", affected)
self.assertEqual(len(affected), 2)

def test_barfoo_changed(self):
self.input_file.write_text("barfoo.nasl\n")
run(self.root, self.input_file, self.output_file)
affected = self.output_file.read_text().splitlines()
# barfoo.nasl, bar.nasl, and foo.nasl should be affected
# foo depends on bar, bar depends on barfoo
self.assertIn("barfoo.nasl", affected)
self.assertIn("bar.nasl", affected)
self.assertIn("gsf/foo.nasl", affected)
self.assertEqual(len(affected), 3)

def test_lib_inc_include(self):
self.input_file.write_text("lib.inc\n")
run(self.root, self.input_file, self.output_file)
affected = self.output_file.read_text().splitlines()
# lib.inc is included by foo.nasl and barfoo.nasl, which propagate up
self.assertIn("lib.inc", affected)
self.assertIn("gsf/foo.nasl", affected)
self.assertIn("barfoo.nasl", affected)
self.assertIn("bar.nasl", affected) # bar depends on barfoo
self.assertEqual(len(affected), 4)

def test_empty_input(self):
self.input_file.write_text("")
run(self.root, self.input_file, self.output_file)
affected = self.output_file.read_text().splitlines()
self.assertEqual(len(affected), 0)

def test_max_distance(self):
# barfoo.nasl depends on lib.inc
# bar.nasl depends on barfoo.nasl
# foo.nasl depends on bar.nasl and others
self.input_file.write_text("barfoo.nasl\n")

# Distance 0: only barfoo.nasl
run(self.root, self.input_file, self.output_file, max_distance=0)
affected = self.output_file.read_text().splitlines()
self.assertEqual(affected, ["barfoo.nasl"])

# Distance 1: barfoo.nasl and its direct ancestor bar.nasl
run(self.root, self.input_file, self.output_file, max_distance=1)
affected = self.output_file.read_text().splitlines()
self.assertIn("barfoo.nasl", affected)
self.assertIn("bar.nasl", affected)
self.assertEqual(len(affected), 2)

# Distance 2: barfoo.nasl, bar.nasl, and foo.nasl
run(self.root, self.input_file, self.output_file, max_distance=2)
affected = self.output_file.read_text().splitlines()
self.assertIn("barfoo.nasl", affected)
self.assertIn("bar.nasl", affected)
self.assertIn("gsf/foo.nasl", affected)
self.assertEqual(len(affected), 3)
4 changes: 2 additions & 2 deletions troubadix/plugins/infos_array_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,15 @@ def check_content(self, nasl_file: Path, file_content: str) -> Iterator[LinterRe
if var_name not in ALLOWED_VARS:
yield LinterWarning(
f'Unexpected variable name "{var_name}" assigned from {FN_CALL_EXPRESSION}. '
f'The standard names are {"|".join(ALLOWED_VARS)}.',
f"The standard names are {'|'.join(ALLOWED_VARS)}.",
file=nasl_file,
plugin=self.name,
)

if not found_vars:
yield LinterError(
f"Missing assignment from {FN_CALL_EXPRESSION}. "
f'The result must be assigned to a variable named {"|".join(ALLOWED_VARS)}.',
f"The result must be assigned to a variable named {'|'.join(ALLOWED_VARS)}.",
file=nasl_file,
plugin=self.name,
)
Expand Down
107 changes: 107 additions & 0 deletions troubadix/standalone_plugins/affected_scripts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: 2026 Greenbone AG

import argparse
import re
from pathlib import Path

import networkx as nx

from troubadix.helper import CURRENT_ENCODING
from troubadix.plugins.dependencies import split_dependencies

INCLUDE_PATTERN = re.compile(
r"include\s*\(\s*(?P<quote>[\'\"])" r"(?P<value>.*?)(?P=quote)\s*\)\s*;"
)
DEPENDENCY_PATTERN = re.compile(r"script_dependencies\s*\(\s*(?P<value>.*?)\)", re.DOTALL)
NASL_EXTENSIONS = (".nasl", ".inc")


def create_graph_from_root(root: Path) -> nx.DiGraph:
graph = nx.DiGraph()
root = Path(root)

for path in root.rglob("*"):
if path.suffix not in NASL_EXTENSIONS or not path.is_file():
continue
content = path.read_text(encoding=CURRENT_ENCODING)
name = str(path.relative_to(root))
graph.add_node(name)

for m in INCLUDE_PATTERN.finditer(content):
graph.add_edge(name, m.group("value"))

for m in DEPENDENCY_PATTERN.finditer(content):
dep_list = split_dependencies(m.group("value"))
for dep in dep_list:
graph.add_edge(name, dep)

return graph


def run(root: Path, input_file: Path, output_file: Path, max_distance: int = None):
root = Path(root)
graph = create_graph_from_root(root)

changed_files = input_file.read_text().splitlines()
affected = set()

# needed for fast shortest path calculation of ancestors
# if max distance is not given, we can just use the ancestors method
# else use reversed graph and single source shortest path going out from given changed files
rev_graph = graph.reverse() if max_distance is not None else None

for line in changed_files:
line = line.strip()
if not line:
continue

path = Path(line)
parts = list(path.parts)
# normalize inputs like 'nasl/common/..' or 'nasl/21.04/..' to names relative to root.
if parts and parts[0] == "nasl":
parts.pop(0)
if parts and parts[0] in ("common", "21.04", "22.04"):
parts.pop(0)

name = str(Path(*parts))

if name in graph:
affected.add(name)
if max_distance is None:
affected.update(nx.ancestors(graph, name))
else:
lengths = nx.single_source_shortest_path_length(
rev_graph, name, cutoff=max_distance
)
affected.update(lengths.keys())

if affected:
output_file.write_text("\n".join(sorted(affected)) + "\n")
else:
output_file.write_text("")


def main():
parser = argparse.ArgumentParser(description="Find scripts affected by changes.")
parser.add_argument("feed_root", help="Root directory where NASL files live post feed_gen")
parser.add_argument(
"input_file",
help="File with changed filenames. "
"Paths relative to repo root or feed root. Newline separated.",
)
parser.add_argument("output_file", help="File to write affected scripts. Newline separated.")
parser.add_argument(
"--max-distance",
"-d",
type=int,
default=None,
help="Maximum distance to search for affected scripts. 1 means only direct dependencies.",
)

args = parser.parse_args()
run(Path(args.feed_root), Path(args.input_file), Path(args.output_file), args.max_distance)


if __name__ == "__main__":
main()