|
Line 0
Link Here
|
|
|
1 |
package Koha::Devel::Node::Utils; |
| 2 |
|
| 3 |
# Copyright 2025 Koha Development Team |
| 4 |
# |
| 5 |
# This file is part of Koha. |
| 6 |
# |
| 7 |
# Koha is free software; you can redistribute it and/or modify it |
| 8 |
# under the terms of the GNU General Public License as published by |
| 9 |
# the Free Software Foundation; either version 3 of the License, or |
| 10 |
# (at your option) any later version. |
| 11 |
# |
| 12 |
# Koha is distributed in the hope that it will be useful, but |
| 13 |
# WITHOUT ANY WARRANTY; without even the implied warranty of |
| 14 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 15 |
# GNU General Public License for more details. |
| 16 |
# |
| 17 |
# You should have received a copy of the GNU General Public License |
| 18 |
# along with Koha; if not, see <https://www.gnu.org/licenses>. |
| 19 |
|
| 20 |
use strict; |
| 21 |
use warnings; |
| 22 |
use Carp qw(carp croak); |
| 23 |
use parent 'Exporter'; |
| 24 |
use Cwd qw(abs_path cwd); |
| 25 |
use File::Basename qw(dirname); |
| 26 |
use File::Spec::Functions qw(catfile catdir updir); |
| 27 |
use English qw(-no_match_vars); |
| 28 |
use JSON qw(decode_json encode_json); |
| 29 |
use POSIX qw(strftime); |
| 30 |
|
| 31 |
our $VERSION = '1.0.0'; |
| 32 |
our @EXPORT_OK = qw( |
| 33 |
log_message |
| 34 |
load_config |
| 35 |
find_project_root |
| 36 |
detect_package_manager |
| 37 |
run_command |
| 38 |
safe_json_decode |
| 39 |
change_to_project_root |
| 40 |
map_dependency_info |
| 41 |
determine_update_type |
| 42 |
version_parts |
| 43 |
xml_escape |
| 44 |
generate_purl |
| 45 |
generate_uuid |
| 46 |
normalize_semver_spec |
| 47 |
load_package_json |
| 48 |
build_direct_dependency_map |
| 49 |
write_json_results |
| 50 |
parse_compromise_line |
| 51 |
run_compromise_check |
| 52 |
build_resolved_index |
| 53 |
determine_exit_code |
| 54 |
build_bugzilla_report_data |
| 55 |
build_sbom_data |
| 56 |
detect_koha_version |
| 57 |
); |
| 58 |
|
| 59 |
sub log_message { |
| 60 |
my ( $level, $verbosity, $message ) = @_; |
| 61 |
|
| 62 |
if ( defined $verbosity && !$verbosity && ( $level eq 'debug' || $level eq 'info' ) ) { |
| 63 |
return; |
| 64 |
} |
| 65 |
|
| 66 |
my $timestamp = strftime( '%Y-%m-%d %H:%M:%S', localtime ); |
| 67 |
print {*STDERR} "[$timestamp] [$level] $message\n"; |
| 68 |
return; |
| 69 |
} |
| 70 |
|
| 71 |
sub load_config { |
| 72 |
my ($config_file) = @_; |
| 73 |
if ( !-f $config_file ) { |
| 74 |
return; |
| 75 |
} |
| 76 |
|
| 77 |
eval { require YAML; YAML->import('LoadFile'); 1; } or do { |
| 78 |
log_message( 'warn', 1, 'YAML module not available, config loading disabled' ); |
| 79 |
return {}; |
| 80 |
}; |
| 81 |
|
| 82 |
my $config; |
| 83 |
eval { $config = LoadFile($config_file); 1; } or do { |
| 84 |
log_message( 'error', 1, "Failed to load config file '$config_file': $EVAL_ERROR" ); |
| 85 |
return {}; |
| 86 |
}; |
| 87 |
return $config; |
| 88 |
} |
| 89 |
|
| 90 |
sub find_project_root { |
| 91 |
my $current_dir = cwd(); |
| 92 |
|
| 93 |
while ( $current_dir ne q{/} ) { |
| 94 |
return $current_dir if -f catfile( $current_dir, 'package.json' ); |
| 95 |
$current_dir = dirname($current_dir); |
| 96 |
} |
| 97 |
|
| 98 |
croak "Could not find project root (package.json)\n"; |
| 99 |
} |
| 100 |
|
| 101 |
sub detect_package_manager { |
| 102 |
my $project_root = find_project_root(); |
| 103 |
|
| 104 |
return 'yarn' if -f catfile( $project_root, 'yarn.lock' ); |
| 105 |
return 'npm' if -f catfile( $project_root, 'package-lock.json' ); |
| 106 |
|
| 107 |
log_message( 'warn', 1, 'No lock file found, defaulting to npm' ); |
| 108 |
return 'npm'; |
| 109 |
} |
| 110 |
|
| 111 |
sub run_command { |
| 112 |
my ( $command, $args_ref ) = @_; |
| 113 |
$args_ref //= []; |
| 114 |
|
| 115 |
log_message( 'debug', 0, "Running command: $command " . join q{ }, @{$args_ref} ); |
| 116 |
|
| 117 |
my $output = _run_command_and_capture( $command, $args_ref ); |
| 118 |
my $exit_code = $CHILD_ERROR >> 8; |
| 119 |
log_message( 'debug', 0, "Command exit code: $exit_code" ); |
| 120 |
|
| 121 |
return ( $output, $exit_code ); |
| 122 |
} |
| 123 |
|
| 124 |
sub _run_command_and_capture { |
| 125 |
my ( $command, $args_ref ) = @_; |
| 126 |
|
| 127 |
my $pid = open my $pipe, q{-|}; |
| 128 |
if ( !defined $pid ) { |
| 129 |
log_message( 'error', 1, "Cannot fork: $OS_ERROR" ); |
| 130 |
return ( "Cannot fork: $OS_ERROR", -1 ); |
| 131 |
} |
| 132 |
|
| 133 |
if ( $pid == 0 ) { |
| 134 |
|
| 135 |
# Use system calls for reliable STDIN redirection |
| 136 |
require POSIX; |
| 137 |
POSIX::dup2( POSIX::open( '/dev/null', POSIX::O_RDONLY() ), 0 ) or exit 127; |
| 138 |
open STDERR, '>', '/dev/null' or exit 127; |
| 139 |
exec {$command} $command, @{$args_ref} or exit 127; |
| 140 |
} |
| 141 |
|
| 142 |
my $output = do { local $INPUT_RECORD_SEPARATOR = undef; <$pipe> }; |
| 143 |
close $pipe; |
| 144 |
|
| 145 |
return $output; |
| 146 |
} |
| 147 |
|
| 148 |
sub safe_json_decode { |
| 149 |
my ($json_string) = @_; |
| 150 |
if ( !defined $json_string || !length $json_string ) { |
| 151 |
return; |
| 152 |
} |
| 153 |
|
| 154 |
my $result; |
| 155 |
eval { $result = decode_json($json_string); 1; } or do { |
| 156 |
log_message( 'warn', 1, "Failed to parse JSON: $EVAL_ERROR" ); |
| 157 |
return; |
| 158 |
}; |
| 159 |
return $result; |
| 160 |
} |
| 161 |
|
| 162 |
sub change_to_project_root { |
| 163 |
my $project_root = find_project_root(); |
| 164 |
chdir $project_root or log_message( 'error', 1, "Cannot change to project root: $OS_ERROR" ) and return; |
| 165 |
log_message( 'debug', 0, "Changed to project root: $project_root" ); |
| 166 |
return $project_root; |
| 167 |
} |
| 168 |
|
| 169 |
sub map_dependency_info { |
| 170 |
my ( $packages, $name, $direct_deps ) = @_; |
| 171 |
|
| 172 |
if ( ref $packages ne 'HASH' || !defined $name ) { |
| 173 |
return; |
| 174 |
} |
| 175 |
|
| 176 |
my $entry = $packages->{$name} ||= {}; |
| 177 |
my $direct = |
| 178 |
( $direct_deps && ref $direct_deps eq 'HASH' ) |
| 179 |
? $direct_deps->{$name} |
| 180 |
: undef; |
| 181 |
|
| 182 |
if ($direct) { |
| 183 |
$entry->{'type'} = $direct->{'type'} || $entry->{'type'} || 'dependencies'; |
| 184 |
$entry->{'version_spec'} = $direct->{'version'} || $entry->{'version_spec'}; |
| 185 |
$entry->{'resolved_version'} = $direct->{'resolved_version'} || $entry->{'resolved_version'}; |
| 186 |
} else { |
| 187 |
$entry->{'type'} ||= 'transitive'; |
| 188 |
} |
| 189 |
|
| 190 |
return $entry; |
| 191 |
} |
| 192 |
|
| 193 |
sub load_package_json { |
| 194 |
my ($root) = @_; |
| 195 |
my $path = catfile( $root, 'package.json' ); |
| 196 |
|
| 197 |
open my $fh, '<', $path or croak "Cannot open $path: $OS_ERROR"; |
| 198 |
local $INPUT_RECORD_SEPARATOR = undef; |
| 199 |
my $json_text = <$fh>; |
| 200 |
close $fh or croak; |
| 201 |
|
| 202 |
my $data = decode_json($json_text); |
| 203 |
if ( ref $data ne 'HASH' ) { |
| 204 |
croak 'package.json did not parse into a hash'; |
| 205 |
} |
| 206 |
|
| 207 |
return $data; |
| 208 |
} |
| 209 |
|
| 210 |
sub build_direct_dependency_map { |
| 211 |
my ($package_json) = @_; |
| 212 |
|
| 213 |
my $map = {}; |
| 214 |
|
| 215 |
for my $name ( keys %{ $package_json->{'dependencies'} || {} } ) { |
| 216 |
my $version = $package_json->{'dependencies'}{$name}; |
| 217 |
$map->{$name} = { |
| 218 |
'type' => 'dependencies', |
| 219 |
'version' => $version, |
| 220 |
}; |
| 221 |
} |
| 222 |
|
| 223 |
for my $name ( keys %{ $package_json->{'devDependencies'} || {} } ) { |
| 224 |
my $version = $package_json->{'devDependencies'}{$name}; |
| 225 |
$map->{$name} = { |
| 226 |
'type' => 'devDependencies', |
| 227 |
'version' => $version, |
| 228 |
}; |
| 229 |
} |
| 230 |
|
| 231 |
return $map; |
| 232 |
} |
| 233 |
|
| 234 |
sub write_json_results { |
| 235 |
my ( $output, $data, $verbose ) = @_; |
| 236 |
|
| 237 |
open my $fh, '>', $output |
| 238 |
or croak "Cannot open $output for writing: $OS_ERROR"; |
| 239 |
print {$fh} encode_json($data) or croak; |
| 240 |
close $fh or croak; |
| 241 |
|
| 242 |
log_message( 'info', $verbose, "JSON results written to $output" ); |
| 243 |
|
| 244 |
return; |
| 245 |
} |
| 246 |
|
| 247 |
sub parse_compromise_line { |
| 248 |
my ($line) = @_; |
| 249 |
|
| 250 |
if ( !defined $line || !length $line ) { |
| 251 |
return { error => 'empty' }; |
| 252 |
} |
| 253 |
|
| 254 |
my $scoped = qr{ |
| 255 |
@[^\/\s]+\/[\w._-]+ |
| 256 |
}smx; |
| 257 |
my $unscoped = qr{ |
| 258 |
[[:alnum:]][[:alnum:]._-]* |
| 259 |
}smx; |
| 260 |
my $version = qr/[\S]+/smx; |
| 261 |
|
| 262 |
if ( $line =~ /^($scoped)@($version)$/smx ) { |
| 263 |
return { name => $1, version => $2 }; |
| 264 |
} |
| 265 |
if ( $line =~ /^($scoped)[:=]($version)$/smx ) { |
| 266 |
return { name => $1, version => $2 }; |
| 267 |
} |
| 268 |
if ( $line =~ /^($scoped)\s+($version)$/smx ) { |
| 269 |
return { name => $1, version => $2 }; |
| 270 |
} |
| 271 |
if ( $line =~ /^($unscoped)@($version)$/smx ) { |
| 272 |
return { name => $1, version => $2 }; |
| 273 |
} |
| 274 |
if ( $line =~ /^($unscoped)[:=]($version)$/smx ) { |
| 275 |
return { name => $1, version => $2 }; |
| 276 |
} |
| 277 |
if ( $line =~ /^($unscoped)\s+($version)$/smx ) { |
| 278 |
return { name => $1, version => $2 }; |
| 279 |
} |
| 280 |
if ( $line =~ /^($scoped)$/smx ) { |
| 281 |
return { name => $1 }; |
| 282 |
} |
| 283 |
if ( $line =~ /^($unscoped)$/smx ) { |
| 284 |
return { name => $1 }; |
| 285 |
} |
| 286 |
|
| 287 |
return { error => 'unrecognized format' }; |
| 288 |
} |
| 289 |
|
| 290 |
sub run_compromise_check { |
| 291 |
my ( $queries, $resolved_data, $verbose ) = @_; |
| 292 |
|
| 293 |
my $queries_list = [ @{ $queries || [] } ]; |
| 294 |
my $summary = { |
| 295 |
total => scalar @{$queries_list}, |
| 296 |
matches => 0, |
| 297 |
missing => 0, |
| 298 |
invalid => 0, |
| 299 |
}; |
| 300 |
|
| 301 |
my $results = []; |
| 302 |
|
| 303 |
if ( !@{$queries_list} ) { |
| 304 |
$summary->{'note'} = 'No package identifiers provided'; |
| 305 |
return { 'summary' => $summary, 'queries' => [] }; |
| 306 |
} |
| 307 |
|
| 308 |
if ( !$resolved_data || !@{ $resolved_data->{'packages'} || [] } ) { |
| 309 |
my $error = 'Resolved dependency data unavailable; cannot evaluate package list'; |
| 310 |
log_message( 'warn', $verbose, $error ); |
| 311 |
$summary->{'error'} = $error; |
| 312 |
return { 'summary' => $summary, 'queries' => [] }; |
| 313 |
} |
| 314 |
|
| 315 |
my $index = $resolved_data->{'index'}; |
| 316 |
$index ||= build_resolved_index( $resolved_data->{'packages'} ); |
| 317 |
|
| 318 |
for my $query ( @{$queries_list} ) { |
| 319 |
if ( $query->{'error'} ) { |
| 320 |
$summary->{'invalid'}++; |
| 321 |
push @{$results}, |
| 322 |
{ |
| 323 |
'input' => $query->{'input'}, |
| 324 |
'status' => 'invalid', |
| 325 |
'error' => $query->{'error'}, |
| 326 |
}; |
| 327 |
next; |
| 328 |
} |
| 329 |
|
| 330 |
my $name = $query->{'name'}; |
| 331 |
my $spec = $query->{'version'}; |
| 332 |
|
| 333 |
my $available = $index->{$name} || {}; |
| 334 |
my $versions = [ sort keys %{$available} ]; |
| 335 |
|
| 336 |
my $matched = []; |
| 337 |
if ( defined $spec && length $spec ) { |
| 338 |
my $normalized = normalize_semver_spec($spec); |
| 339 |
for my $version ( @{$versions} ) { |
| 340 |
if ( $version eq $spec |
| 341 |
|| ( defined $normalized && $version eq $normalized ) ) |
| 342 |
{ |
| 343 |
push @{$matched}, |
| 344 |
{ |
| 345 |
version => $version, |
| 346 |
scope => $available->{$version}, |
| 347 |
}; |
| 348 |
} |
| 349 |
} |
| 350 |
} else { |
| 351 |
@{$matched} = map { |
| 352 |
{ |
| 353 |
version => $_, |
| 354 |
scope => $available->{$_}, |
| 355 |
} |
| 356 |
} @{$versions}; |
| 357 |
} |
| 358 |
|
| 359 |
my $result = { |
| 360 |
'input' => $query->{'input'}, |
| 361 |
'package' => $name, |
| 362 |
'version_spec' => $spec, |
| 363 |
'matches' => $matched, |
| 364 |
}; |
| 365 |
|
| 366 |
if ( @{$matched} ) { |
| 367 |
$result->{'status'} = 'match'; |
| 368 |
$summary->{'matches'}++; |
| 369 |
} else { |
| 370 |
$result->{'status'} = 'no-match'; |
| 371 |
$summary->{'missing'}++; |
| 372 |
} |
| 373 |
|
| 374 |
push @{$results}, $result; |
| 375 |
} |
| 376 |
|
| 377 |
return { summary => $summary, queries => $results }; |
| 378 |
} |
| 379 |
|
| 380 |
sub build_resolved_index { |
| 381 |
my ($packages) = @_; |
| 382 |
my $index = {}; |
| 383 |
for my $entry ( @{ $packages || [] } ) { |
| 384 |
if ( !$entry->{'name'} || !$entry->{'version'} ) { |
| 385 |
next; |
| 386 |
} |
| 387 |
$index->{ $entry->{'name'} }{ $entry->{'version'} } = $entry->{'type'} // 'transitive'; |
| 388 |
} |
| 389 |
return $index; |
| 390 |
} |
| 391 |
|
| 392 |
sub determine_exit_code { |
| 393 |
my ( $results, $thresholds ) = @_; |
| 394 |
|
| 395 |
$thresholds //= {}; |
| 396 |
|
| 397 |
my $vuln_fail = $thresholds->{'vulnerable_fail'}; |
| 398 |
if ( !defined $vuln_fail ) { |
| 399 |
$vuln_fail = 0; |
| 400 |
} |
| 401 |
my $major_fail = $thresholds->{'major_version_fail'} // 0; |
| 402 |
my $audit_level = lc( $thresholds->{'audit_fail_level'} // 'moderate' ); |
| 403 |
my $compromise_fail = $thresholds->{'compromise_fail_on_match'}; |
| 404 |
if ( !defined $compromise_fail ) { |
| 405 |
$compromise_fail = 1; |
| 406 |
} |
| 407 |
|
| 408 |
my $severity_rank = { |
| 409 |
info => 0, |
| 410 |
low => 1, |
| 411 |
moderate => 2, |
| 412 |
high => 3, |
| 413 |
critical => 4, |
| 414 |
unknown => 5, |
| 415 |
}; |
| 416 |
|
| 417 |
my $vulnerabilities = $results->{'vulnerabilities'} || []; |
| 418 |
|
| 419 |
if ( $vuln_fail && @{$vulnerabilities} ) { |
| 420 |
return 1; |
| 421 |
} |
| 422 |
|
| 423 |
if ( @{$vulnerabilities} ) { |
| 424 |
my $threshold_rank = $severity_rank->{$audit_level} // $severity_rank->{'moderate'}; |
| 425 |
|
| 426 |
for my $vuln ( @{$vulnerabilities} ) { |
| 427 |
my $rank = $severity_rank->{ lc( $vuln->{'severity'} || q{} ) } // $severity_rank->{'unknown'}; |
| 428 |
if ( $rank >= $threshold_rank ) { |
| 429 |
return 1; |
| 430 |
} |
| 431 |
} |
| 432 |
} |
| 433 |
|
| 434 |
if ($major_fail) { |
| 435 |
for my $pkg ( keys %{ $results->{'outdated'} || {} } ) { |
| 436 |
my $info = $results->{'outdated'}{$pkg}; |
| 437 |
if ( !$info->{'update'} || $info->{'update'} ne 'major' ) { |
| 438 |
next; |
| 439 |
} |
| 440 |
return 1; |
| 441 |
} |
| 442 |
} |
| 443 |
|
| 444 |
if ( my $comp = $results->{'compromise_check'} ) { |
| 445 |
if ( $comp->{'summary'}{'error'} ) { |
| 446 |
return 1; |
| 447 |
} |
| 448 |
if ( $compromise_fail && ( $comp->{'summary'}{'matches'} || 0 ) > 0 ) { |
| 449 |
return 1; |
| 450 |
} |
| 451 |
} |
| 452 |
|
| 453 |
return 0; |
| 454 |
} |
| 455 |
|
| 456 |
sub determine_update_type { |
| 457 |
my ( $current, $latest ) = @_; |
| 458 |
|
| 459 |
if ( !defined $current || !defined $latest ) { |
| 460 |
return 'unknown'; |
| 461 |
} |
| 462 |
|
| 463 |
my $current_parts = version_parts($current); |
| 464 |
my $latest_parts = version_parts($latest); |
| 465 |
|
| 466 |
if ( !@{$current_parts} || !@{$latest_parts} ) { |
| 467 |
return 'unknown'; |
| 468 |
} |
| 469 |
|
| 470 |
if ( $latest_parts->[0] > $current_parts->[0] ) { |
| 471 |
return 'major'; |
| 472 |
} |
| 473 |
if ( $latest_parts->[1] > $current_parts->[1] ) { |
| 474 |
return 'minor'; |
| 475 |
} |
| 476 |
if ( $latest_parts->[2] > $current_parts->[2] ) { |
| 477 |
return 'patch'; |
| 478 |
} |
| 479 |
|
| 480 |
return 'up-to-date'; |
| 481 |
} |
| 482 |
|
| 483 |
sub version_parts { |
| 484 |
my ($version) = @_; |
| 485 |
if ( !defined $version ) { |
| 486 |
return; |
| 487 |
} |
| 488 |
|
| 489 |
my $clean = $version; |
| 490 |
$clean =~ s/^[\^~><=v\s]+//smx; |
| 491 |
|
| 492 |
my $parts = [ split /[.]/smx, $clean ]; |
| 493 |
my $numeric = []; |
| 494 |
for my $part ( @{$parts} ) { |
| 495 |
my $match = $part; |
| 496 |
push @{$numeric}, ( $match =~ /(\d+)/xms ? $1 : 0 ); |
| 497 |
} |
| 498 |
|
| 499 |
while ( @{$numeric} < 3 ) { |
| 500 |
push @{$numeric}, 0; |
| 501 |
} |
| 502 |
|
| 503 |
return [ @{$numeric}[ 0 .. 2 ] ]; |
| 504 |
} |
| 505 |
|
| 506 |
sub xml_escape { |
| 507 |
my ($text) = @_; |
| 508 |
if ( !defined $text ) { |
| 509 |
return q{}; |
| 510 |
} |
| 511 |
my $escaped = $text; |
| 512 |
$escaped =~ s/&/&/smxg; |
| 513 |
$escaped =~ s/</</smxg; |
| 514 |
$escaped =~ s/>/>/smxg; |
| 515 |
$escaped =~ s/"/"/smxg; |
| 516 |
$escaped =~ s/'/'/smxg; |
| 517 |
return $escaped; |
| 518 |
} |
| 519 |
|
| 520 |
sub generate_purl { |
| 521 |
my ( $name, $version ) = @_; |
| 522 |
|
| 523 |
my $component = $name; |
| 524 |
$component =~ s/\@/%40/smx; |
| 525 |
|
| 526 |
return "pkg:npm/$component\@$version"; |
| 527 |
} |
| 528 |
|
| 529 |
sub generate_uuid { |
| 530 |
my $hex = [ 0 .. 9, 'a' .. 'f' ]; |
| 531 |
my $rand = sub { $hex->[ int rand @{$hex} ] }; |
| 532 |
|
| 533 |
return join q{}, |
| 534 |
map { $_ } ( |
| 535 |
map { $rand->() } 1 .. 8, |
| 536 |
q{-}, |
| 537 |
map { $rand->() } 1 .. 4, |
| 538 |
q{-}, |
| 539 |
map { $rand->() } 1 .. 4, |
| 540 |
q{-}, |
| 541 |
map { $rand->() } 1 .. 4, |
| 542 |
q{-}, |
| 543 |
map { $rand->() } 1 .. 12 |
| 544 |
); |
| 545 |
} |
| 546 |
|
| 547 |
sub normalize_semver_spec { |
| 548 |
my ($spec) = @_; |
| 549 |
if ( !defined $spec ) { |
| 550 |
return; |
| 551 |
} |
| 552 |
|
| 553 |
my $normalized = $spec; |
| 554 |
$normalized =~ s/^\s+|\s+$//smxg; |
| 555 |
$normalized =~ s/^[\^~=\s]+//smx; |
| 556 |
|
| 557 |
return length $normalized ? $normalized : undef; |
| 558 |
} |
| 559 |
|
| 560 |
sub build_sbom_data { |
| 561 |
my ( $results, $sbom_cfg, $package_json, $tool_version, $koha_version ) = @_; |
| 562 |
|
| 563 |
$results ||= {}; |
| 564 |
$sbom_cfg ||= {}; |
| 565 |
$package_json ||= {}; |
| 566 |
$tool_version ||= '1.0.0'; |
| 567 |
$koha_version ||= q{}; |
| 568 |
|
| 569 |
my $template_name = $sbom_cfg->{'template'} || 'dependency_sbom.xml.tt'; |
| 570 |
|
| 571 |
my $include_dev = $sbom_cfg->{'include_dev_dependencies'} // 0; |
| 572 |
|
| 573 |
my $resolved = $results->{'resolved_dependencies'}; |
| 574 |
|
| 575 |
my $scope_map = { |
| 576 |
'dependencies' => 'required', |
| 577 |
'devDependencies' => 'development', |
| 578 |
'transitive' => 'transitive', |
| 579 |
}; |
| 580 |
|
| 581 |
my $seen = {}; |
| 582 |
my $components = []; |
| 583 |
|
| 584 |
if ( $resolved && @{ $resolved->{'packages'} || [] } ) { |
| 585 |
for my $entry ( @{ $resolved->{'packages'} } ) { |
| 586 |
my $name = $entry->{'name'}; |
| 587 |
my $version = $entry->{'version'}; |
| 588 |
my $type = $entry->{'type'} || 'transitive'; |
| 589 |
|
| 590 |
if ( $type eq 'devDependencies' && !$include_dev ) { |
| 591 |
next; |
| 592 |
} |
| 593 |
|
| 594 |
my $scope = $scope_map->{$type} || 'unknown'; |
| 595 |
my $key = join q{|}, $name, $version, $scope; |
| 596 |
if ( $seen->{$key}++ ) { |
| 597 |
next; |
| 598 |
} |
| 599 |
|
| 600 |
push @{$components}, |
| 601 |
_sbom_component( $name, $version, $scope ); |
| 602 |
} |
| 603 |
} else { |
| 604 |
for my $name ( sort keys %{ $package_json->{'dependencies'} || {} } ) { |
| 605 |
my $version = $package_json->{'dependencies'}{$name}; |
| 606 |
push @{$components}, |
| 607 |
_sbom_component( $name, $version, $scope_map->{'dependencies'} ); |
| 608 |
} |
| 609 |
|
| 610 |
if ($include_dev) { |
| 611 |
for my $name ( sort keys %{ $package_json->{'devDependencies'} || {} } ) { |
| 612 |
my $version = $package_json->{'devDependencies'}{$name}; |
| 613 |
push @{$components}, |
| 614 |
_sbom_component( $name, $version, $scope_map->{'devDependencies'} ); |
| 615 |
} |
| 616 |
} |
| 617 |
} |
| 618 |
|
| 619 |
my $timestamp = strftime '%Y-%m-%dT%H:%M:%SZ', gmtime; |
| 620 |
my $serial = generate_uuid(); |
| 621 |
my $app_version = $koha_version || $package_json->{'version'} || 'unknown'; |
| 622 |
|
| 623 |
my $data = { |
| 624 |
timestamp => $timestamp, |
| 625 |
serial => $serial, |
| 626 |
app_version => $app_version, |
| 627 |
tool_name => 'node_audit_dependencies.pl', |
| 628 |
tool_version => $tool_version, |
| 629 |
components => $components, |
| 630 |
}; |
| 631 |
|
| 632 |
return ( $template_name, $data ); |
| 633 |
} |
| 634 |
|
| 635 |
sub _sbom_component { |
| 636 |
my ( $name, $version, $scope ) = @_; |
| 637 |
|
| 638 |
my $clean_version = $version // 'unknown'; |
| 639 |
$clean_version =~ s/^[\^~><=v\s]+//smx; |
| 640 |
|
| 641 |
return { |
| 642 |
name => $name, |
| 643 |
version => $clean_version || 'unknown', |
| 644 |
scope => $scope, |
| 645 |
purl => generate_purl( $name, $clean_version || 'unknown' ), |
| 646 |
}; |
| 647 |
} |
| 648 |
|
| 649 |
sub detect_koha_version { |
| 650 |
my $project_root = eval { find_project_root() }; |
| 651 |
if ( !$project_root ) { |
| 652 |
return; |
| 653 |
} |
| 654 |
|
| 655 |
my $path = catfile( $project_root, 'Koha.pm' ); |
| 656 |
if ( !-f $path ) { |
| 657 |
return; |
| 658 |
} |
| 659 |
|
| 660 |
open my $fh, '<', $path or do { |
| 661 |
log_message( 'warn', 1, "Unable to open Koha.pm: $OS_ERROR" ); |
| 662 |
return; |
| 663 |
}; |
| 664 |
local $INPUT_RECORD_SEPARATOR = undef; |
| 665 |
my $content = <$fh>; |
| 666 |
close $fh; |
| 667 |
|
| 668 |
if ( $content && $content =~ /\$VERSION\s*=\s*["']([^"']+)["']/smx ) { |
| 669 |
return $1; |
| 670 |
} |
| 671 |
|
| 672 |
return; |
| 673 |
} |
| 674 |
|
| 675 |
sub build_bugzilla_report_data { |
| 676 |
my ( $results, $bugzilla_cfg ) = @_; |
| 677 |
|
| 678 |
$results ||= {}; |
| 679 |
$bugzilla_cfg ||= {}; |
| 680 |
|
| 681 |
my $template_name = $bugzilla_cfg->{'template'} || 'dependency_bugzilla_report.md.tt'; |
| 682 |
|
| 683 |
my $show_vulns = |
| 684 |
exists $bugzilla_cfg->{'show_vulnerabilities'} |
| 685 |
? $bugzilla_cfg->{'show_vulnerabilities'} |
| 686 |
: 1; |
| 687 |
my $show_outdated = |
| 688 |
exists $bugzilla_cfg->{'show_outdated'} |
| 689 |
? $bugzilla_cfg->{'show_outdated'} |
| 690 |
: 1; |
| 691 |
my $show_next_steps = |
| 692 |
exists $bugzilla_cfg->{'show_next_steps'} |
| 693 |
? $bugzilla_cfg->{'show_next_steps'} |
| 694 |
: 1; |
| 695 |
my $include_summary = |
| 696 |
exists $bugzilla_cfg->{'summary'} |
| 697 |
? $bugzilla_cfg->{'summary'} |
| 698 |
: 1; |
| 699 |
|
| 700 |
my $dependency_path_limit = $bugzilla_cfg->{'dependency_path_limit'} // 0; |
| 701 |
my $severity_order_cfg = $bugzilla_cfg->{'severity_order'}; |
| 702 |
my $metadata_static = $bugzilla_cfg->{'metadata'}; |
| 703 |
my $metadata_env = $bugzilla_cfg->{'metadata_env'}; |
| 704 |
|
| 705 |
my $default_severity_order = [qw(critical high moderate low info unknown)]; |
| 706 |
my $severity_order = [ @{ ref $severity_order_cfg eq 'ARRAY' ? $severity_order_cfg : $default_severity_order } ]; |
| 707 |
|
| 708 |
my $severity_rank = {}; |
| 709 |
for my $idx ( 0 .. $#{$severity_order} ) { |
| 710 |
my $key = lc $severity_order->[$idx]; |
| 711 |
$severity_rank->{$key} = $idx; |
| 712 |
} |
| 713 |
my $fallback_rank = scalar @{$severity_order}; |
| 714 |
|
| 715 |
my $vuln_source = $results->{'vulnerabilities'} || []; |
| 716 |
my $outdated_source = $results->{'outdated'} || {}; |
| 717 |
|
| 718 |
my $severity_counts = {}; |
| 719 |
my $vulnerability_rows = []; |
| 720 |
|
| 721 |
my $ordered_severities = []; |
| 722 |
|
| 723 |
if ($show_vulns) { |
| 724 |
for my $vuln ( @{$vuln_source} ) { |
| 725 |
my $severity = lc( $vuln->{'severity'} // 'unknown' ); |
| 726 |
my $rank = exists $severity_rank->{$severity} ? $severity_rank->{$severity} : $fallback_rank; |
| 727 |
$severity_counts->{$severity}++; |
| 728 |
|
| 729 |
my $paths = [ @{ $vuln->{'dependency_paths'} || [] } ]; |
| 730 |
my $omitted = 0; |
| 731 |
if ( $dependency_path_limit && $dependency_path_limit > 0 && @{$paths} > $dependency_path_limit ) { |
| 732 |
$omitted = @{$paths} - $dependency_path_limit; |
| 733 |
@{$paths} = @{$paths}[ 0 .. $dependency_path_limit - 1 ]; |
| 734 |
} |
| 735 |
|
| 736 |
my $direct_list = []; |
| 737 |
for my $dep ( @{ $vuln->{'direct_dependencies'} || [] } ) { |
| 738 |
my $scope = |
| 739 |
( $dep->{'type'} && $dep->{'type'} eq 'devDependencies' ) ? 'dev' |
| 740 |
: ( $dep->{'type'} && $dep->{'type'} eq 'dependencies' ) ? 'prod' |
| 741 |
: 'unknown'; |
| 742 |
my @pieces = ( sprintf '%s (%s)', $dep->{'name'} // q{}, $scope ); |
| 743 |
if ( my $spec = $dep->{'version_spec'} ) { |
| 744 |
push @pieces, sprintf 'spec %s', $spec; |
| 745 |
} |
| 746 |
if ( my $resolved = $dep->{'resolved_version'} ) { |
| 747 |
push @pieces, sprintf 'resolved %s', $resolved; |
| 748 |
} |
| 749 |
push @{$direct_list}, |
| 750 |
{ |
| 751 |
info => join '; ', @pieces, |
| 752 |
}; |
| 753 |
} |
| 754 |
|
| 755 |
push @{$vulnerability_rows}, |
| 756 |
{ |
| 757 |
package => $vuln->{'package'} // q{}, |
| 758 |
severity => $severity, |
| 759 |
severity_label => uc $severity, |
| 760 |
_severity_rank => $rank, |
| 761 |
current_version => $vuln->{'current_version'} // 'unknown', |
| 762 |
fixed_in => $vuln->{'fixed_in'}, |
| 763 |
recommendation => $vuln->{'recommendation'}, |
| 764 |
url => $vuln->{'url'}, |
| 765 |
dependency_paths => $paths, |
| 766 |
dependency_paths_omitted => $omitted, |
| 767 |
direct_dependencies => $direct_list, |
| 768 |
}; |
| 769 |
} |
| 770 |
|
| 771 |
@{$vulnerability_rows} = sort { |
| 772 |
( $a->{_severity_rank} <=> $b->{_severity_rank} ) |
| 773 |
|| ( lc( $a->{package} ) cmp lc( $b->{package} ) ) |
| 774 |
|| ( ( $a->{current_version} // q{} ) cmp( $b->{current_version} // q{} ) ) |
| 775 |
} @{$vulnerability_rows}; |
| 776 |
|
| 777 |
for my $entry ( @{$vulnerability_rows} ) { |
| 778 |
delete $entry->{_severity_rank}; |
| 779 |
} |
| 780 |
|
| 781 |
my $seen = {}; |
| 782 |
for my $sev ( @{$severity_order} ) { |
| 783 |
if ( !$severity_counts->{$sev} ) { |
| 784 |
next; |
| 785 |
} |
| 786 |
push @{$ordered_severities}, $sev; |
| 787 |
$seen->{$sev} = 1; |
| 788 |
} |
| 789 |
for my $sev ( sort keys %{$severity_counts} ) { |
| 790 |
if ( $seen->{$sev} ) { |
| 791 |
next; |
| 792 |
} |
| 793 |
push @{$ordered_severities}, $sev; |
| 794 |
} |
| 795 |
} |
| 796 |
|
| 797 |
my $outdated_rows = []; |
| 798 |
my $update_counts = {}; |
| 799 |
my $default_update_order = [qw(major minor patch up-to-date unknown)]; |
| 800 |
my $update_seen = {}; |
| 801 |
my $ordered_updates = []; |
| 802 |
|
| 803 |
if ($show_outdated) { |
| 804 |
for my $pkg ( sort keys %{$outdated_source} ) { |
| 805 |
my $info = $outdated_source->{$pkg} || {}; |
| 806 |
my $update = lc( $info->{'update'} // 'unknown' ); |
| 807 |
$update_counts->{$update}++; |
| 808 |
|
| 809 |
push @{$outdated_rows}, |
| 810 |
{ |
| 811 |
name => $pkg, |
| 812 |
current => $info->{'current'} // 'unknown', |
| 813 |
wanted => $info->{'wanted'} // 'unknown', |
| 814 |
latest => $info->{'latest'} // 'unknown', |
| 815 |
resolved => $info->{'resolved'}, |
| 816 |
scope => $info->{'scope'}, |
| 817 |
update => $info->{'update'} // 'unknown', |
| 818 |
}; |
| 819 |
} |
| 820 |
|
| 821 |
for my $type ( @{$default_update_order} ) { |
| 822 |
if ( !$update_counts->{$type} ) { |
| 823 |
next; |
| 824 |
} |
| 825 |
push @{$ordered_updates}, $type; |
| 826 |
$update_seen->{$type} = 1; |
| 827 |
} |
| 828 |
for my $type ( sort keys %{$update_counts} ) { |
| 829 |
if ( $update_seen->{$type} ) { |
| 830 |
next; |
| 831 |
} |
| 832 |
push @{$ordered_updates}, $type; |
| 833 |
} |
| 834 |
} |
| 835 |
|
| 836 |
my $summary = { |
| 837 |
vulnerabilities => { |
| 838 |
total => $show_vulns ? scalar @{$vulnerability_rows} : 0, |
| 839 |
severity_counts => $show_vulns ? $severity_counts : {}, |
| 840 |
hidden => $show_vulns ? 0 : scalar @{$vuln_source}, |
| 841 |
severity_order => $show_vulns ? $ordered_severities : [], |
| 842 |
}, |
| 843 |
outdated => { |
| 844 |
total => $show_outdated ? scalar @{$outdated_rows} : 0, |
| 845 |
update_counts => $show_outdated ? $update_counts : {}, |
| 846 |
hidden => $show_outdated ? 0 : scalar keys %{$outdated_source}, |
| 847 |
update_order => $show_outdated ? $ordered_updates : [], |
| 848 |
}, |
| 849 |
}; |
| 850 |
|
| 851 |
my $metadata = {}; |
| 852 |
if ( ref $metadata_static eq 'HASH' && keys %{$metadata_static} ) { |
| 853 |
$metadata = { %{$metadata_static} }; |
| 854 |
} |
| 855 |
|
| 856 |
if ( ref $metadata_env eq 'ARRAY' ) { |
| 857 |
for my $entry ( @{$metadata_env} ) { |
| 858 |
if ( !defined $entry ) { |
| 859 |
next; |
| 860 |
} |
| 861 |
my ( $label, $env_key ); |
| 862 |
if ( ref $entry eq 'HASH' ) { |
| 863 |
( $label, $env_key ) = each %{$entry}; |
| 864 |
} elsif ( $entry =~ /\A(.+?)=(.+)\z/smx ) { |
| 865 |
( $label, $env_key ) = ( $1, $2 ); |
| 866 |
} else { |
| 867 |
$label = $entry; |
| 868 |
$env_key = $entry; |
| 869 |
} |
| 870 |
if ( !defined $env_key || $env_key eq q{} ) { |
| 871 |
next; |
| 872 |
} |
| 873 |
my $value = $ENV{$env_key}; |
| 874 |
if ( !defined $value || $value eq q{} ) { |
| 875 |
next; |
| 876 |
} |
| 877 |
$metadata->{$label} = $value; |
| 878 |
} |
| 879 |
} |
| 880 |
|
| 881 |
my $metadata_payload = ( keys %{$metadata} ) ? $metadata : undef; |
| 882 |
|
| 883 |
my $payload = { |
| 884 |
generated_at => $results->{'generated_at'}, |
| 885 |
tool => $results->{'tool'}, |
| 886 |
vulnerabilities => $vulnerability_rows, |
| 887 |
outdated => $outdated_rows, |
| 888 |
summary => $include_summary ? $summary : undef, |
| 889 |
metadata => $metadata_payload, |
| 890 |
config => { |
| 891 |
show_vulnerabilities => $show_vulns ? 1 : 0, |
| 892 |
show_outdated => $show_outdated ? 1 : 0, |
| 893 |
show_next_steps => $show_next_steps ? 1 : 0, |
| 894 |
include_summary => $include_summary ? 1 : 0, |
| 895 |
dependency_path_limit => $dependency_path_limit, |
| 896 |
severity_order => $severity_order, |
| 897 |
}, |
| 898 |
}; |
| 899 |
|
| 900 |
return ( $template_name, $payload ); |
| 901 |
} |
| 902 |
|
| 903 |
1; |
| 904 |
|
| 905 |
__END__ |
| 906 |
|
| 907 |
=head1 NAME |
| 908 |
|
| 909 |
Koha::Devel::Node::Utils - Utility functions for Node.js dependency management |
| 910 |
|
| 911 |
=head1 SYNOPSIS |
| 912 |
|
| 913 |
use Koha::Devel::Node::Utils qw(log_message safe_json_decode); |
| 914 |
|
| 915 |
log_message('info', 1, 'Processing dependencies'); |
| 916 |
my $data = safe_json_decode($json_string); |
| 917 |
|
| 918 |
=head1 DESCRIPTION |
| 919 |
|
| 920 |
This module provides reusable utility functions for Node.js dependency |
| 921 |
management operations including logging, configuration loading, JSON parsing, |
| 922 |
and command execution. |
| 923 |
|
| 924 |
=head1 FUNCTIONS |
| 925 |
|
| 926 |
=head2 log_message($level, $verbose, $message) |
| 927 |
|
| 928 |
Outputs a timestamped log message to STDERR. Debug and info messages are only |
| 929 |
emitted when C<$verbose> is truthy; warnings and errors are always printed. |
| 930 |
|
| 931 |
=head2 load_config($config_file) |
| 932 |
|
| 933 |
Loads and parses a YAML configuration file. |
| 934 |
|
| 935 |
=head2 find_project_root() |
| 936 |
|
| 937 |
Finds the project root directory by looking for package.json. |
| 938 |
|
| 939 |
=head2 detect_package_manager() |
| 940 |
|
| 941 |
Detects which package manager is being used (yarn or npm). |
| 942 |
|
| 943 |
=head2 run_command($command, $args_ref) |
| 944 |
|
| 945 |
Executes a command with proper error handling. |
| 946 |
|
| 947 |
=head2 safe_json_decode($json_string) |
| 948 |
|
| 949 |
Safely decodes JSON with error handling. |
| 950 |
|
| 951 |
=head2 change_to_project_root() |
| 952 |
|
| 953 |
Changes to the project root directory. |
| 954 |
|
| 955 |
=head2 map_dependency_info($packages, $name, $direct_deps) |
| 956 |
|
| 957 |
Augments C<$packages->{$name}> with direct dependency metadata. |
| 958 |
|
| 959 |
=head2 load_package_json($root) |
| 960 |
|
| 961 |
Loads and decodes F<package.json> from the provided project root. |
| 962 |
|
| 963 |
=head2 build_direct_dependency_map($package_json) |
| 964 |
|
| 965 |
Builds a lookup hash describing each direct dependency, including its type and |
| 966 |
version specification. |
| 967 |
|
| 968 |
=head2 write_json_results($output, $data, $verbose) |
| 969 |
|
| 970 |
Writes combined JSON results to disk and emits an informational log message |
| 971 |
when verbosity permits. |
| 972 |
|
| 973 |
=head2 parse_compromise_line($line) |
| 974 |
|
| 975 |
Parses a single package identifier used by the compromise helper, supporting |
| 976 |
scoped packages and explicit version specifiers. |
| 977 |
|
| 978 |
=head2 run_compromise_check($queries, $resolved_data, $verbose) |
| 979 |
|
| 980 |
Evaluates compromise queries against the resolved dependency graph and returns |
| 981 |
match summaries. |
| 982 |
|
| 983 |
=head2 build_resolved_index($packages) |
| 984 |
|
| 985 |
Builds a hash index of resolved packages keyed by name and version. |
| 986 |
|
| 987 |
=head2 determine_exit_code($results, $thresholds) |
| 988 |
|
| 989 |
Computes the dependency-check exit code based on configured thresholds and the |
| 990 |
aggregated results structure. |
| 991 |
|
| 992 |
=head2 determine_update_type($current, $latest) |
| 993 |
|
| 994 |
Determines type of update needed (major, minor, patch, up-to-date). |
| 995 |
|
| 996 |
=head2 version_parts($version) |
| 997 |
|
| 998 |
Extracts numeric version parts from version string. |
| 999 |
|
| 1000 |
=head2 xml_escape($text) |
| 1001 |
|
| 1002 |
Escapes text for XML output. |
| 1003 |
|
| 1004 |
=head2 generate_purl($name, $version) |
| 1005 |
|
| 1006 |
Generates package URL (purl) for npm package. |
| 1007 |
|
| 1008 |
=head2 generate_uuid() |
| 1009 |
|
| 1010 |
Generates a random UUID v4. |
| 1011 |
|
| 1012 |
=head2 normalize_semver_spec($spec) |
| 1013 |
|
| 1014 |
Normalizes a semver specification by trimming whitespace and stripping leading |
| 1015 |
range operators (for example C<^>, C<~>, or C<=>). Returns the cleaned version |
| 1016 |
string, or C<undef> if nothing meaningful remains. |
| 1017 |
|
| 1018 |
=head2 build_sbom_data($results, $sbom_cfg, $package_json, $tool_version, $koha_version) |
| 1019 |
|
| 1020 |
Builds the data structure and selects the template required to generate the |
| 1021 |
CycloneDX SBOM report. The returned list contains the template filename and a |
| 1022 |
hashref with timestamps, tool metadata, and the component list filtered by the |
| 1023 |
supplied configuration. |
| 1024 |
|
| 1025 |
=head2 build_bugzilla_report_data($results, $bugzilla_cfg) |
| 1026 |
|
| 1027 |
Assembles vulnerability and outdated dependency data for the Bugzilla Markdown |
| 1028 |
report. Configuration flags control which sections are included, severity |
| 1029 |
ordering, and any metadata overrides merged into the rendered output. |
| 1030 |
|
| 1031 |
=head2 detect_koha_version() |
| 1032 |
|
| 1033 |
Attempts to read C<$VERSION> from F<Koha.pm> located at the project root and |
| 1034 |
returns it when available. Returns C<undef> if the version cannot be found. |
| 1035 |
|
| 1036 |
=head1 AUTHOR |
| 1037 |
|
| 1038 |
Koha Development Team |
| 1039 |
|
| 1040 |
=head1 COPYRIGHT |
| 1041 |
|
| 1042 |
Copyright 2025 Koha Development Team |
| 1043 |
|
| 1044 |
=cut |