From 14cb424054824d80d378f9523ee562df6b2b36ba Mon Sep 17 00:00:00 2001 From: Paul Derscheid Date: Mon, 3 Nov 2025 20:51:37 +0000 Subject: [PATCH] Bug 40778: Introduce modular Node.js dependency auditing framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a complete Node.js dependency management and audit framework under Koha/Devel/Node/, supporting npm and Yarn via a strategy-based design - Unified command-line tools for outdated, audit, SBOM, and Bugzilla reports - Extensible package manager abstraction (Npm, Yarn) - Structured JSON output and CycloneDX-like SBOM generation - Compromise detection for quick checks if security breaches are announced - Integrated CI helper To test: 1. Run `./misc/devel/node_audit_dependencies.pl --help` to verify tool availability. 2. Execute `--outdated` and `--audit` modes to ensure JSON output and summary files are generated. 3. Generate reports: - `--sbom` → creates `node_modules_sbom.xml` - `--bugzilla` → creates `dependency_bugzilla_report.md` 4. Optionally test compromise detection: `./misc/devel/node_audit_compromise.pl --inline "lodash@4.17.21"` 5. Sign off. --- Koha/Devel/Files.pm | 2 + Koha/Devel/Node/Package/Manager/Base.pm | 143 +++ Koha/Devel/Node/Package/Manager/Npm.pm | 505 ++++++++ Koha/Devel/Node/Package/Manager/Yarn.pm | 601 ++++++++++ Koha/Devel/Node/Utils.pm | 1044 +++++++++++++++++ .../dependency_bugzilla_report.md.tt | 80 ++ .../Node/templates/dependency_sbom.xml.tt | 27 + misc/devel/node_audit_compromise.pl | 321 +++++ misc/devel/node_audit_config.yml | 51 + misc/devel/node_audit_dependencies.pl | 537 +++++++++ misc/devel/node_ci_audit.pl | 262 +++++ 11 files changed, 3573 insertions(+) create mode 100644 Koha/Devel/Node/Package/Manager/Base.pm create mode 100644 Koha/Devel/Node/Package/Manager/Npm.pm create mode 100644 Koha/Devel/Node/Package/Manager/Yarn.pm create mode 100644 Koha/Devel/Node/Utils.pm create mode 100644 Koha/Devel/Node/templates/dependency_bugzilla_report.md.tt create mode 100644 Koha/Devel/Node/templates/dependency_sbom.xml.tt create mode 100755 misc/devel/node_audit_compromise.pl create mode 100644 misc/devel/node_audit_config.yml create mode 100755 misc/devel/node_audit_dependencies.pl create mode 100755 misc/devel/node_ci_audit.pl diff --git a/Koha/Devel/Files.pm b/Koha/Devel/Files.pm index ae4152b19a4..30f4453fc49 100644 --- a/Koha/Devel/Files.pm +++ b/Koha/Devel/Files.pm @@ -85,6 +85,8 @@ my $exceptions = { Koha/ILL/Backend/ *doc-head-open.inc misc/cronjobs/rss + Koha/Devel/Node/templates/dependency_bugzilla_report.md.tt + Koha/Devel/Node/templates/dependency_sbom.xml.tt ) ], codespell => [], diff --git a/Koha/Devel/Node/Package/Manager/Base.pm b/Koha/Devel/Node/Package/Manager/Base.pm new file mode 100644 index 00000000000..24ae82eaf4c --- /dev/null +++ b/Koha/Devel/Node/Package/Manager/Base.pm @@ -0,0 +1,143 @@ +package Koha::Devel::Node::Package::Manager::Base; + +# Copyright 2025 Koha Development Team +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; +use Carp qw(croak); + +use Koha::Devel::Node::Utils qw( + log_message + run_command + safe_json_decode + map_dependency_info +); + +our $VERSION = '1.0.0'; + +sub new { + my ($class) = @_; + return bless {}, $class; +} + +sub run_outdated_check { + my ( $self, $tool_config, $direct_deps, $verbose ) = @_; + croak 'Must implement run_outdated_check method'; +} + +sub run_audit_check { + my ( $self, $tool_config, $direct_deps, $verbose ) = @_; + croak 'Must implement run_audit_check method'; +} + +sub build_resolved_dependency_data { + my ( $self, $tool_config, $direct_deps, $project_root, $verbose ) = @_; + croak 'Must implement build_resolved_dependency_data method'; +} + +1; + +__END__ + +=head1 NAME + +Koha::Devel::Node::Package::Manager::Base - Abstract base class for package manager strategies + +=head1 SYNOPSIS + + # This is an abstract base class + # Use concrete implementations like Koha::Devel::Node::Package::Manager::Yarn or Npm + + my $manager = Koha::Devel::Node::Package::Manager::Yarn->new(); + my ($outdated, $summary) = $manager->run_outdated_check($config, $deps, $verbose); + +=head1 DESCRIPTION + +This module provides an abstract base class implementing the Strategy pattern for +different Node.js package managers (yarn, npm). It defines the interface +that all package manager strategies must implement. + +=head1 METHODS + +=head2 new() + +Creates a new instance of the package manager strategy. + + my $manager = Koha::Devel::Node::Package::Manager::Yarn->new(); + +=head2 run_outdated_check($tool_config, $direct_deps, $verbose) + +Abstract method that must be implemented by concrete classes. +Runs outdated dependency check for the specific package manager. + +=over 4 + +=item * $tool_config - Hashref of configuration for this package manager + +=item * $direct_deps - Hashref of direct dependencies from package.json + +=item * $verbose - Boolean flag for verbose output + +=back + +Returns: ($outdated_packages, $summary) + +=head2 run_audit_check($tool_config, $direct_deps, $verbose) + +Abstract method that must be implemented by concrete classes. +Runs security audit check for the specific package manager. + +=over 4 + +=item * $tool_config - Hashref of configuration for this package manager + +=item * $direct_deps - Hashref of direct dependencies from package.json + +=item * $verbose - Boolean flag for verbose output + +=back + +Returns: ($vulnerabilities, $summary) + +=head2 build_resolved_dependency_data($tool_config, $direct_deps, $project_root, $verbose) + +Abstract method that must be implemented by concrete classes. +Builds complete resolved dependency tree for the package manager. + +=over 4 + +=item * $tool_config - Hashref of configuration for this package manager + +=item * $direct_deps - Hashref of direct dependencies from package.json + +=item * $project_root - Path to project root directory + +=item * $verbose - Boolean flag for verbose output + +=back + +Returns: Hashref containing resolved dependency data + +=head1 AUTHOR + +Koha Development Team + +=head1 COPYRIGHT + +Copyright 2025 Koha + +=cut diff --git a/Koha/Devel/Node/Package/Manager/Npm.pm b/Koha/Devel/Node/Package/Manager/Npm.pm new file mode 100644 index 00000000000..b45cf87ecab --- /dev/null +++ b/Koha/Devel/Node/Package/Manager/Npm.pm @@ -0,0 +1,505 @@ +package Koha::Devel::Node::Package::Manager::Npm; + +# Copyright 2025 Koha Development Team +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; +use Carp qw(carp croak); +use JSON qw(decode_json); + +use Koha::Devel::Node::Utils qw( + log_message + run_command + safe_json_decode + map_dependency_info +); + +use parent 'Koha::Devel::Node::Package::Manager::Base'; + +our $VERSION = '1.0.0'; + +sub run_outdated_check { + my ( $self, $tool_config, $direct_deps, $verbose ) = @_; + + my $cmd = $tool_config->{'commands'}{'outdated'}; + if ( !$cmd ) { + log_message( 'warn', $verbose, 'Outdated command not configured for npm' ); + return ( {}, {} ); + } + + log_message( 'debug', $verbose, "Running outdated command: $cmd" ); + my @cmd_parts = split /\s+/smx, $cmd; + my @args = @cmd_parts[ 1 .. $#cmd_parts ]; + my ( $output, $exit ) = run_command( $cmd_parts[0], \@args ); + log_message( 'debug', $verbose, "Outdated command exit code: $exit" ); + + if ( !$output ) { + return ( {}, {} ); + } + + my $payload = safe_json_decode($output); + if ( !$payload ) { + log_message( 'warn', $verbose, 'Failed to decode npm outdated output' ); + return ( {}, {} ); + } + + my $packages = {}; + + if ( ref $payload eq 'HASH' ) { + for my $name ( keys %{$payload} ) { + my $entry = $payload->{$name}; + if ( ref $entry ne 'HASH' ) { + next; + } + + my $current = $entry->{'current'}; + my $latest = $entry->{'latest'}; + my $wanted = $entry->{'wanted'}; + my $update = $self->_determine_update_type( $current, $latest ); + + $packages->{$name} = { + 'current' => $current, + 'wanted' => $wanted, + 'latest' => $latest, + 'update' => $update, + }; + + map_dependency_info( $packages, $name, $direct_deps ); + } + } + + my $counts = {}; + for my $info ( values %{$packages} ) { + if ( ref $info ne 'HASH' ) { + next; + } + my $update = $info->{'update'} // 'unknown'; + $counts->{$update}++; + } + + my $summary = { + 'total' => scalar keys %{$packages}, + 'counts' => $counts, + }; + + return ( $packages, $summary ); +} + +sub run_audit_check { + my ( $self, $tool_config, $direct_deps, $verbose ) = @_; + + my $cmd = $tool_config->{'commands'}{'audit'}; + if ( !$cmd ) { + log_message( 'warn', $verbose, 'Audit command not configured for npm' ); + return ( [], {} ); + } + + log_message( 'debug', $verbose, "Running audit command: $cmd" ); + my @cmd_parts = split /\s+/smx, $cmd; + my @args = @cmd_parts[ 1 .. $#cmd_parts ]; + my ( $output, $exit ) = run_command( $cmd_parts[0], \@args ); + log_message( 'debug', $verbose, "Audit command exit code: $exit" ); + + if ( !$output ) { + return ( [], {} ); + } + + my $payload = safe_json_decode($output); + if ( !$payload ) { + log_message( 'warn', $verbose, 'Failed to decode npm audit output' ); + return ( [], {} ); + } + + my $vulnerabilities = []; + my $vulnerabilities_data = $payload->{'vulnerabilities'} || {}; + for my $module ( keys %{$vulnerabilities_data} ) { + my $entry = $vulnerabilities_data->{$module}; + if ( ref $entry ne 'HASH' ) { + next; + } + + my $severity = lc( $entry->{'severity'} // 'unknown' ); + + my $fix = $entry->{'fixAvailable'}; + my $fixed_in; + if ( !defined $fix ) { + $fixed_in = undef; + } elsif ( ref $fix eq 'HASH' && $fix->{'version'} ) { + $fixed_in = $fix->{'version'}; + } elsif ($fix) { + $fixed_in = 'latest'; + } + + my $nodes = [ @{ $entry->{'nodes'} || [] } ]; + my $paths = + [ map { $self->_npm_node_to_path($_) } @{$nodes} ]; + my $direct = $self->_analyze_dependency_paths( $paths, $direct_deps ); + + push @{$vulnerabilities}, + { + 'package' => $module, + 'severity' => $severity, + 'current_version' => $entry->{'version'} // $entry->{'range'} // 'unknown', + 'fixed_in' => $fixed_in, + 'url' => $self->_find_first_url( $entry->{'via'} ), + 'recommendation' => $entry->{'via'} && ref $entry->{'via'}[0] eq 'HASH' + ? $entry->{'via'}[0]{'recommendation'} + : q{}, + 'dependency_paths' => $paths, + 'direct_dependencies' => $direct, + }; + } + + my $severity_counts = {}; + for my $entry ( @{$vulnerabilities} ) { + my $severity = lc( $entry->{'severity'} // 'unknown' ); + $severity_counts->{$severity}++; + } + + my $summary = { + 'total' => scalar @{$vulnerabilities}, + 'severity_counts' => $severity_counts, + }; + + return ( $vulnerabilities, $summary ); +} + +sub build_resolved_dependency_data { + my ( $self, $tool_config, $direct_deps, $project_root, $verbose ) = @_; + + my $list_cmd = $tool_config->{'commands'}{'list'}; + if ( !$list_cmd ) { + log_message( 'warn', $verbose, 'npm list command not configured, skipping resolved map' ); + return; + } + + my @cmd_parts = split /\s+/smx, $list_cmd; + my @args = @cmd_parts[ 1 .. $#cmd_parts ]; + my ( $output, $exit ) = run_command( $cmd_parts[0], \@args ); + if ( $exit != 0 ) { + log_message( 'warn', $verbose, "npm list command failed ($exit), skipping resolved map" ); + return; + } + + my $payload = safe_json_decode($output); + if ( !$payload ) { + log_message( 'warn', $verbose, 'Failed to decode npm list output' ); + return; + } + + my $direct_resolved = { + dependencies => {}, + devDependencies => {}, + }; + my $packages = {}; + $self->_traverse_npm_tree( + $payload->{'dependencies'} || {}, + $packages, + $direct_resolved, + $direct_deps + ); + + my $package_list = []; + my $package_index = {}; + for my $name ( sort keys %{$packages} ) { + for my $version ( sort keys %{ $packages->{$name} } ) { + my $type = $packages->{$name}{$version}; + $package_index->{$name}{$version} = $type; + push @{$package_list}, + { + name => $name, + version => $version, + type => $type, + }; + } + } + + my $metadata = { + source => 'npm list --json', + tool => 'npm', + package_count => scalar @{$package_list}, + }; + + return { + metadata => $metadata, + direct => $direct_resolved, + packages => $package_list, + index => $package_index, + }; +} + +sub _determine_update_type { + my ( $self, $current, $latest ) = @_; + + if ( !defined $current || !defined $latest ) { + return 'unknown'; + } + + my $current_parts = $self->_version_parts($current); + my $latest_parts = $self->_version_parts($latest); + + if ( !@{$current_parts} || !@{$latest_parts} ) { + return 'unknown'; + } + + if ( $latest_parts->[0] > $current_parts->[0] ) { + return 'major'; + } + if ( $latest_parts->[1] > $current_parts->[1] ) { + return 'minor'; + } + if ( $latest_parts->[2] > $current_parts->[2] ) { + return 'patch'; + } + + return 'up-to-date'; +} + +sub _version_parts { + my ( $self, $version ) = @_; + if ( !defined $version ) { + return; + } + + my $clean = $version; + $clean =~ s/^[\^~><=v\s]+//smx; + + my $parts = [ split /[.]/smx, $clean ]; + my $numeric = []; + for my $part ( @{$parts} ) { + my $match = $part; + push @{$numeric}, ( $match =~ /(\d+)/xms ? $1 : 0 ); + } + + while ( @{$numeric} < 3 ) { + push @{$numeric}, 0; + } + + return [ @{$numeric}[ 0 .. 2 ] ]; +} + +sub _analyze_dependency_paths { + my ( $self, $paths, $direct_deps ) = @_; + + my $direct = {}; + + for my $path ( @{$paths} ) { + if ( !defined $path ) { + next; + } + my $segments = [ split /\s*>\s*/smx, $path ]; + for my $segment ( @{$segments} ) { + my $pkg = $self->_normalize_dependency_segment($segment); + if ( !$pkg ) { + next; + } + if ( exists $direct_deps->{$pkg} ) { + $direct->{$pkg} = $direct_deps->{$pkg}; + } + } + } + + my $result = []; + for my $key ( sort keys %{$direct} ) { + my $entry = $direct->{$key}; + push @{$result}, + { + 'name' => $key, + 'type' => $entry->{'type'}, + 'version_spec' => $entry->{'version'}, + 'resolved_version' => $entry->{'resolved_version'}, + }; + } + return $result; +} + +sub _normalize_dependency_segment { + my ( $self, $segment ) = @_; + if ( !defined $segment ) { + return q{}; + } + + $segment =~ s/^\s+|\s+$//smxg; + if ( !length $segment ) { + return q{}; + } + + # Remove trailing version portion (but keep scoped package prefix) + $segment =~ s/\@(?=[^\/]+$)[^\/]+$//smx; + + return $segment; +} + +sub _npm_node_to_path { + my ( $self, $node ) = @_; + if ( !defined $node ) { + return q{}; + } + + # node_modules/foo/node_modules/bar -> foo>bar + my $segments = [ + grep { $_ ne 'node_modules' && $_ ne q{} } + split m{/}smx, $node + ]; + + my $packages = []; + for my $segment ( @{$segments} ) { + push @{$packages}, + $self->_normalize_dependency_segment($segment); + } + + return join '>', @{$packages}; +} + +sub _find_first_url { + my ( $self, $via ) = @_; + + if ( ref $via ne 'ARRAY' ) { + return q{}; + } + for my $item ( @{$via} ) { + if ( ref $item ne 'HASH' ) { + next; + } + if ( $item->{'url'} ) { + return $item->{'url'}; + } + } + return q{}; +} + +sub _traverse_npm_tree { + my ( $self, $node, $packages, $direct_resolved, $direct_deps, $path ) = @_; + + $path ||= []; + + my $precedence = { + transitive => 0, + devDependencies => 1, + dependencies => 2, + }; + + for my $name ( keys %{$node} ) { + my $info = $node->{$name} || {}; + my $version = + $info->{'version'} + || $info->{'resolved'} + || $info->{'from'} + || 'unknown'; + + my $type = 'transitive'; + if ( $direct_deps->{$name} + && $direct_deps->{$name}{'type'} eq 'dependencies' ) + { + $type = 'dependencies'; + $direct_resolved->{'dependencies'}{$name} = $version; + } elsif ( $direct_deps->{$name} + && $direct_deps->{$name}{'type'} eq 'devDependencies' ) + { + $type = 'devDependencies'; + $direct_resolved->{'devDependencies'}{$name} = $version; + } + + my $existing = $packages->{$name}{$version}; + if ( !defined $existing + || ( $precedence->{$type} // 0 ) > ( $precedence->{$existing} // 0 ) ) + { + $packages->{$name}{$version} = $type; + } + + $self->_traverse_npm_tree( + $info->{'dependencies'} || {}, + $packages, + $direct_resolved, + $direct_deps, + [ @{$path}, $name ] + ); + } + + return; +} + +1; + +__END__ + +=head1 NAME + +Koha::Devel::Node::Package::Manager::Npm - NPM package manager strategy implementation + +=head1 SYNOPSIS + + use Koha::Devel::Node::Package::Manager::Npm; + + my $npm = Koha::Devel::Node::Package::Manager::Npm->new(); + my ($outdated, $summary) = $npm->run_outdated_check($config, $deps, $verbose); + +=head1 DESCRIPTION + +This module provides a concrete implementation of the package manager strategy +pattern for NPM. It implements the abstract methods defined in +Koha::Devel::Node::Package::Manager::Base to handle NPM-specific operations +including outdated checks, security audits, and dependency resolution. + +=head1 METHODS + +This class inherits from Koha::Devel::Node::Package::Manager::Base and implements +the following abstract methods: + +=head2 run_outdated_check($tool_config, $direct_deps, $verbose) + +Implements outdated dependency check for NPM using 'npm outdated --json' command. + +=head2 run_audit_check($tool_config, $direct_deps, $verbose) + +Implements security audit for NPM using 'npm audit --json' command. + +=head2 build_resolved_dependency_data($tool_config, $direct_deps, $project_root, $verbose) + +Builds complete dependency tree using 'npm list --json' command. + +=head1 PRIVATE METHODS + +The following methods are internal to the implementation: + +=over 4 + +=item * _determine_update_type($current, $latest) + +=item * _version_parts($version) + +=item * _analyze_dependency_paths($paths, $direct_deps) + +=item * _normalize_dependency_segment($segment) + +=item * _npm_node_to_path($node) + +=item * _find_first_url($via) + +=item * _traverse_npm_tree($node, $packages, $direct_resolved, $direct_deps, $path) + +=back + +=head1 AUTHOR + +Koha Development Team + +=head1 COPYRIGHT + +Copyright 2025 Koha + +=cut diff --git a/Koha/Devel/Node/Package/Manager/Yarn.pm b/Koha/Devel/Node/Package/Manager/Yarn.pm new file mode 100644 index 00000000000..1143f15123f --- /dev/null +++ b/Koha/Devel/Node/Package/Manager/Yarn.pm @@ -0,0 +1,601 @@ +package Koha::Devel::Node::Package::Manager::Yarn; + +# Copyright 2025 Koha Development Team +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; +use Carp qw(carp croak); +use JSON qw(decode_json); + +use Koha::Devel::Node::Utils qw( + log_message + run_command + safe_json_decode + map_dependency_info + normalize_semver_spec +); + +use parent 'Koha::Devel::Node::Package::Manager::Base'; + +our $VERSION = '1.0.0'; + +sub run_outdated_check { + my ( $self, $tool_config, $direct_deps, $verbose ) = @_; + + my $cmd = $tool_config->{'commands'}{'outdated'}; + if ( !$cmd ) { + log_message( 'warn', $verbose, 'Outdated command not configured for yarn' ); + return ( {}, {} ); + } + + log_message( 'debug', $verbose, "Running outdated command: $cmd" ); + my @cmd_parts = split /\s+/smx, $cmd; + my @args = @cmd_parts[ 1 .. $#cmd_parts ]; + my ( $output, $exit ) = run_command( $cmd_parts[0], \@args ); + log_message( 'debug', $verbose, "Outdated command exit code: $exit" ); + + if ( !$output ) { + return ( {}, {} ); + } + + my $packages = {}; + + for my $line ( split /\n/smx, $output ) { + if ( $line !~ /\S/smx ) { + next; + } + if ( $line !~ /^\s*[{]/smx ) { + next; + } + my $payload = safe_json_decode($line); + if ( !$payload ) { + log_message( 'warn', $verbose, "Failed to decode yarn outdated line: $line" ); + next; + } + + if ( !$payload->{'type'} || $payload->{'type'} ne 'table' ) { + next; + } + my $body = $payload->{'data'}{'body'} || []; + for my $row ( @{$body} ) { + if ( ref $row ne 'ARRAY' || @{$row} < 4 ) { + next; + } + my ( $name, $current, $wanted, $latest ) = + @{$row}[ 0 .. 3 ]; + my $update_type = $self->_determine_update_type( $current, $latest ); + + $packages->{$name} = { + 'current' => $current, + 'wanted' => $wanted, + 'latest' => $latest, + 'update' => $update_type, + }; + + map_dependency_info( $packages, $name, $direct_deps ); + } + } + + my $counts = {}; + for my $info ( values %{$packages} ) { + if ( ref $info ne 'HASH' ) { + next; + } + my $update = $info->{'update'} // 'unknown'; + $counts->{$update}++; + } + + my $summary = { + 'total' => scalar keys %{$packages}, + 'counts' => $counts, + }; + + return ( $packages, $summary ); +} + +sub run_audit_check { + my ( $self, $tool_config, $direct_deps, $verbose ) = @_; + + my $cmd = $tool_config->{'commands'}{'audit'}; + if ( !$cmd ) { + log_message( 'warn', $verbose, 'Audit command not configured for yarn' ); + return ( [], {} ); + } + + log_message( 'debug', $verbose, "Running audit command: $cmd" ); + my @cmd_parts = split /\s+/smx, $cmd; + my @args = @cmd_parts[ 1 .. $#cmd_parts ]; + my ( $output, $exit ) = run_command( $cmd_parts[0], \@args ); + log_message( 'debug', $verbose, "Audit command exit code: $exit" ); + + if ( !$output ) { + return ( [], {} ); + } + + my $vulnerabilities = []; + my $seen = {}; + for my $line ( split /\n/smx, $output ) { + if ( $line !~ /\S/smx ) { + next; + } + if ( $line !~ /^\s*[{]/smx ) { + next; + } + my $payload = safe_json_decode($line); + if ( !$payload ) { + log_message( 'warn', $verbose, "Failed to decode yarn audit line: $line" ); + next; + } + + if ( !$payload->{'type'} || $payload->{'type'} ne 'auditAdvisory' ) { + next; + } + + my $advisory = $payload->{'data'}{'advisory'} || {}; + my $resolution = $payload->{'data'}{'resolution'} || {}; + + my $findings = + [ @{ $advisory->{'findings'} || [] } ]; + my $paths_seen = {}; + for my $finding ( @{$findings} ) { + if ( ref $finding ne 'HASH' ) { + next; + } + for my $path ( @{ $finding->{'paths'} || [] } ) { + if ( !defined $path ) { + next; + } + $paths_seen->{$path} = 1; + } + } + + my $paths = [ sort keys %{$paths_seen} ]; + + my $severity = lc( $advisory->{'severity'} // 'unknown' ); + + my $module = $advisory->{'module_name'} // 'unknown'; + my $current_version = + $advisory->{'findings'} && @{ $advisory->{'findings'} } && $advisory->{'findings'}[0]{'version'} + ? $advisory->{'findings'}[0]{'version'} + : $resolution->{'currentVersion'} // 'unknown'; + + my $patched = $advisory->{'patched_versions'} // q{}; + if ($patched) { + $patched =~ s/^[\^~><=\s]+//smx; + } + + my $recommendation = $advisory->{'recommendation'} // q{}; + + my $key = join q{:}, $module, $current_version, @{$paths}; + if ( $seen->{$key} ) { + next; + } + $seen->{$key} = 1; + + push @{$vulnerabilities}, + { + 'package' => $module, + 'severity' => $severity, + 'current_version' => $current_version, + 'fixed_in' => $patched || undef, + 'url' => $advisory->{'url'} // q{}, + 'recommendation' => $recommendation, + 'dependency_paths' => $paths, + 'direct_dependencies' => $self->_analyze_dependency_paths( $paths, $direct_deps ), + }; + } + + my $severity_counts = {}; + for my $entry ( @{$vulnerabilities} ) { + my $severity = lc( $entry->{'severity'} // 'unknown' ); + $severity_counts->{$severity}++; + } + + my $summary = { + 'total' => scalar @{$vulnerabilities}, + 'severity_counts' => $severity_counts, + }; + + return ( $vulnerabilities, $summary ); +} + +sub build_resolved_dependency_data { + my ( $self, $tool_config, $direct_deps, $project_root, $verbose ) = @_; + + my $list_cmd = $tool_config->{'commands'}{'list'}; + if ( !$list_cmd ) { + log_message( 'warn', $verbose, 'yarn list command not configured, skipping resolved map' ); + return; + } + + log_message( 'debug', $verbose, "Running yarn list command: $list_cmd" ); + my @cmd_parts = split /\s+/smx, $list_cmd; + my @args = @cmd_parts[ 1 .. $#cmd_parts ]; + my ( $output, $exit ) = run_command( $cmd_parts[0], \@args ); + log_message( 'debug', $verbose, "yarn list command exit code: $exit" ); + + if ( $exit != 0 ) { + log_message( 'warn', $verbose, "yarn list command failed ($exit), skipping resolved map" ); + return; + } + + my $nodes = []; + for my $line ( split /\n/smx, $output ) { + if ( $line !~ /\S/smx ) { + next; + } + my $payload = safe_json_decode($line); + if ( !$payload ) { + log_message( 'warn', $verbose, "Failed to decode yarn list line: $line" ); + next; + } + + my $type = $payload->{'type'} // q{}; + if ( $type eq 'warning' || $type eq 'info' ) { + next; + } + + if ( $type eq 'tree' ) { + my $trees = $payload->{'data'}{'trees'} || []; + push @{$nodes}, @{$trees}; + } + } + + if ( !@{$nodes} ) { + log_message( 'warn', $verbose, 'yarn list produced no dependency data' ); + return; + } + + return $self->_process_yarn_nodes( $nodes, $direct_deps, $verbose ); +} + +sub _determine_update_type { + my ( $self, $current, $latest ) = @_; + + if ( !defined $current || !defined $latest ) { + return 'unknown'; + } + + my $current_parts = $self->_version_parts($current); + my $latest_parts = $self->_version_parts($latest); + + if ( !@{$current_parts} || !@{$latest_parts} ) { + return 'unknown'; + } + + if ( $latest_parts->[0] > $current_parts->[0] ) { + return 'major'; + } + if ( $latest_parts->[1] > $current_parts->[1] ) { + return 'minor'; + } + if ( $latest_parts->[2] > $current_parts->[2] ) { + return 'patch'; + } + + return 'up-to-date'; +} + +sub _version_parts { + my ( $self, $version ) = @_; + if ( !defined $version ) { + return; + } + + my $clean = $version; + $clean =~ s/^[\^~><=v\s]+//smx; + + my $parts = [ split /[.]/smx, $clean ]; + my $numeric = []; + for my $part ( @{$parts} ) { + my $match = $part; + push @{$numeric}, ( $match =~ /(\d+)/xms ? $1 : 0 ); + } + + while ( @{$numeric} < 3 ) { + push @{$numeric}, 0; + } + + return [ @{$numeric}[ 0 .. 2 ] ]; +} + +sub _analyze_dependency_paths { + my ( $self, $paths, $direct_deps ) = @_; + + my $direct = {}; + + for my $path ( @{$paths} ) { + if ( !defined $path ) { + next; + } + my $segments = [ split /\s*>\s*/smx, $path ]; + for my $segment ( @{$segments} ) { + my $pkg = $self->_normalize_dependency_segment($segment); + if ( !$pkg ) { + next; + } + if ( exists $direct_deps->{$pkg} ) { + $direct->{$pkg} = $direct_deps->{$pkg}; + } + } + } + + my $result = []; + for my $key ( sort keys %{$direct} ) { + my $entry = $direct->{$key}; + push @{$result}, + { + 'name' => $key, + 'type' => $entry->{'type'}, + 'version_spec' => $entry->{'version'}, + 'resolved_version' => $entry->{'resolved_version'}, + }; + } + return $result; +} + +sub _normalize_dependency_segment { + my ( $self, $segment ) = @_; + if ( !defined $segment ) { + return q{}; + } + + $segment =~ s/^\s+|\s+$//smxg; + if ( !length $segment ) { + return q{}; + } + + # Remove trailing version portion (but keep scoped package prefix) + $segment =~ s/\@(?=[^\/]+$)[^\/]+$//smx; + + return $segment; +} + +sub _process_yarn_nodes { + my ( $self, $nodes, $direct_deps, $verbose ) = @_; + + my $packages = {}; + my $package_index = {}; + my $direct_resolved = { + dependencies => {}, + devDependencies => {}, + }; + + my $precedence = { + transitive => 0, + devDependencies => 1, + dependencies => 2, + }; + + my $visit; + $visit = sub { + my ($node) = @_; + if ( ref $node ne 'HASH' ) { + return; + } + + for my $child ( @{ $node->{'children'} || [] } ) { + $visit->($child); + } + + if ( $node->{'shadow'} ) { + return; + } + + my $raw = $node->{'name'}; + if ( !defined $raw ) { + return; + } + + my ( $pkg, $descriptor ) = $self->_parse_yarn_identifier($raw); + if ( !$pkg || !defined $descriptor ) { + return; + } + + my $version = $self->_extract_yarn_version($descriptor); + if ( !defined $version || $version eq q{} ) { + return; + } + + my $type = 'transitive'; + if ( my $direct = $direct_deps->{$pkg} ) { + my $bucket = + $direct->{'type'} eq 'devDependencies' + ? 'devDependencies' + : 'dependencies'; + $type = $bucket; + $direct_resolved->{$bucket}{$pkg} //= $version; + } + + my $existing = $packages->{$pkg}{$version}; + if ( !defined $existing + || ( $precedence->{$type} // 0 ) > ( $precedence->{$existing} // 0 ) ) + { + $packages->{$pkg}{$version} = $type; + } + }; + + $visit->($_) for @{$nodes}; + + if ( !keys %{$packages} ) { + log_message( 'warn', $verbose, 'yarn list traversal yielded no packages' ); + return; + } + + my $package_list = []; + for my $name ( sort keys %{$packages} ) { + for my $version ( sort keys %{ $packages->{$name} } ) { + my $type = $packages->{$name}{$version}; + $package_index->{$name}{$version} = $type; + push @{$package_list}, + { + name => $name, + version => $version, + type => $type, + }; + } + } + + my $spec_map = {}; + for my $name ( keys %{$direct_deps} ) { + my $spec = $direct_deps->{$name}{'version'}; + if ( !defined $spec || $spec eq q{} ) { + next; + } + + my $bucket = + $direct_deps->{$name}{'type'} eq 'devDependencies' + ? 'devDependencies' + : 'dependencies'; + my $resolved = $direct_resolved->{$bucket}{$name}; + + if ( !defined $resolved || $resolved eq q{} ) { + next; + } + + $spec_map->{"$name\@$spec"} = $resolved; + + if ( my $normalized = normalize_semver_spec($spec) ) { + $spec_map->{"$name\@$normalized"} //= $resolved; + } + } + + my $metadata = { + source => 'yarn list --json', + tool => 'yarn', + package_count => scalar @{$package_list}, + }; + + return { + metadata => $metadata, + direct => $direct_resolved, + packages => $package_list, + spec_map => $spec_map, + index => $package_index, + }; +} + +sub _parse_yarn_identifier { + my ( $self, $identifier ) = @_; + if ( !defined $identifier ) { + return; + } + + $identifier =~ s/^\s+//smx; + $identifier =~ s/\s+$//smx; + + if ( $identifier =~ /^(@[^\/]+\/[^@]+)@(.+)$/smx ) { + return ( $1, $2 ); + } elsif ( $identifier =~ /^([^@]+)@(.+)$/smx ) { + return ( $1, $2 ); + } + + return; +} + +sub _extract_yarn_version { + my ( $self, $descriptor ) = @_; + if ( !defined $descriptor ) { + return; + } + + my $value = $descriptor; + $value =~ s/^\s+|\s+$//smxg; + + if ( $value =~ /^([^:]+):(.+)$/smx ) { + my ( $prefix, $rest ) = ( $1, $2 ); + if ( $prefix eq 'npm' ) { + return $rest; + } + return "$prefix:$rest"; + } + + $value =~ s/^[\^~=\s]+//smx; + + return length $value ? $value : undef; +} + +1; + +__END__ + +=head1 NAME + +Koha::Devel::Node::Package::Manager::Yarn - Yarn package manager strategy implementation + +=head1 SYNOPSIS + + use Koha::Devel::Node::Package::Manager::Yarn; + + my $yarn = Koha::Devel::Node::Package::Manager::Yarn->new(); + my ($outdated, $summary) = $yarn->run_outdated_check($config, $deps, $verbose); + +=head1 DESCRIPTION + +This module provides a concrete implementation of the package manager strategy +pattern for Yarn. It implements the abstract methods defined in +Koha::Devel::Node::Package::Manager::Base to handle Yarn-specific operations +including outdated checks, security audits, and dependency resolution. + +=head1 METHODS + +This class inherits from Koha::Devel::Node::Package::Manager::Base and implements +the following abstract methods: + +=head2 run_outdated_check($tool_config, $direct_deps, $verbose) + +Implements outdated dependency check for Yarn using 'yarn outdated --json' command. + +=head2 run_audit_check($tool_config, $direct_deps, $verbose) + +Implements security audit for Yarn using 'yarn audit --json' command. + +=head2 build_resolved_dependency_data($tool_config, $direct_deps, $project_root, $verbose) + +Builds complete dependency tree using 'yarn list --json' command. + +=head1 PRIVATE METHODS + +The following methods are internal to the implementation: + +=over 4 + +=item * _determine_update_type($current, $latest) + +=item * _version_parts($version) + +=item * _analyze_dependency_paths($paths, $direct_deps) + +=item * _normalize_dependency_segment($segment) + +=item * _process_yarn_nodes($nodes, $direct_deps, $verbose) + +=item * _parse_yarn_identifier($identifier) + +=item * _extract_yarn_version($descriptor) + +=back + +=head1 AUTHOR + +Koha Development Team + +=head1 COPYRIGHT + +Copyright 2025 Koha + +=cut diff --git a/Koha/Devel/Node/Utils.pm b/Koha/Devel/Node/Utils.pm new file mode 100644 index 00000000000..e3e147fa80a --- /dev/null +++ b/Koha/Devel/Node/Utils.pm @@ -0,0 +1,1044 @@ +package Koha::Devel::Node::Utils; + +# Copyright 2025 Koha Development Team +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use strict; +use warnings; +use Carp qw(carp croak); +use parent 'Exporter'; +use Cwd qw(abs_path cwd); +use File::Basename qw(dirname); +use File::Spec::Functions qw(catfile catdir updir); +use English qw(-no_match_vars); +use JSON qw(decode_json encode_json); +use POSIX qw(strftime); + +our $VERSION = '1.0.0'; +our @EXPORT_OK = qw( + log_message + load_config + find_project_root + detect_package_manager + run_command + safe_json_decode + change_to_project_root + map_dependency_info + determine_update_type + version_parts + xml_escape + generate_purl + generate_uuid + normalize_semver_spec + load_package_json + build_direct_dependency_map + write_json_results + parse_compromise_line + run_compromise_check + build_resolved_index + determine_exit_code + build_bugzilla_report_data + build_sbom_data + detect_koha_version +); + +sub log_message { + my ( $level, $verbosity, $message ) = @_; + + if ( defined $verbosity && !$verbosity && ( $level eq 'debug' || $level eq 'info' ) ) { + return; + } + + my $timestamp = strftime( '%Y-%m-%d %H:%M:%S', localtime ); + print {*STDERR} "[$timestamp] [$level] $message\n"; + return; +} + +sub load_config { + my ($config_file) = @_; + if ( !-f $config_file ) { + return; + } + + eval { require YAML; YAML->import('LoadFile'); 1; } or do { + log_message( 'warn', 1, 'YAML module not available, config loading disabled' ); + return {}; + }; + + my $config; + eval { $config = LoadFile($config_file); 1; } or do { + log_message( 'error', 1, "Failed to load config file '$config_file': $EVAL_ERROR" ); + return {}; + }; + return $config; +} + +sub find_project_root { + my $current_dir = cwd(); + + while ( $current_dir ne q{/} ) { + return $current_dir if -f catfile( $current_dir, 'package.json' ); + $current_dir = dirname($current_dir); + } + + croak "Could not find project root (package.json)\n"; +} + +sub detect_package_manager { + my $project_root = find_project_root(); + + return 'yarn' if -f catfile( $project_root, 'yarn.lock' ); + return 'npm' if -f catfile( $project_root, 'package-lock.json' ); + + log_message( 'warn', 1, 'No lock file found, defaulting to npm' ); + return 'npm'; +} + +sub run_command { + my ( $command, $args_ref ) = @_; + $args_ref //= []; + + log_message( 'debug', 0, "Running command: $command " . join q{ }, @{$args_ref} ); + + my $output = _run_command_and_capture( $command, $args_ref ); + my $exit_code = $CHILD_ERROR >> 8; + log_message( 'debug', 0, "Command exit code: $exit_code" ); + + return ( $output, $exit_code ); +} + +sub _run_command_and_capture { + my ( $command, $args_ref ) = @_; + + my $pid = open my $pipe, q{-|}; + if ( !defined $pid ) { + log_message( 'error', 1, "Cannot fork: $OS_ERROR" ); + return ( "Cannot fork: $OS_ERROR", -1 ); + } + + if ( $pid == 0 ) { + + # Use system calls for reliable STDIN redirection + require POSIX; + POSIX::dup2( POSIX::open( '/dev/null', POSIX::O_RDONLY() ), 0 ) or exit 127; + open STDERR, '>', '/dev/null' or exit 127; + exec {$command} $command, @{$args_ref} or exit 127; + } + + my $output = do { local $INPUT_RECORD_SEPARATOR = undef; <$pipe> }; + close $pipe; + + return $output; +} + +sub safe_json_decode { + my ($json_string) = @_; + if ( !defined $json_string || !length $json_string ) { + return; + } + + my $result; + eval { $result = decode_json($json_string); 1; } or do { + log_message( 'warn', 1, "Failed to parse JSON: $EVAL_ERROR" ); + return; + }; + return $result; +} + +sub change_to_project_root { + my $project_root = find_project_root(); + chdir $project_root or log_message( 'error', 1, "Cannot change to project root: $OS_ERROR" ) and return; + log_message( 'debug', 0, "Changed to project root: $project_root" ); + return $project_root; +} + +sub map_dependency_info { + my ( $packages, $name, $direct_deps ) = @_; + + if ( ref $packages ne 'HASH' || !defined $name ) { + return; + } + + my $entry = $packages->{$name} ||= {}; + my $direct = + ( $direct_deps && ref $direct_deps eq 'HASH' ) + ? $direct_deps->{$name} + : undef; + + if ($direct) { + $entry->{'type'} = $direct->{'type'} || $entry->{'type'} || 'dependencies'; + $entry->{'version_spec'} = $direct->{'version'} || $entry->{'version_spec'}; + $entry->{'resolved_version'} = $direct->{'resolved_version'} || $entry->{'resolved_version'}; + } else { + $entry->{'type'} ||= 'transitive'; + } + + return $entry; +} + +sub load_package_json { + my ($root) = @_; + my $path = catfile( $root, 'package.json' ); + + open my $fh, '<', $path or croak "Cannot open $path: $OS_ERROR"; + local $INPUT_RECORD_SEPARATOR = undef; + my $json_text = <$fh>; + close $fh or croak; + + my $data = decode_json($json_text); + if ( ref $data ne 'HASH' ) { + croak 'package.json did not parse into a hash'; + } + + return $data; +} + +sub build_direct_dependency_map { + my ($package_json) = @_; + + my $map = {}; + + for my $name ( keys %{ $package_json->{'dependencies'} || {} } ) { + my $version = $package_json->{'dependencies'}{$name}; + $map->{$name} = { + 'type' => 'dependencies', + 'version' => $version, + }; + } + + for my $name ( keys %{ $package_json->{'devDependencies'} || {} } ) { + my $version = $package_json->{'devDependencies'}{$name}; + $map->{$name} = { + 'type' => 'devDependencies', + 'version' => $version, + }; + } + + return $map; +} + +sub write_json_results { + my ( $output, $data, $verbose ) = @_; + + open my $fh, '>', $output + or croak "Cannot open $output for writing: $OS_ERROR"; + print {$fh} encode_json($data) or croak; + close $fh or croak; + + log_message( 'info', $verbose, "JSON results written to $output" ); + + return; +} + +sub parse_compromise_line { + my ($line) = @_; + + if ( !defined $line || !length $line ) { + return { error => 'empty' }; + } + + my $scoped = qr{ + @[^\/\s]+\/[\w._-]+ + }smx; + my $unscoped = qr{ + [[:alnum:]][[:alnum:]._-]* + }smx; + my $version = qr/[\S]+/smx; + + if ( $line =~ /^($scoped)@($version)$/smx ) { + return { name => $1, version => $2 }; + } + if ( $line =~ /^($scoped)[:=]($version)$/smx ) { + return { name => $1, version => $2 }; + } + if ( $line =~ /^($scoped)\s+($version)$/smx ) { + return { name => $1, version => $2 }; + } + if ( $line =~ /^($unscoped)@($version)$/smx ) { + return { name => $1, version => $2 }; + } + if ( $line =~ /^($unscoped)[:=]($version)$/smx ) { + return { name => $1, version => $2 }; + } + if ( $line =~ /^($unscoped)\s+($version)$/smx ) { + return { name => $1, version => $2 }; + } + if ( $line =~ /^($scoped)$/smx ) { + return { name => $1 }; + } + if ( $line =~ /^($unscoped)$/smx ) { + return { name => $1 }; + } + + return { error => 'unrecognized format' }; +} + +sub run_compromise_check { + my ( $queries, $resolved_data, $verbose ) = @_; + + my $queries_list = [ @{ $queries || [] } ]; + my $summary = { + total => scalar @{$queries_list}, + matches => 0, + missing => 0, + invalid => 0, + }; + + my $results = []; + + if ( !@{$queries_list} ) { + $summary->{'note'} = 'No package identifiers provided'; + return { 'summary' => $summary, 'queries' => [] }; + } + + if ( !$resolved_data || !@{ $resolved_data->{'packages'} || [] } ) { + my $error = 'Resolved dependency data unavailable; cannot evaluate package list'; + log_message( 'warn', $verbose, $error ); + $summary->{'error'} = $error; + return { 'summary' => $summary, 'queries' => [] }; + } + + my $index = $resolved_data->{'index'}; + $index ||= build_resolved_index( $resolved_data->{'packages'} ); + + for my $query ( @{$queries_list} ) { + if ( $query->{'error'} ) { + $summary->{'invalid'}++; + push @{$results}, + { + 'input' => $query->{'input'}, + 'status' => 'invalid', + 'error' => $query->{'error'}, + }; + next; + } + + my $name = $query->{'name'}; + my $spec = $query->{'version'}; + + my $available = $index->{$name} || {}; + my $versions = [ sort keys %{$available} ]; + + my $matched = []; + if ( defined $spec && length $spec ) { + my $normalized = normalize_semver_spec($spec); + for my $version ( @{$versions} ) { + if ( $version eq $spec + || ( defined $normalized && $version eq $normalized ) ) + { + push @{$matched}, + { + version => $version, + scope => $available->{$version}, + }; + } + } + } else { + @{$matched} = map { + { + version => $_, + scope => $available->{$_}, + } + } @{$versions}; + } + + my $result = { + 'input' => $query->{'input'}, + 'package' => $name, + 'version_spec' => $spec, + 'matches' => $matched, + }; + + if ( @{$matched} ) { + $result->{'status'} = 'match'; + $summary->{'matches'}++; + } else { + $result->{'status'} = 'no-match'; + $summary->{'missing'}++; + } + + push @{$results}, $result; + } + + return { summary => $summary, queries => $results }; +} + +sub build_resolved_index { + my ($packages) = @_; + my $index = {}; + for my $entry ( @{ $packages || [] } ) { + if ( !$entry->{'name'} || !$entry->{'version'} ) { + next; + } + $index->{ $entry->{'name'} }{ $entry->{'version'} } = $entry->{'type'} // 'transitive'; + } + return $index; +} + +sub determine_exit_code { + my ( $results, $thresholds ) = @_; + + $thresholds //= {}; + + my $vuln_fail = $thresholds->{'vulnerable_fail'}; + if ( !defined $vuln_fail ) { + $vuln_fail = 0; + } + my $major_fail = $thresholds->{'major_version_fail'} // 0; + my $audit_level = lc( $thresholds->{'audit_fail_level'} // 'moderate' ); + my $compromise_fail = $thresholds->{'compromise_fail_on_match'}; + if ( !defined $compromise_fail ) { + $compromise_fail = 1; + } + + my $severity_rank = { + info => 0, + low => 1, + moderate => 2, + high => 3, + critical => 4, + unknown => 5, + }; + + my $vulnerabilities = $results->{'vulnerabilities'} || []; + + if ( $vuln_fail && @{$vulnerabilities} ) { + return 1; + } + + if ( @{$vulnerabilities} ) { + my $threshold_rank = $severity_rank->{$audit_level} // $severity_rank->{'moderate'}; + + for my $vuln ( @{$vulnerabilities} ) { + my $rank = $severity_rank->{ lc( $vuln->{'severity'} || q{} ) } // $severity_rank->{'unknown'}; + if ( $rank >= $threshold_rank ) { + return 1; + } + } + } + + if ($major_fail) { + for my $pkg ( keys %{ $results->{'outdated'} || {} } ) { + my $info = $results->{'outdated'}{$pkg}; + if ( !$info->{'update'} || $info->{'update'} ne 'major' ) { + next; + } + return 1; + } + } + + if ( my $comp = $results->{'compromise_check'} ) { + if ( $comp->{'summary'}{'error'} ) { + return 1; + } + if ( $compromise_fail && ( $comp->{'summary'}{'matches'} || 0 ) > 0 ) { + return 1; + } + } + + return 0; +} + +sub determine_update_type { + my ( $current, $latest ) = @_; + + if ( !defined $current || !defined $latest ) { + return 'unknown'; + } + + my $current_parts = version_parts($current); + my $latest_parts = version_parts($latest); + + if ( !@{$current_parts} || !@{$latest_parts} ) { + return 'unknown'; + } + + if ( $latest_parts->[0] > $current_parts->[0] ) { + return 'major'; + } + if ( $latest_parts->[1] > $current_parts->[1] ) { + return 'minor'; + } + if ( $latest_parts->[2] > $current_parts->[2] ) { + return 'patch'; + } + + return 'up-to-date'; +} + +sub version_parts { + my ($version) = @_; + if ( !defined $version ) { + return; + } + + my $clean = $version; + $clean =~ s/^[\^~><=v\s]+//smx; + + my $parts = [ split /[.]/smx, $clean ]; + my $numeric = []; + for my $part ( @{$parts} ) { + my $match = $part; + push @{$numeric}, ( $match =~ /(\d+)/xms ? $1 : 0 ); + } + + while ( @{$numeric} < 3 ) { + push @{$numeric}, 0; + } + + return [ @{$numeric}[ 0 .. 2 ] ]; +} + +sub xml_escape { + my ($text) = @_; + if ( !defined $text ) { + return q{}; + } + my $escaped = $text; + $escaped =~ s/&/&/smxg; + $escaped =~ s//>/smxg; + $escaped =~ s/"/"/smxg; + $escaped =~ s/'/'/smxg; + return $escaped; +} + +sub generate_purl { + my ( $name, $version ) = @_; + + my $component = $name; + $component =~ s/\@/%40/smx; + + return "pkg:npm/$component\@$version"; +} + +sub generate_uuid { + my $hex = [ 0 .. 9, 'a' .. 'f' ]; + my $rand = sub { $hex->[ int rand @{$hex} ] }; + + return join q{}, + map { $_ } ( + map { $rand->() } 1 .. 8, + q{-}, + map { $rand->() } 1 .. 4, + q{-}, + map { $rand->() } 1 .. 4, + q{-}, + map { $rand->() } 1 .. 4, + q{-}, + map { $rand->() } 1 .. 12 + ); +} + +sub normalize_semver_spec { + my ($spec) = @_; + if ( !defined $spec ) { + return; + } + + my $normalized = $spec; + $normalized =~ s/^\s+|\s+$//smxg; + $normalized =~ s/^[\^~=\s]+//smx; + + return length $normalized ? $normalized : undef; +} + +sub build_sbom_data { + my ( $results, $sbom_cfg, $package_json, $tool_version, $koha_version ) = @_; + + $results ||= {}; + $sbom_cfg ||= {}; + $package_json ||= {}; + $tool_version ||= '1.0.0'; + $koha_version ||= q{}; + + my $template_name = $sbom_cfg->{'template'} || 'dependency_sbom.xml.tt'; + + my $include_dev = $sbom_cfg->{'include_dev_dependencies'} // 0; + + my $resolved = $results->{'resolved_dependencies'}; + + my $scope_map = { + 'dependencies' => 'required', + 'devDependencies' => 'development', + 'transitive' => 'transitive', + }; + + my $seen = {}; + my $components = []; + + if ( $resolved && @{ $resolved->{'packages'} || [] } ) { + for my $entry ( @{ $resolved->{'packages'} } ) { + my $name = $entry->{'name'}; + my $version = $entry->{'version'}; + my $type = $entry->{'type'} || 'transitive'; + + if ( $type eq 'devDependencies' && !$include_dev ) { + next; + } + + my $scope = $scope_map->{$type} || 'unknown'; + my $key = join q{|}, $name, $version, $scope; + if ( $seen->{$key}++ ) { + next; + } + + push @{$components}, + _sbom_component( $name, $version, $scope ); + } + } else { + for my $name ( sort keys %{ $package_json->{'dependencies'} || {} } ) { + my $version = $package_json->{'dependencies'}{$name}; + push @{$components}, + _sbom_component( $name, $version, $scope_map->{'dependencies'} ); + } + + if ($include_dev) { + for my $name ( sort keys %{ $package_json->{'devDependencies'} || {} } ) { + my $version = $package_json->{'devDependencies'}{$name}; + push @{$components}, + _sbom_component( $name, $version, $scope_map->{'devDependencies'} ); + } + } + } + + my $timestamp = strftime '%Y-%m-%dT%H:%M:%SZ', gmtime; + my $serial = generate_uuid(); + my $app_version = $koha_version || $package_json->{'version'} || 'unknown'; + + my $data = { + timestamp => $timestamp, + serial => $serial, + app_version => $app_version, + tool_name => 'node_audit_dependencies.pl', + tool_version => $tool_version, + components => $components, + }; + + return ( $template_name, $data ); +} + +sub _sbom_component { + my ( $name, $version, $scope ) = @_; + + my $clean_version = $version // 'unknown'; + $clean_version =~ s/^[\^~><=v\s]+//smx; + + return { + name => $name, + version => $clean_version || 'unknown', + scope => $scope, + purl => generate_purl( $name, $clean_version || 'unknown' ), + }; +} + +sub detect_koha_version { + my $project_root = eval { find_project_root() }; + if ( !$project_root ) { + return; + } + + my $path = catfile( $project_root, 'Koha.pm' ); + if ( !-f $path ) { + return; + } + + open my $fh, '<', $path or do { + log_message( 'warn', 1, "Unable to open Koha.pm: $OS_ERROR" ); + return; + }; + local $INPUT_RECORD_SEPARATOR = undef; + my $content = <$fh>; + close $fh; + + if ( $content && $content =~ /\$VERSION\s*=\s*["']([^"']+)["']/smx ) { + return $1; + } + + return; +} + +sub build_bugzilla_report_data { + my ( $results, $bugzilla_cfg ) = @_; + + $results ||= {}; + $bugzilla_cfg ||= {}; + + my $template_name = $bugzilla_cfg->{'template'} || 'dependency_bugzilla_report.md.tt'; + + my $show_vulns = + exists $bugzilla_cfg->{'show_vulnerabilities'} + ? $bugzilla_cfg->{'show_vulnerabilities'} + : 1; + my $show_outdated = + exists $bugzilla_cfg->{'show_outdated'} + ? $bugzilla_cfg->{'show_outdated'} + : 1; + my $show_next_steps = + exists $bugzilla_cfg->{'show_next_steps'} + ? $bugzilla_cfg->{'show_next_steps'} + : 1; + my $include_summary = + exists $bugzilla_cfg->{'summary'} + ? $bugzilla_cfg->{'summary'} + : 1; + + my $dependency_path_limit = $bugzilla_cfg->{'dependency_path_limit'} // 0; + my $severity_order_cfg = $bugzilla_cfg->{'severity_order'}; + my $metadata_static = $bugzilla_cfg->{'metadata'}; + my $metadata_env = $bugzilla_cfg->{'metadata_env'}; + + my $default_severity_order = [qw(critical high moderate low info unknown)]; + my $severity_order = [ @{ ref $severity_order_cfg eq 'ARRAY' ? $severity_order_cfg : $default_severity_order } ]; + + my $severity_rank = {}; + for my $idx ( 0 .. $#{$severity_order} ) { + my $key = lc $severity_order->[$idx]; + $severity_rank->{$key} = $idx; + } + my $fallback_rank = scalar @{$severity_order}; + + my $vuln_source = $results->{'vulnerabilities'} || []; + my $outdated_source = $results->{'outdated'} || {}; + + my $severity_counts = {}; + my $vulnerability_rows = []; + + my $ordered_severities = []; + + if ($show_vulns) { + for my $vuln ( @{$vuln_source} ) { + my $severity = lc( $vuln->{'severity'} // 'unknown' ); + my $rank = exists $severity_rank->{$severity} ? $severity_rank->{$severity} : $fallback_rank; + $severity_counts->{$severity}++; + + my $paths = [ @{ $vuln->{'dependency_paths'} || [] } ]; + my $omitted = 0; + if ( $dependency_path_limit && $dependency_path_limit > 0 && @{$paths} > $dependency_path_limit ) { + $omitted = @{$paths} - $dependency_path_limit; + @{$paths} = @{$paths}[ 0 .. $dependency_path_limit - 1 ]; + } + + my $direct_list = []; + for my $dep ( @{ $vuln->{'direct_dependencies'} || [] } ) { + my $scope = + ( $dep->{'type'} && $dep->{'type'} eq 'devDependencies' ) ? 'dev' + : ( $dep->{'type'} && $dep->{'type'} eq 'dependencies' ) ? 'prod' + : 'unknown'; + my @pieces = ( sprintf '%s (%s)', $dep->{'name'} // q{}, $scope ); + if ( my $spec = $dep->{'version_spec'} ) { + push @pieces, sprintf 'spec %s', $spec; + } + if ( my $resolved = $dep->{'resolved_version'} ) { + push @pieces, sprintf 'resolved %s', $resolved; + } + push @{$direct_list}, + { + info => join '; ', @pieces, + }; + } + + push @{$vulnerability_rows}, + { + package => $vuln->{'package'} // q{}, + severity => $severity, + severity_label => uc $severity, + _severity_rank => $rank, + current_version => $vuln->{'current_version'} // 'unknown', + fixed_in => $vuln->{'fixed_in'}, + recommendation => $vuln->{'recommendation'}, + url => $vuln->{'url'}, + dependency_paths => $paths, + dependency_paths_omitted => $omitted, + direct_dependencies => $direct_list, + }; + } + + @{$vulnerability_rows} = sort { + ( $a->{_severity_rank} <=> $b->{_severity_rank} ) + || ( lc( $a->{package} ) cmp lc( $b->{package} ) ) + || ( ( $a->{current_version} // q{} ) cmp( $b->{current_version} // q{} ) ) + } @{$vulnerability_rows}; + + for my $entry ( @{$vulnerability_rows} ) { + delete $entry->{_severity_rank}; + } + + my $seen = {}; + for my $sev ( @{$severity_order} ) { + if ( !$severity_counts->{$sev} ) { + next; + } + push @{$ordered_severities}, $sev; + $seen->{$sev} = 1; + } + for my $sev ( sort keys %{$severity_counts} ) { + if ( $seen->{$sev} ) { + next; + } + push @{$ordered_severities}, $sev; + } + } + + my $outdated_rows = []; + my $update_counts = {}; + my $default_update_order = [qw(major minor patch up-to-date unknown)]; + my $update_seen = {}; + my $ordered_updates = []; + + if ($show_outdated) { + for my $pkg ( sort keys %{$outdated_source} ) { + my $info = $outdated_source->{$pkg} || {}; + my $update = lc( $info->{'update'} // 'unknown' ); + $update_counts->{$update}++; + + push @{$outdated_rows}, + { + name => $pkg, + current => $info->{'current'} // 'unknown', + wanted => $info->{'wanted'} // 'unknown', + latest => $info->{'latest'} // 'unknown', + resolved => $info->{'resolved'}, + scope => $info->{'scope'}, + update => $info->{'update'} // 'unknown', + }; + } + + for my $type ( @{$default_update_order} ) { + if ( !$update_counts->{$type} ) { + next; + } + push @{$ordered_updates}, $type; + $update_seen->{$type} = 1; + } + for my $type ( sort keys %{$update_counts} ) { + if ( $update_seen->{$type} ) { + next; + } + push @{$ordered_updates}, $type; + } + } + + my $summary = { + vulnerabilities => { + total => $show_vulns ? scalar @{$vulnerability_rows} : 0, + severity_counts => $show_vulns ? $severity_counts : {}, + hidden => $show_vulns ? 0 : scalar @{$vuln_source}, + severity_order => $show_vulns ? $ordered_severities : [], + }, + outdated => { + total => $show_outdated ? scalar @{$outdated_rows} : 0, + update_counts => $show_outdated ? $update_counts : {}, + hidden => $show_outdated ? 0 : scalar keys %{$outdated_source}, + update_order => $show_outdated ? $ordered_updates : [], + }, + }; + + my $metadata = {}; + if ( ref $metadata_static eq 'HASH' && keys %{$metadata_static} ) { + $metadata = { %{$metadata_static} }; + } + + if ( ref $metadata_env eq 'ARRAY' ) { + for my $entry ( @{$metadata_env} ) { + if ( !defined $entry ) { + next; + } + my ( $label, $env_key ); + if ( ref $entry eq 'HASH' ) { + ( $label, $env_key ) = each %{$entry}; + } elsif ( $entry =~ /\A(.+?)=(.+)\z/smx ) { + ( $label, $env_key ) = ( $1, $2 ); + } else { + $label = $entry; + $env_key = $entry; + } + if ( !defined $env_key || $env_key eq q{} ) { + next; + } + my $value = $ENV{$env_key}; + if ( !defined $value || $value eq q{} ) { + next; + } + $metadata->{$label} = $value; + } + } + + my $metadata_payload = ( keys %{$metadata} ) ? $metadata : undef; + + my $payload = { + generated_at => $results->{'generated_at'}, + tool => $results->{'tool'}, + vulnerabilities => $vulnerability_rows, + outdated => $outdated_rows, + summary => $include_summary ? $summary : undef, + metadata => $metadata_payload, + config => { + show_vulnerabilities => $show_vulns ? 1 : 0, + show_outdated => $show_outdated ? 1 : 0, + show_next_steps => $show_next_steps ? 1 : 0, + include_summary => $include_summary ? 1 : 0, + dependency_path_limit => $dependency_path_limit, + severity_order => $severity_order, + }, + }; + + return ( $template_name, $payload ); +} + +1; + +__END__ + +=head1 NAME + +Koha::Devel::Node::Utils - Utility functions for Node.js dependency management + +=head1 SYNOPSIS + + use Koha::Devel::Node::Utils qw(log_message safe_json_decode); + + log_message('info', 1, 'Processing dependencies'); + my $data = safe_json_decode($json_string); + +=head1 DESCRIPTION + +This module provides reusable utility functions for Node.js dependency +management operations including logging, configuration loading, JSON parsing, +and command execution. + +=head1 FUNCTIONS + +=head2 log_message($level, $verbose, $message) + +Outputs a timestamped log message to STDERR. Debug and info messages are only +emitted when C<$verbose> is truthy; warnings and errors are always printed. + +=head2 load_config($config_file) + +Loads and parses a YAML configuration file. + +=head2 find_project_root() + +Finds the project root directory by looking for package.json. + +=head2 detect_package_manager() + +Detects which package manager is being used (yarn or npm). + +=head2 run_command($command, $args_ref) + +Executes a command with proper error handling. + +=head2 safe_json_decode($json_string) + +Safely decodes JSON with error handling. + +=head2 change_to_project_root() + +Changes to the project root directory. + +=head2 map_dependency_info($packages, $name, $direct_deps) + +Augments C<$packages->{$name}> with direct dependency metadata. + +=head2 load_package_json($root) + +Loads and decodes F from the provided project root. + +=head2 build_direct_dependency_map($package_json) + +Builds a lookup hash describing each direct dependency, including its type and +version specification. + +=head2 write_json_results($output, $data, $verbose) + +Writes combined JSON results to disk and emits an informational log message +when verbosity permits. + +=head2 parse_compromise_line($line) + +Parses a single package identifier used by the compromise helper, supporting +scoped packages and explicit version specifiers. + +=head2 run_compromise_check($queries, $resolved_data, $verbose) + +Evaluates compromise queries against the resolved dependency graph and returns +match summaries. + +=head2 build_resolved_index($packages) + +Builds a hash index of resolved packages keyed by name and version. + +=head2 determine_exit_code($results, $thresholds) + +Computes the dependency-check exit code based on configured thresholds and the +aggregated results structure. + +=head2 determine_update_type($current, $latest) + +Determines type of update needed (major, minor, patch, up-to-date). + +=head2 version_parts($version) + +Extracts numeric version parts from version string. + +=head2 xml_escape($text) + +Escapes text for XML output. + +=head2 generate_purl($name, $version) + +Generates package URL (purl) for npm package. + +=head2 generate_uuid() + +Generates a random UUID v4. + +=head2 normalize_semver_spec($spec) + +Normalizes a semver specification by trimming whitespace and stripping leading +range operators (for example C<^>, C<~>, or C<=>). Returns the cleaned version +string, or C if nothing meaningful remains. + +=head2 build_sbom_data($results, $sbom_cfg, $package_json, $tool_version, $koha_version) + +Builds the data structure and selects the template required to generate the +CycloneDX SBOM report. The returned list contains the template filename and a +hashref with timestamps, tool metadata, and the component list filtered by the +supplied configuration. + +=head2 build_bugzilla_report_data($results, $bugzilla_cfg) + +Assembles vulnerability and outdated dependency data for the Bugzilla Markdown +report. Configuration flags control which sections are included, severity +ordering, and any metadata overrides merged into the rendered output. + +=head2 detect_koha_version() + +Attempts to read C<$VERSION> from F located at the project root and +returns it when available. Returns C if the version cannot be found. + +=head1 AUTHOR + +Koha Development Team + +=head1 COPYRIGHT + +Copyright 2025 Koha Development Team + +=cut diff --git a/Koha/Devel/Node/templates/dependency_bugzilla_report.md.tt b/Koha/Devel/Node/templates/dependency_bugzilla_report.md.tt new file mode 100644 index 00000000000..77eaa28d935 --- /dev/null +++ b/Koha/Devel/Node/templates/dependency_bugzilla_report.md.tt @@ -0,0 +1,80 @@ +# Node.js Dependency Security Report + +**Generated**: [% generated_at | html %] +**Package Manager**: [% tool | html %] + +## Security Vulnerabilities + +[% IF vulnerabilities.size %] +[% FOREACH vuln IN vulnerabilities -%] +### [% vuln.package | html %] ([% vuln.severity_label | html %]) + +**Package**: [% vuln.package | html %] +**Severity**: [% vuln.severity_label | html %] +**Current Version**: [% vuln.current_version | html %] + +[% IF vuln.fixed_in %] +**Fixed In**: [% vuln.fixed_in | html %] +**Recommendation**: Update to [% vuln.fixed_in | html %] +[% ELSIF vuln.recommendation %] +**Recommendation**: [% vuln.recommendation | html %] +[% ELSE %] +**Recommendation**: Monitor for updates +[% END %] + +[% IF vuln.url %] +**Details**: [% vuln.url | html %] +[% END %] + +[% IF vuln.dependency_paths.size %] +**Dependency Paths**: +``` +[% FOREACH path IN vuln.dependency_paths -%] +[% path | html %] +[% END -%] +``` +[% END %] + +[% IF vuln.direct_dependencies.size %] +**Direct Dependencies**: +[% FOREACH dep IN vuln.direct_dependencies -%] +- [% dep.info | html %] +[% END %] + +[% END %] + +[% END -%] +[% ELSE %] +No vulnerabilities detected at configured severity threshold. +[% END %] + +## Outdated Packages + +[% IF outdated.size %] +[% FOREACH item IN outdated -%] +### [% item.name | html %] + +**Current**: [% item.current | html %] +**Wanted**: [% item.wanted | html %] +**Latest**: [% item.latest | html %] + +[% IF item.resolved %] +**Resolved**: [% item.resolved | html %] +[% END %] + +[% IF item.scope %] +**Scope**: [% item.scope | html %] +[% END %] + +**Update Type**: [% item.update | html %] + +[% END -%] +[% ELSE %] +All listed dependencies are on the latest locked versions. +[% END %] + +## Next Steps + +1. Review vulnerabilities and schedule fixes in Bugzilla. +2. Plan updates for major-version or security related packages. +3. Validate dependency changes in a controlled environment. diff --git a/Koha/Devel/Node/templates/dependency_sbom.xml.tt b/Koha/Devel/Node/templates/dependency_sbom.xml.tt new file mode 100644 index 00000000000..2bd78fb7b8e --- /dev/null +++ b/Koha/Devel/Node/templates/dependency_sbom.xml.tt @@ -0,0 +1,27 @@ + + + + [% timestamp | html %] + + + Koha Community + [% tool_name | html %] + [% tool_version | html %] + + + + Koha + [% app_version | html %] + + + +[% FOREACH component IN components -%] + + [% component.name | html %] + [% component.version | html %] + [% component.purl | html %] + [% component.scope | html %] + +[% END -%] + + diff --git a/misc/devel/node_audit_compromise.pl b/misc/devel/node_audit_compromise.pl new file mode 100755 index 00000000000..92decf532a4 --- /dev/null +++ b/misc/devel/node_audit_compromise.pl @@ -0,0 +1,321 @@ +#!/usr/bin/perl + +# Copyright 2025 Koha Development Team +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use Carp qw(croak); +use Cwd qw(abs_path); +use English qw(-no_match_vars); +use File::Spec::Functions qw(catfile); +use Getopt::Long qw(GetOptions); +use POSIX qw(strftime isatty); +use Pod::Usage qw(pod2usage); + +use FindBin; +use lib $FindBin::Bin; + +use Koha::Devel::Node::Utils qw( + log_message + load_config + find_project_root + detect_package_manager + change_to_project_root + load_package_json + build_direct_dependency_map + parse_compromise_line + run_compromise_check + write_json_results + determine_exit_code +); + +use Koha::Devel::Node::Package::Manager::Base; +use Koha::Devel::Node::Package::Manager::Yarn; +use Koha::Devel::Node::Package::Manager::Npm; + +sub main { + my $opt_config_file = q{}; + my $opt_json_output = q{}; + my $opt_input_file = q{}; + my $opt_inline = q{}; + my $opt_stdin = 0; + my $opt_verbose = 0; + my $opt_help = 0; + + GetOptions( + 'config-file=s' => \$opt_config_file, + 'json=s' => \$opt_json_output, + 'input-file=s' => \$opt_input_file, + 'inline=s' => \$opt_inline, + 'stdin' => \$opt_stdin, + 'verbose' => \$opt_verbose, + 'help' => \$opt_help, + ) or pod2usage(2); + + if ($opt_help) { + pod2usage(0); + } + + my $queries = build_queries( + { + input_file => $opt_input_file, + inline => $opt_inline, + stdin => $opt_stdin, + } + ); + + my $script_dir = $FindBin::Bin; + my $default_cfg = catfile( $script_dir, 'node_audit_config.yml' ); + my $config_path = $opt_config_file ? abs_path($opt_config_file) : $default_cfg; + if ($opt_config_file) { + $config_path //= $opt_config_file; + } + + my $config = load_config($config_path); + my $project_root = find_project_root(); + my $package_json = load_package_json($project_root); + my $direct_deps = build_direct_dependency_map($package_json); + my $tool = detect_package_manager(); + + my $tool_cfg = {}; + if ( $config && ref $config eq 'HASH' && $config->{'tools'} && $config->{'tools'}{$tool} ) { + $tool_cfg = $config->{'tools'}{$tool}; + } + + my $strategy = create_package_manager_strategy($tool); + my $resolved_data = + $strategy->build_resolved_dependency_data( $tool_cfg, $direct_deps, $project_root, $opt_verbose ); + + if ( $resolved_data && $resolved_data->{'direct'} ) { + for my $section (qw(dependencies devDependencies)) { + my $resolved_section = $resolved_data->{'direct'}{$section} || {}; + for my $name ( keys %{$resolved_section} ) { + next if !$direct_deps->{$name}; + next if $direct_deps->{$name}{'type'} ne $section; + $direct_deps->{$name}{'resolved_version'} = $resolved_section->{$name}; + } + } + } + + change_to_project_root($project_root); + + log_message( 'info', $opt_verbose, "Using package manager: $tool" ); + + my $results = { + 'tool' => $tool, + 'generated_at' => strftime( '%Y-%m-%dT%H:%M:%SZ', gmtime ), + 'metadata' => { + 'project_root' => $project_root, + 'config_file' => $config_path, + }, + 'direct_dependencies' => $direct_deps, + 'resolved_dependencies' => $resolved_data, + }; + + my $compromise = run_compromise_check( $queries, $resolved_data, $opt_verbose ); + $results->{'compromise_check'} = $compromise; + + my $json_output; + if ( defined $opt_json_output && length $opt_json_output ) { + $json_output = $opt_json_output; + } else { + $json_output = catfile( $project_root, 'dependency_check_results.json' ); + } + + write_json_results( $json_output, $results, $opt_verbose ); + + my $thresholds = {}; + if ( $config && ref $config eq 'HASH' && $config->{'thresholds'} ) { + $thresholds = $config->{'thresholds'}; + } + + my $exit_code = determine_exit_code( $results, $thresholds ); + log_message( 'info', $opt_verbose, "Exit code: $exit_code" ); + return $exit_code; +} + +sub build_queries { + my ($args) = @_; + + my $lines = []; + + if ( my $file = $args->{input_file} ) { + if ( !-e $file ) { + croak "Input file '$file' not found"; + } + open my $fh, '<', $file or croak "Cannot open $file: $OS_ERROR"; + push @{$lines}, <$fh>; + close $fh or croak; + } + + if ( defined $args->{inline} && length $args->{inline} ) { + push @{$lines}, map { "$_\n" } split /\n/smx, $args->{inline}; + } + + my $should_read_stdin = $args->{stdin}; + if ( !$should_read_stdin && !@{$lines} ) { + my $stdin_fd = fileno STDIN; + if ( !defined $stdin_fd || !isatty($stdin_fd) ) { + $should_read_stdin = 1; + } + } + + if ($should_read_stdin) { + push @{$lines}, ; + } + + my $queries = []; + for my $raw_line ( @{$lines} ) { + my $line = $raw_line; + $line =~ s/\r?\n$//smx; + my $trimmed = $line; + $trimmed =~ s/^\s+|\s+$//smxg; + + if ( $trimmed eq q{} ) { + next; + } + if ( index( $trimmed, q{#} ) == 0 ) { + next; + } + if ( $trimmed =~ /^\s*\/\//smx ) { + next; + } + + my $parsed = parse_compromise_line($trimmed); + if ( $parsed->{'error'} ) { + push @{$queries}, + { + 'input' => $trimmed, + 'error' => $parsed->{'error'}, + }; + } else { + $parsed->{'input'} = $trimmed; + push @{$queries}, $parsed; + } + } + + return $queries; +} + +sub create_package_manager_strategy { + my ($tool) = @_; + + if ( $tool eq 'yarn' ) { + return Koha::Devel::Node::Package::Manager::Yarn->new; + } elsif ( $tool eq 'npm' ) { + return Koha::Devel::Node::Package::Manager::Npm->new; + } + + croak "Unsupported package manager: $tool"; +} + +exit main(); + +__END__ + +=head1 NAME + +node_audit_compromise.pl - Standalone Node.js compromise detection helper + +=head1 SYNOPSIS + + node_audit_compromise.pl [options] + +=head1 DESCRIPTION + +Reads a list of package identifiers, resolves the current dependency graph, and +reports matches. Output is emitted as JSON for downstream automation, and exit +codes honour the thresholds defined in C. + +=head1 OPTIONS + +=over 4 + +=item B<--input-file FILE> + +Read package identifiers from FILE (one per line). + +=item B<--inline STRING> + +Provide newline-separated package identifiers directly on the command line. + +=item B<--stdin> + +Force reading package identifiers from STDIN. When neither C<--input-file> nor +C<--inline> is provided, the script automatically reads STDIN when it is not a +TTY. + +=item B<--config-file FILE> + +Use an alternate configuration file. Defaults to +C. + +=item B<--json FILE> + +Write combined JSON results to FILE. Defaults to +C in the project root. + +=item B<--verbose> + +Emit debug logging. + +=item B<--help> + +Show usage information. + +=back + +=head1 OUTPUT + +The JSON payload mirrors the structure produced by +C and adds a C section with: + +=over 4 + +=item * C - totals for matches, missing packages, and invalid inputs. + +=item * C - per-identifier results containing C (C, +C, or C), the normalised package name, and resolved versions. + +=back + +Helpful C snippets when reviewing the output: + +=over 4 + +=item * C - quick +totals for compromise queries. + +=item * C +- list only matching packages. + +=back + +Exit codes follow the threshold settings defined in +C; the helper exits non-zero when a match is +found and C is enabled. + +=head1 AUTHOR + +Koha Development Team + +=head1 COPYRIGHT + +Copyright 2025 Koha + +=cut diff --git a/misc/devel/node_audit_config.yml b/misc/devel/node_audit_config.yml new file mode 100644 index 00000000000..24bc5aa1d5d --- /dev/null +++ b/misc/devel/node_audit_config.yml @@ -0,0 +1,51 @@ +# Node.js dependency management configuration + +tools: + yarn: + check: 'yarn --version' + install: 'yarn install --frozen-lockfile --ignore-scripts' + commands: + outdated: 'yarn outdated --json' + audit: 'yarn audit --json --level moderate' + list: 'yarn list --json' + why: 'yarn why' + npm: + check: 'npm --version' + install: 'npm ci --ignore-scripts' + commands: + outdated: 'npm outdated --json' + audit: 'npm audit --json --audit-level moderate' + list: 'npm list --json' + why: 'npm explain' + +detection_order: + - yarn + - npm + +sbom: + output_file: 'node_modules_sbom.xml' + template: 'dependency_sbom.xml.tt' + include_dev_dependencies: true + +bugzilla: + output_file: 'dependency_bugzilla_report.md' + template: 'dependency_bugzilla_report.md.tt' + show_vulnerabilities: true + show_outdated: true + show_next_steps: true + summary: true + dependency_path_limit: 0 # 0 disables truncation, use positive integer to limit paths per vulnerability + severity_order: + - critical + - high + - moderate + - low + - info + metadata: {} + metadata_env: [] # entries may be ENV_NAME or label=ENV_NAME + +thresholds: + audit_fail_level: 'moderate' + major_version_fail: 1 + vulnerable_fail: 0 # set to 1 to fail if any vulnerability is detected + compromise_fail_on_match: 1 # fail if compromised package check finds a hit diff --git a/misc/devel/node_audit_dependencies.pl b/misc/devel/node_audit_dependencies.pl new file mode 100755 index 00000000000..ebed909a3e0 --- /dev/null +++ b/misc/devel/node_audit_dependencies.pl @@ -0,0 +1,537 @@ +#!/usr/bin/perl + +# Copyright 2025 Koha Development Team +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +our $VERSION = '1.0.0'; + +use Carp qw(croak); +use Cwd qw(abs_path getcwd); +use English qw(-no_match_vars); +use File::Basename qw(dirname); +use File::Spec::Functions qw(catfile catdir updir); +use Getopt::Long qw(GetOptions); +use JSON qw(decode_json encode_json); +use POSIX qw(strftime); +use Pod::Usage qw(pod2usage); +use YAML qw(LoadFile); +use Template; + +use Koha::Devel::Node::Utils qw( + log_message + load_config + find_project_root + detect_package_manager + run_command + safe_json_decode + change_to_project_root + map_dependency_info + determine_update_type + version_parts + generate_purl + generate_uuid + normalize_semver_spec + load_package_json + build_direct_dependency_map + write_json_results + determine_exit_code + build_bugzilla_report_data + build_sbom_data + detect_koha_version +); + +use Koha::Devel::Node::Package::Manager::Base; +use Koha::Devel::Node::Package::Manager::Yarn; +use Koha::Devel::Node::Package::Manager::Npm; + +sub get_context { + state $context = {}; + return $context; +} + +sub set_context { + my ( $key, $value ) = @_; + get_context()->{$key} = $value; + return; +} + +sub get_verbose { + return get_context()->{'verbose'} // 0; +} + +sub get_config { + return get_context()->{'config'} // {}; +} + +sub get_project_root { + return get_context()->{'project_root'}; +} + +sub get_tool { + return get_context()->{'tool'}; +} + +sub get_tool_config { + return get_context()->{'tool_config'} // {}; +} + +sub get_template_dir { + return get_context()->{'template_dir'}; +} + +sub get_direct_deps { + return get_context()->{'direct_deps'} // {}; +} + +sub get_resolved_data { + return get_context()->{'resolved_data'}; +} + +sub get_package_json { + return get_context()->{'package_json'} // {}; +} + +sub initialize_context { + my ($args) = @_; + + set_context( 'verbose', $args->{'verbose'} // 0 ); + set_context( 'config', $args->{'config'} // {} ); + set_context( 'project_root', $args->{'project_root'} ); + set_context( 'tool', $args->{'tool'} ); + set_context( 'tool_config', $args->{'tool_config'} // {} ); + set_context( 'direct_deps', $args->{'direct_deps'} // {} ); + set_context( 'resolved_data', $args->{'resolved_data'} ); + set_context( 'package_json', $args->{'package_json'} // {} ); + set_context( 'template_dir', $args->{'template_dir'} ); + + return; +} + +sub create_package_manager_strategy { + my ($tool) = @_; + + if ( $tool eq 'yarn' ) { + return Koha::Devel::Node::Package::Manager::Yarn->new; + } elsif ( $tool eq 'npm' ) { + return Koha::Devel::Node::Package::Manager::Npm->new; + } else { + croak "Unsupported package manager: $tool"; + } +} + +sub main { + my $opt_outdated = 0; + my $opt_audit = 0; + my $opt_sbom = 0; + my $opt_bugzilla = 0; + my $opt_verbose = 0; + my $opt_config_file = q{}; + my $opt_json_output = q{}; + my $opt_help = 0; + + GetOptions( + 'outdated' => \$opt_outdated, + 'audit' => \$opt_audit, + 'sbom' => \$opt_sbom, + 'bugzilla' => \$opt_bugzilla, + 'verbose' => \$opt_verbose, + 'config-file=s' => \$opt_config_file, + 'json=s' => \$opt_json_output, + 'help' => \$opt_help, + ) or pod2usage(2); + + if ($opt_help) { + pod2usage(0); + } + + if ( !$opt_outdated && !$opt_audit && !$opt_sbom && !$opt_bugzilla ) { + $opt_outdated = 1; + $opt_audit = 1; + $opt_sbom = 1; + $opt_bugzilla = 1; + } + + my $script_dir = dirname( abs_path($PROGRAM_NAME) ); + my $template_dir = catdir( $script_dir, q{..}, q{..}, 'Koha', 'Devel', 'Node', 'templates' ); + my $default_cfg = catfile( $script_dir, 'node_audit_config.yml' ); + my $config_path = $opt_config_file ? abs_path($opt_config_file) : $default_cfg; + if ($opt_config_file) { + $config_path //= $opt_config_file; + } + my $config = load_config($config_path); + my $project_root = find_project_root(); + my $package_json = load_package_json($project_root); + my $direct_deps = build_direct_dependency_map($package_json); + my $tool = detect_package_manager(); + my $tool_cfg = {}; + if ( $config && ref $config eq 'HASH' && $config->{'tools'} && $config->{'tools'}{$tool} ) { + $tool_cfg = $config->{'tools'}{$tool}; + } + my $strategy = create_package_manager_strategy($tool); + my $resolved_data = + $strategy->build_resolved_dependency_data( $tool_cfg, $direct_deps, $project_root, $opt_verbose ); + + if ( $resolved_data && $resolved_data->{'direct'} ) { + for my $section (qw(dependencies devDependencies)) { + my $resolved_section = $resolved_data->{'direct'}{$section} || {}; + for my $name ( keys %{$resolved_section} ) { + if ( !$direct_deps->{$name} ) { + next; + } + if ( $direct_deps->{$name}{'type'} ne $section ) { + next; + } + $direct_deps->{$name}{'resolved_version'} = $resolved_section->{$name}; + } + } + } + + initialize_context( + { + 'verbose' => $opt_verbose, + 'config' => $config, + 'project_root' => $project_root, + 'tool' => $tool, + 'tool_config' => $tool_cfg, + 'direct_deps' => $direct_deps, + 'resolved_data' => $resolved_data, + 'package_json' => $package_json, + 'template_dir' => $template_dir, + } + ); + + change_to_project_root($project_root); + + log_message( 'info', $opt_verbose, "Using package manager: $tool" ); + + my $results = { + 'tool' => $tool, + 'generated_at' => strftime( '%Y-%m-%dT%H:%M:%SZ', gmtime ), + 'metadata' => { + 'project_root' => $project_root, + 'config_file' => $config_path, + }, + 'direct_dependencies' => $direct_deps, + 'resolved_dependencies' => $resolved_data, + }; + + if ($opt_outdated) { + my ( $outdated, $summary ) = + $strategy->run_outdated_check( get_tool_config(), get_direct_deps(), get_verbose() ); + $results->{'outdated'} = $outdated; + $results->{'outdated_summary'} = $summary; + } + + if ($opt_audit) { + my ( $vulns, $summary ) = $strategy->run_audit_check( get_tool_config(), get_direct_deps(), get_verbose() ); + $results->{'vulnerabilities'} = $vulns; + $results->{'vulnerability_summary'} = $summary; + } + + if ($opt_sbom) { + my $sbom_config = get_config()->{'sbom'} // {}; + generate_sbom_output( $results, $sbom_config ); + } + + if ($opt_bugzilla) { + my $bugzilla_config = get_config()->{'bugzilla'} // {}; + generate_bugzilla_report( $results, $bugzilla_config ); + } + + if ($opt_json_output) { + write_json_results( $opt_json_output, $results, get_verbose() ); + } + + my $thresholds = {}; + if ( $config && ref $config eq 'HASH' && $config->{'thresholds'} ) { + $thresholds = $config->{'thresholds'}; + } + my $exit_code = determine_exit_code( $results, $thresholds ); + log_message( 'info', $opt_verbose, "Exit code: $exit_code" ); + return $exit_code; +} + +exit main(); + +sub generate_sbom_output { + my ( $results, $sbom_cfg ) = @_; + + my $output_file = $sbom_cfg->{'output_file'} + || 'node_modules_sbom.xml'; + my $koha_version = detect_koha_version(); + my ( $template_name, $data ) = + build_sbom_data( $results, $sbom_cfg, get_package_json(), $VERSION, $koha_version ); + + process_template( $template_name, $data, $output_file ); + + log_message( 'info', get_verbose(), "SBOM generated at $output_file" ); + return; +} + +sub generate_bugzilla_report { + my ( $results, $bugzilla_cfg ) = @_; + + my $output_file = $bugzilla_cfg->{'output_file'} + || 'dependency_bugzilla_report.md'; + my ( $template_name, $data ) = build_bugzilla_report_data( $results, $bugzilla_cfg ); + + process_template( $template_name, $data, $output_file ); + + log_message( 'info', get_verbose(), "Bugzilla report generated at $output_file" ); + + return; +} + +sub process_template { + my ( $template_name, $vars, $output_file ) = @_; + + my $template_dir = get_template_dir(); + my $include_path = [ grep { defined && length } ($template_dir) ]; + + my $cache_key = join q{|}, @{$include_path} ? @{$include_path} : ('__none__'); + state $template_cache; + + my $tt = $template_cache->{$cache_key}; + if ( !$tt ) { + my $config = { + ABSOLUTE => 1, + ENCODING => 'utf8', + }; + if ( @{$include_path} ) { + $config->{INCLUDE_PATH} = $include_path; + } + + $tt = Template->new($config) + or croak Template->error; + $template_cache->{$cache_key} = $tt; + } + + my $options = { binmode => ':utf8' }; + $tt->process( $template_name, $vars, $output_file, $options ) + or croak $tt->error; + + return; +} + +__END__ + +=head1 NAME + +node_audit_dependencies.pl - Node.js dependency management and security audit tool + +=head1 SYNOPSIS + + node_audit_dependencies.pl [options] + +=head1 DESCRIPTION + +This script provides comprehensive Node.js dependency management including +outdated package detection, security vulnerability auditing, and SBOM/markdown +report generation. It supports both npm and yarn package managers using a +strategy pattern implementation. + +=head1 OPTIONS + +=over 4 + +=item B<--outdated> + +Run the outdated dependency check. + +=item B<--audit> + +Run the security audit check. + +=item B<--sbom> + +Generate a CycloneDX SBOM based on package.json. + +=item B<--bugzilla> + +Generate a Bugzilla-ready markdown summary. + +=item B<--config-file FILE> + +Use an alternate configuration file. + +=item B<--json FILE> + +Write combined JSON results to FILE. + +=item B<--verbose> + +Emit debug information. + +=item B<--help> + +Print this help message. + +=back + +=head1 OUTPUT + +The tool can emit both human-readable and machine-readable artefacts. + +=over 4 + +=item * C<--json FILE> writes a consolidated JSON report (the CI wrapper defaults to C). + +=item * C<--bugzilla> renders a markdown summary (default C). + +=item * C<--sbom> produces a CycloneDX-style XML software bill of materials (default C). + +=back + +=head2 JSON report structure + +The JSON document contains the following top-level keys: + +=over 4 + +=item * C, C, C - run metadata including the config file used. + +=item * C - direct dependencies from F including their type and version spec. + +=item * C - the resolved dependency graph as reported by the package manager. + +=item * C, C - packages with updates available, grouped by update type. + +=item * C, C - security advisories grouped by severity. + +=back + +Common C snippets when reviewing C: + +=over 4 + +=item * C - quick counts per severity. + +=item * C - list actionable upgrade targets. + +=item * C - totals per update channel. + +=back + +=head2 Exit codes + +The script returns zero when no configured thresholds are exceeded. Non-zero exit +codes occur when vulnerability or outdated thresholds fail, or when compromise +checks (handled by L) detect matches. + +=head1 FUNCTIONS + +=head2 create_package_manager_strategy($tool) + +Creates and returns appropriate package manager strategy object. + +=over 4 + +=item * $tool - String indicating package manager ('yarn' or 'npm') + +=back + +Returns: Strategy object (Koha::Devel::Node::Package::Manager::Yarn or Koha::Devel::Node::Package::Manager::Npm) + +=head2 Memoization Functions + +The following functions implement memoization using Perl's C keyword +to eliminate argument drilling throughout the application: + +=head3 get_context() + +Returns the memoized context hashref. + +=head3 set_context($key, $value) + +Sets a value in the memoized context. + +=head3 initialize_context($args) + +Initializes the memoized context with common parameters. + +=over 4 + +=item * $args - Hashref containing initial context values + +=back + +=head3 get_verbose() + +Returns verbose flag from context. + +=head3 get_config() + +Returns configuration hash from context. + +=head3 get_project_root() + +Returns project root path from context. + +=head3 get_tool() + +Returns package manager name from context. + +=head3 get_tool_config() + +Returns tool-specific configuration from context. + +=head3 get_direct_deps() + +Returns direct dependencies hash from context. + +=head3 get_resolved_data() + +Returns resolved dependency data from context. + +=head3 get_package_json() + +Returns package.json data from context. + +=head2 main() + +Main entry point for the script. Handles command line options, +initializes context, and orchestrates dependency checking operations. + +=head2 generate_sbom_output($results, $sbom_config) + +Generates CycloneDX SBOM XML file from dependency data. + +=head2 sbom_component($name, $version, $scope) + +Creates SBOM component hash for a package. + +=head2 generate_bugzilla_report($results, $bugzilla_config) + +Generates Bugzilla-ready markdown report from dependency data. + +=head1 INTERNAL ARCHITECTURE + +This script uses a memoized context pattern with Perl's C keyword +to avoid argument drilling. Common parameters (verbose, config, etc.) are +stored in a persistent context and accessed through getter functions. + +=head1 AUTHOR + +Koha Development Team + +=head1 COPYRIGHT + +Copyright 2025 Koha + +=cut diff --git a/misc/devel/node_ci_audit.pl b/misc/devel/node_ci_audit.pl new file mode 100755 index 00000000000..3b5bd2488ed --- /dev/null +++ b/misc/devel/node_ci_audit.pl @@ -0,0 +1,262 @@ +#!/usr/bin/perl + +# Copyright 2025 Koha Development Team +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use Carp qw(croak); +use Cwd qw(abs_path); +use English qw(-no_match_vars); +use File::Basename qw(dirname); +use File::Spec::Functions qw(catdir catfile updir); +use FindBin qw($Bin); +my $script_dir = abs_path($Bin); +my $project_root = abs_path( catdir( $script_dir, updir(), updir() ) ); + +use Koha::Devel::Node::Utils qw( + load_config + change_to_project_root + detect_package_manager + run_command +); + +sub main { + my $check_script = catfile( $project_root, 'misc', 'devel', 'node_audit_dependencies.pl' ); + my $config_file = catfile( $project_root, 'misc', 'devel', 'node_audit_config.yml' ); + + if ( !-f $check_script ) { + croak "node_audit_dependencies.pl not found at $check_script\n"; + } + if ( !-f $config_file ) { + croak "Configuration file not found at $config_file\n"; + } + + my $config = load_config($config_file); + + my $tool = detect_package_manager(); + + say 'Koha Node.js dependency check' or croak; + say "Detected package manager: $tool" or croak; + + my $tool_conf = $config->{'tools'}{$tool} || {}; + my $install_cmd = $tool_conf->{'install'} || q{}; + my $config_path = $config_file; # used later + + if ( !$install_cmd ) { + croak "Install command not configured for $tool\n"; + } + + say "Install command: $install_cmd" or croak; + + change_to_project_root($project_root); + + say 'Installing dependencies with frozen lockfile...' or croak; + my @cmd_parts = split /\s+/smx, $install_cmd; + my @args = @cmd_parts[ 1 .. $#cmd_parts ]; + my ( $output, $exit_code ) = run_command( $cmd_parts[0], \@args ); + if ( $exit_code != 0 ) { + croak 'Installing dependencies failed'; + } + + my $env = {%ENV}; + + my ( $cmd_ref, $stdin_path, $stdin_label ) = build_dependency_command( + { + 'config_file' => $config_path, + 'check_script' => $check_script, + 'env' => $env, + } + ); + + my $system_exit_code; + if ($stdin_path) { + open my $stdin_fh, '<', $stdin_path + or croak "Unable to open stdin file $stdin_path: $OS_ERROR"; + { + local *STDIN = $stdin_fh; + $system_exit_code = system { $cmd_ref->[0] } @{$cmd_ref}; + } + close $stdin_fh; + } else { + $system_exit_code = system { $cmd_ref->[0] } @{$cmd_ref}; + } + + my $rc = $system_exit_code >> 8; + + say "Dependency check completed with exit code: $rc" or croak; + + if ( defined $stdin_label && length $stdin_label ) { + say " - Compromise check input: $stdin_label" or croak; + } + + if ( $stdin_path && ( $env->{NODE_DEP_CHECK_INPUT_FILE} // q{} ) eq q{} ) { + unlink $stdin_path; + } + + return $rc; +} + +# Call main function and exit with its return code +exit main(); + +sub build_dependency_command { + my ($args) = @_; + + my $config_file = $args->{config_file}; + my $check_script = $args->{check_script}; + my $env = $args->{env} || {}; + + my $modes_raw = + exists $env->{NODE_DEP_CHECK_MODES} && length $env->{NODE_DEP_CHECK_MODES} + ? $env->{NODE_DEP_CHECK_MODES} + : 'outdated,audit,sbom,bugzilla'; + + my $mode_tokens = [ grep { length } map { lc s/\s+//smxgr } split /,/smx, $modes_raw ]; + + my $mode_flags = { + outdated => '--outdated', + audit => '--audit', + sbom => '--sbom', + bugzilla => '--bugzilla', + compromise => '--check-packages', + 'check-packages' => '--check-packages', + }; + + my $selected; + for my $token ( @{$mode_tokens} ) { + next if !exists $mode_flags->{$token}; + $selected->{ $mode_flags->{$token} } = 1; + } + + if ( !%{$selected} ) { + $mode_tokens = [qw(outdated audit sbom bugzilla)]; + $selected = { map { $mode_flags->{$_} => 1 } @{$mode_tokens} }; + } + + my $compromise = delete $selected->{'--check-packages'}; + if ($compromise) { + if ( %{$selected} ) { + croak 'Compromise mode cannot be combined with other dependency-check modes'; + } + + my $comp_script = catfile( $project_root, 'misc', 'devel', 'node_audit_compromise.pl' ); + my $cmd = [ 'perl', $comp_script, '--config-file', $config_file ]; + + if ( !exists $env->{NODE_DEP_CHECK_VERBOSE} || $env->{NODE_DEP_CHECK_VERBOSE} ne '0' ) { + push @{$cmd}, '--verbose'; + } + + my $json_output = $env->{NODE_DEP_CHECK_JSON} // catfile( $project_root, 'dependency_check_results.json' ); + if ( length $json_output ) { + push @{$cmd}, '--json', $json_output; + } + + my $stdin_label; + if ( my $input_file = $env->{NODE_DEP_CHECK_INPUT_FILE} ) { + if ( !-e $input_file ) { + croak "Compromise input file '$input_file' not found\n"; + } + push @{$cmd}, '--input-file', $input_file; + $stdin_label = $input_file; + } elsif ( defined $env->{NODE_DEP_CHECK_INPUT} && length $env->{NODE_DEP_CHECK_INPUT} ) { + push @{$cmd}, '--inline', $env->{NODE_DEP_CHECK_INPUT}; + $stdin_label = 'NODE_DEP_CHECK_INPUT'; + } else { + croak "Compromise mode requested but NODE_DEP_CHECK_INPUT_FILE or NODE_DEP_CHECK_INPUT not provided\n"; + } + + return ( $cmd, undef, $stdin_label ); + } + + my $cmd = [ 'perl', $check_script, '--config-file', $config_file ]; + + if ( !exists $env->{NODE_DEP_CHECK_VERBOSE} || $env->{NODE_DEP_CHECK_VERBOSE} ne '0' ) { + push @{$cmd}, '--verbose'; + } + + my $json_output = $env->{NODE_DEP_CHECK_JSON} // catfile( $project_root, 'dependency_check_results.json' ); + if ( length $json_output ) { + push @{$cmd}, '--json', $json_output; + } + + push @{$cmd}, sort keys %{$selected}; + + return ( $cmd, undef, undef ); +} + +__END__ + +=head1 NAME + +node_ci_audit.pl - CI-oriented wrapper for node_audit_dependencies.pl + +=head1 DESCRIPTION + +Installs Node.js dependencies using the configured package manager and then +invokes L (or the compromise helper) with +the appropriate flags for continuous integration jobs. + +=head1 ENVIRONMENT + +The following environment variables alter the behaviour of the script: + +=over 4 + +=item * C - comma-separated list of modes to run. Defaults +to C. Include C to run +C instead. + +=item * C - path for the combined JSON output +(defaults to F in the project root). + +=item * C - set to C<0> to disable the C<--verbose> flag. + +=item * C, C - sources for +compromise queries when C mode is selected. + +=back + +=head1 OUTPUT + +When the JSON output is enabled, refer to the structure documented in +L. Typical CI checks include: + +=over 4 + +=item * C - severity +counts for audit findings. + +=item * C - upgrade counts +per update cadence. + +=back + +=head1 EXIT STATUS + +The wrapper returns the exit code from the underlying audit script so that CI +systems can fail the job when thresholds are exceeded. + +=head1 AUTHOR + +Koha Development Team + +=head1 COPYRIGHT + +Copyright 2025 Koha + +=cut -- 2.51.2