Bugzilla – Attachment 189194 Details for
Bug 40778
Manage, upgrade and audit npm packages in a structured manner
Home
|
New
|
Browse
|
Search
|
[?]
|
Reports
|
Help
|
New Account
|
Log In
[x]
|
Forgot Password
Login:
[x]
[patch]
Bug 40778: Introduce modular Node.js dependency auditing framework
Bug-40778-Introduce-modular-Nodejs-dependency-audi.patch (text/plain), 107.16 KB, created by
Paul Derscheid
on 2025-11-06 17:38:48 UTC
(
hide
)
Description:
Bug 40778: Introduce modular Node.js dependency auditing framework
Filename:
MIME Type:
Creator:
Paul Derscheid
Created:
2025-11-06 17:38:48 UTC
Size:
107.16 KB
patch
obsolete
>From 14cb424054824d80d378f9523ee562df6b2b36ba Mon Sep 17 00:00:00 2001 >From: Paul Derscheid <paul.derscheid@lmscloud.de> >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 <https://www.gnu.org/licenses/>. >+ >+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 <https://www.gnu.org/licenses/>. >+ >+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 <https://www.gnu.org/licenses/>. >+ >+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 <https://www.gnu.org/licenses>. >+ >+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; >+ $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<package.json> 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<undef> 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<Koha.pm> located at the project root and >+returns it when available. Returns C<undef> 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 @@ >+<?xml version="1.0" encoding="UTF-8"?> >+<bom xmlns="https://cyclonedx.org/schema/bom/1.4" version="1" serialNumber="urn:uuid:[% serial | html %]"> >+ <metadata> >+ <timestamp>[% timestamp | html %]</timestamp> >+ <tools> >+ <tool> >+ <vendor>Koha Community</vendor> >+ <name>[% tool_name | html %]</name> >+ <version>[% tool_version | html %]</version> >+ </tool> >+ </tools> >+ <component type="application"> >+ <name>Koha</name> >+ <version>[% app_version | html %]</version> >+ </component> >+ </metadata> >+ <components> >+[% FOREACH component IN components -%] >+ <component type="library" bom-ref="[% component.purl | html %]"> >+ <name>[% component.name | html %]</name> >+ <version>[% component.version | html %]</version> >+ <purl>[% component.purl | html %]</purl> >+ <scope>[% component.scope | html %]</scope> >+ </component> >+[% END -%] >+ </components> >+</bom> >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 <https://www.gnu.org/licenses/>. >+ >+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}, <STDIN>; >+ } >+ >+ 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<misc/devel/node_audit_config.yml>. >+ >+=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<misc/devel/node_audit_config.yml>. >+ >+=item B<--json FILE> >+ >+Write combined JSON results to FILE. Defaults to >+C<dependency_check_results.json> 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<node_audit_dependencies.pl> and adds a C<compromise_check> section with: >+ >+=over 4 >+ >+=item * C<summary> - totals for matches, missing packages, and invalid inputs. >+ >+=item * C<queries> - per-identifier results containing C<status> (C<match>, >+C<no-match>, or C<invalid>), the normalised package name, and resolved versions. >+ >+=back >+ >+Helpful C<jq> snippets when reviewing the output: >+ >+=over 4 >+ >+=item * C<jq '.compromise_check.summary' dependency_check_results.json> - quick >+totals for compromise queries. >+ >+=item * C<jq '.compromise_check.queries[] | select(.status==\"match\")' dependency_check_results.json> >+- list only matching packages. >+ >+=back >+ >+Exit codes follow the threshold settings defined in >+C<misc/devel/node_audit_config.yml>; the helper exits non-zero when a match is >+found and C<compromise_fail_on_match> 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 <https://www.gnu.org/licenses/>. >+ >+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<dependency_check_results.json>). >+ >+=item * C<--bugzilla> renders a markdown summary (default C<dependency_bugzilla_report.md>). >+ >+=item * C<--sbom> produces a CycloneDX-style XML software bill of materials (default C<node_modules_sbom.xml>). >+ >+=back >+ >+=head2 JSON report structure >+ >+The JSON document contains the following top-level keys: >+ >+=over 4 >+ >+=item * C<generated_at>, C<tool>, C<metadata> - run metadata including the config file used. >+ >+=item * C<direct_dependencies> - direct dependencies from F<package.json> including their type and version spec. >+ >+=item * C<resolved_dependencies> - the resolved dependency graph as reported by the package manager. >+ >+=item * C<outdated>, C<outdated_summary> - packages with updates available, grouped by update type. >+ >+=item * C<vulnerabilities>, C<vulnerability_summary> - security advisories grouped by severity. >+ >+=back >+ >+Common C<jq> snippets when reviewing C<dependency_check_results.json>: >+ >+=over 4 >+ >+=item * C<jq '.vulnerability_summary' dependency_check_results.json> - quick counts per severity. >+ >+=item * C<jq '.vulnerabilities[] | {package, severity, fixed_in}' dependency_check_results.json> - list actionable upgrade targets. >+ >+=item * C<jq '.outdated_summary' dependency_check_results.json> - 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<misc/devel/node_audit_compromise.pl>) 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<state> 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<state> 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 <https://www.gnu.org/licenses/>. >+ >+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<misc/devel/node_audit_dependencies.pl> (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<NODE_DEP_CHECK_MODES> - comma-separated list of modes to run. Defaults >+to C<outdated,audit,sbom,bugzilla>. Include C<compromise> to run >+C<node_audit_compromise.pl> instead. >+ >+=item * C<NODE_DEP_CHECK_JSON> - path for the combined JSON output >+(defaults to F<dependency_check_results.json> in the project root). >+ >+=item * C<NODE_DEP_CHECK_VERBOSE> - set to C<0> to disable the C<--verbose> flag. >+ >+=item * C<NODE_DEP_CHECK_INPUT_FILE>, C<NODE_DEP_CHECK_INPUT> - sources for >+compromise queries when C<compromise> mode is selected. >+ >+=back >+ >+=head1 OUTPUT >+ >+When the JSON output is enabled, refer to the structure documented in >+L<misc/devel/node_audit_dependencies.pl>. Typical CI checks include: >+ >+=over 4 >+ >+=item * C<jq '.vulnerability_summary' dependency_check_results.json> - severity >+counts for audit findings. >+ >+=item * C<jq '.outdated_summary' dependency_check_results.json> - 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
You cannot view the attachment while viewing its details because your browser does not support IFRAMEs.
View the attachment on a separate page
.
View Attachment As Diff
View Attachment As Raw
Actions:
View
|
Diff
|
Splinter Review
Attachments on
bug 40778
:
189192
|
189193
| 189194