View | Details | Raw Unified | Return to bug 40778
Collapse All | Expand All

(-)a/Koha/Devel/Files.pm (+2 lines)
Lines 85-90 my $exceptions = { Link Here
85
                Koha/ILL/Backend/
85
                Koha/ILL/Backend/
86
                *doc-head-open.inc
86
                *doc-head-open.inc
87
                misc/cronjobs/rss
87
                misc/cronjobs/rss
88
                Koha/Devel/Node/templates/dependency_bugzilla_report.md.tt
89
                Koha/Devel/Node/templates/dependency_sbom.xml.tt
88
            )
90
            )
89
        ],
91
        ],
90
        codespell => [],
92
        codespell => [],
(-)a/Koha/Devel/Node/Package/Manager/Base.pm (+143 lines)
Line 0 Link Here
1
package Koha::Devel::Node::Package::Manager::Base;
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 Modern::Perl;
21
use Carp qw(croak);
22
23
use Koha::Devel::Node::Utils qw(
24
    log_message
25
    run_command
26
    safe_json_decode
27
    map_dependency_info
28
);
29
30
our $VERSION = '1.0.0';
31
32
sub new {
33
    my ($class) = @_;
34
    return bless {}, $class;
35
}
36
37
sub run_outdated_check {
38
    my ( $self, $tool_config, $direct_deps, $verbose ) = @_;
39
    croak 'Must implement run_outdated_check method';
40
}
41
42
sub run_audit_check {
43
    my ( $self, $tool_config, $direct_deps, $verbose ) = @_;
44
    croak 'Must implement run_audit_check method';
45
}
46
47
sub build_resolved_dependency_data {
48
    my ( $self, $tool_config, $direct_deps, $project_root, $verbose ) = @_;
49
    croak 'Must implement build_resolved_dependency_data method';
50
}
51
52
1;
53
54
__END__
55
56
=head1 NAME
57
58
Koha::Devel::Node::Package::Manager::Base - Abstract base class for package manager strategies
59
60
=head1 SYNOPSIS
61
62
    # This is an abstract base class
63
    # Use concrete implementations like Koha::Devel::Node::Package::Manager::Yarn or Npm
64
65
    my $manager = Koha::Devel::Node::Package::Manager::Yarn->new();
66
    my ($outdated, $summary) = $manager->run_outdated_check($config, $deps, $verbose);
67
68
=head1 DESCRIPTION
69
70
This module provides an abstract base class implementing the Strategy pattern for
71
different Node.js package managers (yarn, npm). It defines the interface
72
that all package manager strategies must implement.
73
74
=head1 METHODS
75
76
=head2 new()
77
78
Creates a new instance of the package manager strategy.
79
80
    my $manager = Koha::Devel::Node::Package::Manager::Yarn->new();
81
82
=head2 run_outdated_check($tool_config, $direct_deps, $verbose)
83
84
Abstract method that must be implemented by concrete classes.
85
Runs outdated dependency check for the specific package manager.
86
87
=over 4
88
89
=item * $tool_config - Hashref of configuration for this package manager
90
91
=item * $direct_deps - Hashref of direct dependencies from package.json
92
93
=item * $verbose - Boolean flag for verbose output
94
95
=back
96
97
Returns: ($outdated_packages, $summary)
98
99
=head2 run_audit_check($tool_config, $direct_deps, $verbose)
100
101
Abstract method that must be implemented by concrete classes.
102
Runs security audit check for the specific package manager.
103
104
=over 4
105
106
=item * $tool_config - Hashref of configuration for this package manager
107
108
=item * $direct_deps - Hashref of direct dependencies from package.json
109
110
=item * $verbose - Boolean flag for verbose output
111
112
=back
113
114
Returns: ($vulnerabilities, $summary)
115
116
=head2 build_resolved_dependency_data($tool_config, $direct_deps, $project_root, $verbose)
117
118
Abstract method that must be implemented by concrete classes.
119
Builds complete resolved dependency tree for the package manager.
120
121
=over 4
122
123
=item * $tool_config - Hashref of configuration for this package manager
124
125
=item * $direct_deps - Hashref of direct dependencies from package.json
126
127
=item * $project_root - Path to project root directory
128
129
=item * $verbose - Boolean flag for verbose output
130
131
=back
132
133
Returns: Hashref containing resolved dependency data
134
135
=head1 AUTHOR
136
137
Koha Development Team
138
139
=head1 COPYRIGHT
140
141
Copyright 2025 Koha
142
143
=cut
(-)a/Koha/Devel/Node/Package/Manager/Npm.pm (+505 lines)
Line 0 Link Here
1
package Koha::Devel::Node::Package::Manager::Npm;
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 Modern::Perl;
21
use Carp qw(carp croak);
22
use JSON qw(decode_json);
23
24
use Koha::Devel::Node::Utils qw(
25
    log_message
26
    run_command
27
    safe_json_decode
28
    map_dependency_info
29
);
30
31
use parent 'Koha::Devel::Node::Package::Manager::Base';
32
33
our $VERSION = '1.0.0';
34
35
sub run_outdated_check {
36
    my ( $self, $tool_config, $direct_deps, $verbose ) = @_;
37
38
    my $cmd = $tool_config->{'commands'}{'outdated'};
39
    if ( !$cmd ) {
40
        log_message( 'warn', $verbose, 'Outdated command not configured for npm' );
41
        return ( {}, {} );
42
    }
43
44
    log_message( 'debug', $verbose, "Running outdated command: $cmd" );
45
    my @cmd_parts = split /\s+/smx, $cmd;
46
    my @args      = @cmd_parts[ 1 .. $#cmd_parts ];
47
    my ( $output, $exit ) = run_command( $cmd_parts[0], \@args );
48
    log_message( 'debug', $verbose, "Outdated command exit code: $exit" );
49
50
    if ( !$output ) {
51
        return ( {}, {} );
52
    }
53
54
    my $payload = safe_json_decode($output);
55
    if ( !$payload ) {
56
        log_message( 'warn', $verbose, 'Failed to decode npm outdated output' );
57
        return ( {}, {} );
58
    }
59
60
    my $packages = {};
61
62
    if ( ref $payload eq 'HASH' ) {
63
        for my $name ( keys %{$payload} ) {
64
            my $entry = $payload->{$name};
65
            if ( ref $entry ne 'HASH' ) {
66
                next;
67
            }
68
69
            my $current = $entry->{'current'};
70
            my $latest  = $entry->{'latest'};
71
            my $wanted  = $entry->{'wanted'};
72
            my $update  = $self->_determine_update_type( $current, $latest );
73
74
            $packages->{$name} = {
75
                'current' => $current,
76
                'wanted'  => $wanted,
77
                'latest'  => $latest,
78
                'update'  => $update,
79
            };
80
81
            map_dependency_info( $packages, $name, $direct_deps );
82
        }
83
    }
84
85
    my $counts = {};
86
    for my $info ( values %{$packages} ) {
87
        if ( ref $info ne 'HASH' ) {
88
            next;
89
        }
90
        my $update = $info->{'update'} // 'unknown';
91
        $counts->{$update}++;
92
    }
93
94
    my $summary = {
95
        'total'  => scalar keys %{$packages},
96
        'counts' => $counts,
97
    };
98
99
    return ( $packages, $summary );
100
}
101
102
sub run_audit_check {
103
    my ( $self, $tool_config, $direct_deps, $verbose ) = @_;
104
105
    my $cmd = $tool_config->{'commands'}{'audit'};
106
    if ( !$cmd ) {
107
        log_message( 'warn', $verbose, 'Audit command not configured for npm' );
108
        return ( [], {} );
109
    }
110
111
    log_message( 'debug', $verbose, "Running audit command: $cmd" );
112
    my @cmd_parts = split /\s+/smx, $cmd;
113
    my @args      = @cmd_parts[ 1 .. $#cmd_parts ];
114
    my ( $output, $exit ) = run_command( $cmd_parts[0], \@args );
115
    log_message( 'debug', $verbose, "Audit command exit code: $exit" );
116
117
    if ( !$output ) {
118
        return ( [], {} );
119
    }
120
121
    my $payload = safe_json_decode($output);
122
    if ( !$payload ) {
123
        log_message( 'warn', $verbose, 'Failed to decode npm audit output' );
124
        return ( [], {} );
125
    }
126
127
    my $vulnerabilities      = [];
128
    my $vulnerabilities_data = $payload->{'vulnerabilities'} || {};
129
    for my $module ( keys %{$vulnerabilities_data} ) {
130
        my $entry = $vulnerabilities_data->{$module};
131
        if ( ref $entry ne 'HASH' ) {
132
            next;
133
        }
134
135
        my $severity = lc( $entry->{'severity'} // 'unknown' );
136
137
        my $fix = $entry->{'fixAvailable'};
138
        my $fixed_in;
139
        if ( !defined $fix ) {
140
            $fixed_in = undef;
141
        } elsif ( ref $fix eq 'HASH' && $fix->{'version'} ) {
142
            $fixed_in = $fix->{'version'};
143
        } elsif ($fix) {
144
            $fixed_in = 'latest';
145
        }
146
147
        my $nodes = [ @{ $entry->{'nodes'} || [] } ];
148
        my $paths =
149
            [ map { $self->_npm_node_to_path($_) } @{$nodes} ];
150
        my $direct = $self->_analyze_dependency_paths( $paths, $direct_deps );
151
152
        push @{$vulnerabilities},
153
            {
154
            'package'         => $module,
155
            'severity'        => $severity,
156
            'current_version' => $entry->{'version'} // $entry->{'range'} // 'unknown',
157
            'fixed_in'        => $fixed_in,
158
            'url'             => $self->_find_first_url( $entry->{'via'} ),
159
            'recommendation'  => $entry->{'via'} && ref $entry->{'via'}[0] eq 'HASH'
160
            ? $entry->{'via'}[0]{'recommendation'}
161
            : q{},
162
            'dependency_paths'    => $paths,
163
            'direct_dependencies' => $direct,
164
            };
165
    }
166
167
    my $severity_counts = {};
168
    for my $entry ( @{$vulnerabilities} ) {
169
        my $severity = lc( $entry->{'severity'} // 'unknown' );
170
        $severity_counts->{$severity}++;
171
    }
172
173
    my $summary = {
174
        'total'           => scalar @{$vulnerabilities},
175
        'severity_counts' => $severity_counts,
176
    };
177
178
    return ( $vulnerabilities, $summary );
179
}
180
181
sub build_resolved_dependency_data {
182
    my ( $self, $tool_config, $direct_deps, $project_root, $verbose ) = @_;
183
184
    my $list_cmd = $tool_config->{'commands'}{'list'};
185
    if ( !$list_cmd ) {
186
        log_message( 'warn', $verbose, 'npm list command not configured, skipping resolved map' );
187
        return;
188
    }
189
190
    my @cmd_parts = split /\s+/smx, $list_cmd;
191
    my @args      = @cmd_parts[ 1 .. $#cmd_parts ];
192
    my ( $output, $exit ) = run_command( $cmd_parts[0], \@args );
193
    if ( $exit != 0 ) {
194
        log_message( 'warn', $verbose, "npm list command failed ($exit), skipping resolved map" );
195
        return;
196
    }
197
198
    my $payload = safe_json_decode($output);
199
    if ( !$payload ) {
200
        log_message( 'warn', $verbose, 'Failed to decode npm list output' );
201
        return;
202
    }
203
204
    my $direct_resolved = {
205
        dependencies    => {},
206
        devDependencies => {},
207
    };
208
    my $packages = {};
209
    $self->_traverse_npm_tree(
210
        $payload->{'dependencies'} || {},
211
        $packages,
212
        $direct_resolved,
213
        $direct_deps
214
    );
215
216
    my $package_list  = [];
217
    my $package_index = {};
218
    for my $name ( sort keys %{$packages} ) {
219
        for my $version ( sort keys %{ $packages->{$name} } ) {
220
            my $type = $packages->{$name}{$version};
221
            $package_index->{$name}{$version} = $type;
222
            push @{$package_list},
223
                {
224
                name    => $name,
225
                version => $version,
226
                type    => $type,
227
                };
228
        }
229
    }
230
231
    my $metadata = {
232
        source        => 'npm list --json',
233
        tool          => 'npm',
234
        package_count => scalar @{$package_list},
235
    };
236
237
    return {
238
        metadata => $metadata,
239
        direct   => $direct_resolved,
240
        packages => $package_list,
241
        index    => $package_index,
242
    };
243
}
244
245
sub _determine_update_type {
246
    my ( $self, $current, $latest ) = @_;
247
248
    if ( !defined $current || !defined $latest ) {
249
        return 'unknown';
250
    }
251
252
    my $current_parts = $self->_version_parts($current);
253
    my $latest_parts  = $self->_version_parts($latest);
254
255
    if ( !@{$current_parts} || !@{$latest_parts} ) {
256
        return 'unknown';
257
    }
258
259
    if ( $latest_parts->[0] > $current_parts->[0] ) {
260
        return 'major';
261
    }
262
    if ( $latest_parts->[1] > $current_parts->[1] ) {
263
        return 'minor';
264
    }
265
    if ( $latest_parts->[2] > $current_parts->[2] ) {
266
        return 'patch';
267
    }
268
269
    return 'up-to-date';
270
}
271
272
sub _version_parts {
273
    my ( $self, $version ) = @_;
274
    if ( !defined $version ) {
275
        return;
276
    }
277
278
    my $clean = $version;
279
    $clean =~ s/^[\^~><=v\s]+//smx;
280
281
    my $parts   = [ split /[.]/smx, $clean ];
282
    my $numeric = [];
283
    for my $part ( @{$parts} ) {
284
        my $match = $part;
285
        push @{$numeric}, ( $match =~ /(\d+)/xms ? $1 : 0 );
286
    }
287
288
    while ( @{$numeric} < 3 ) {
289
        push @{$numeric}, 0;
290
    }
291
292
    return [ @{$numeric}[ 0 .. 2 ] ];
293
}
294
295
sub _analyze_dependency_paths {
296
    my ( $self, $paths, $direct_deps ) = @_;
297
298
    my $direct = {};
299
300
    for my $path ( @{$paths} ) {
301
        if ( !defined $path ) {
302
            next;
303
        }
304
        my $segments = [ split /\s*>\s*/smx, $path ];
305
        for my $segment ( @{$segments} ) {
306
            my $pkg = $self->_normalize_dependency_segment($segment);
307
            if ( !$pkg ) {
308
                next;
309
            }
310
            if ( exists $direct_deps->{$pkg} ) {
311
                $direct->{$pkg} = $direct_deps->{$pkg};
312
            }
313
        }
314
    }
315
316
    my $result = [];
317
    for my $key ( sort keys %{$direct} ) {
318
        my $entry = $direct->{$key};
319
        push @{$result},
320
            {
321
            'name'             => $key,
322
            'type'             => $entry->{'type'},
323
            'version_spec'     => $entry->{'version'},
324
            'resolved_version' => $entry->{'resolved_version'},
325
            };
326
    }
327
    return $result;
328
}
329
330
sub _normalize_dependency_segment {
331
    my ( $self, $segment ) = @_;
332
    if ( !defined $segment ) {
333
        return q{};
334
    }
335
336
    $segment =~ s/^\s+|\s+$//smxg;
337
    if ( !length $segment ) {
338
        return q{};
339
    }
340
341
    # Remove trailing version portion (but keep scoped package prefix)
342
    $segment =~ s/\@(?=[^\/]+$)[^\/]+$//smx;
343
344
    return $segment;
345
}
346
347
sub _npm_node_to_path {
348
    my ( $self, $node ) = @_;
349
    if ( !defined $node ) {
350
        return q{};
351
    }
352
353
    # node_modules/foo/node_modules/bar -> foo>bar
354
    my $segments = [
355
        grep { $_ ne 'node_modules' && $_ ne q{} }
356
            split m{/}smx, $node
357
    ];
358
359
    my $packages = [];
360
    for my $segment ( @{$segments} ) {
361
        push @{$packages},
362
            $self->_normalize_dependency_segment($segment);
363
    }
364
365
    return join '>', @{$packages};
366
}
367
368
sub _find_first_url {
369
    my ( $self, $via ) = @_;
370
371
    if ( ref $via ne 'ARRAY' ) {
372
        return q{};
373
    }
374
    for my $item ( @{$via} ) {
375
        if ( ref $item ne 'HASH' ) {
376
            next;
377
        }
378
        if ( $item->{'url'} ) {
379
            return $item->{'url'};
380
        }
381
    }
382
    return q{};
383
}
384
385
sub _traverse_npm_tree {
386
    my ( $self, $node, $packages, $direct_resolved, $direct_deps, $path ) = @_;
387
388
    $path ||= [];
389
390
    my $precedence = {
391
        transitive      => 0,
392
        devDependencies => 1,
393
        dependencies    => 2,
394
    };
395
396
    for my $name ( keys %{$node} ) {
397
        my $info = $node->{$name} || {};
398
        my $version =
399
               $info->{'version'}
400
            || $info->{'resolved'}
401
            || $info->{'from'}
402
            || 'unknown';
403
404
        my $type = 'transitive';
405
        if (   $direct_deps->{$name}
406
            && $direct_deps->{$name}{'type'} eq 'dependencies' )
407
        {
408
            $type = 'dependencies';
409
            $direct_resolved->{'dependencies'}{$name} = $version;
410
        } elsif ( $direct_deps->{$name}
411
            && $direct_deps->{$name}{'type'} eq 'devDependencies' )
412
        {
413
            $type = 'devDependencies';
414
            $direct_resolved->{'devDependencies'}{$name} = $version;
415
        }
416
417
        my $existing = $packages->{$name}{$version};
418
        if ( !defined $existing
419
            || ( $precedence->{$type} // 0 ) > ( $precedence->{$existing} // 0 ) )
420
        {
421
            $packages->{$name}{$version} = $type;
422
        }
423
424
        $self->_traverse_npm_tree(
425
            $info->{'dependencies'} || {},
426
            $packages,
427
            $direct_resolved,
428
            $direct_deps,
429
            [ @{$path}, $name ]
430
        );
431
    }
432
433
    return;
434
}
435
436
1;
437
438
__END__
439
440
=head1 NAME
441
442
Koha::Devel::Node::Package::Manager::Npm - NPM package manager strategy implementation
443
444
=head1 SYNOPSIS
445
446
    use Koha::Devel::Node::Package::Manager::Npm;
447
448
    my $npm = Koha::Devel::Node::Package::Manager::Npm->new();
449
    my ($outdated, $summary) = $npm->run_outdated_check($config, $deps, $verbose);
450
451
=head1 DESCRIPTION
452
453
This module provides a concrete implementation of the package manager strategy
454
pattern for NPM. It implements the abstract methods defined in
455
Koha::Devel::Node::Package::Manager::Base to handle NPM-specific operations
456
including outdated checks, security audits, and dependency resolution.
457
458
=head1 METHODS
459
460
This class inherits from Koha::Devel::Node::Package::Manager::Base and implements
461
the following abstract methods:
462
463
=head2 run_outdated_check($tool_config, $direct_deps, $verbose)
464
465
Implements outdated dependency check for NPM using 'npm outdated --json' command.
466
467
=head2 run_audit_check($tool_config, $direct_deps, $verbose)
468
469
Implements security audit for NPM using 'npm audit --json' command.
470
471
=head2 build_resolved_dependency_data($tool_config, $direct_deps, $project_root, $verbose)
472
473
Builds complete dependency tree using 'npm list --json' command.
474
475
=head1 PRIVATE METHODS
476
477
The following methods are internal to the implementation:
478
479
=over 4
480
481
=item * _determine_update_type($current, $latest)
482
483
=item * _version_parts($version)
484
485
=item * _analyze_dependency_paths($paths, $direct_deps)
486
487
=item * _normalize_dependency_segment($segment)
488
489
=item * _npm_node_to_path($node)
490
491
=item * _find_first_url($via)
492
493
=item * _traverse_npm_tree($node, $packages, $direct_resolved, $direct_deps, $path)
494
495
=back
496
497
=head1 AUTHOR
498
499
Koha Development Team
500
501
=head1 COPYRIGHT
502
503
Copyright 2025 Koha
504
505
=cut
(-)a/Koha/Devel/Node/Package/Manager/Yarn.pm (+601 lines)
Line 0 Link Here
1
package Koha::Devel::Node::Package::Manager::Yarn;
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 Modern::Perl;
21
use Carp qw(carp croak);
22
use JSON qw(decode_json);
23
24
use Koha::Devel::Node::Utils qw(
25
    log_message
26
    run_command
27
    safe_json_decode
28
    map_dependency_info
29
    normalize_semver_spec
30
);
31
32
use parent 'Koha::Devel::Node::Package::Manager::Base';
33
34
our $VERSION = '1.0.0';
35
36
sub run_outdated_check {
37
    my ( $self, $tool_config, $direct_deps, $verbose ) = @_;
38
39
    my $cmd = $tool_config->{'commands'}{'outdated'};
40
    if ( !$cmd ) {
41
        log_message( 'warn', $verbose, 'Outdated command not configured for yarn' );
42
        return ( {}, {} );
43
    }
44
45
    log_message( 'debug', $verbose, "Running outdated command: $cmd" );
46
    my @cmd_parts = split /\s+/smx, $cmd;
47
    my @args      = @cmd_parts[ 1 .. $#cmd_parts ];
48
    my ( $output, $exit ) = run_command( $cmd_parts[0], \@args );
49
    log_message( 'debug', $verbose, "Outdated command exit code: $exit" );
50
51
    if ( !$output ) {
52
        return ( {}, {} );
53
    }
54
55
    my $packages = {};
56
57
    for my $line ( split /\n/smx, $output ) {
58
        if ( $line !~ /\S/smx ) {
59
            next;
60
        }
61
        if ( $line !~ /^\s*[{]/smx ) {
62
            next;
63
        }
64
        my $payload = safe_json_decode($line);
65
        if ( !$payload ) {
66
            log_message( 'warn', $verbose, "Failed to decode yarn outdated line: $line" );
67
            next;
68
        }
69
70
        if ( !$payload->{'type'} || $payload->{'type'} ne 'table' ) {
71
            next;
72
        }
73
        my $body = $payload->{'data'}{'body'} || [];
74
        for my $row ( @{$body} ) {
75
            if ( ref $row ne 'ARRAY' || @{$row} < 4 ) {
76
                next;
77
            }
78
            my ( $name, $current, $wanted, $latest ) =
79
                @{$row}[ 0 .. 3 ];
80
            my $update_type = $self->_determine_update_type( $current, $latest );
81
82
            $packages->{$name} = {
83
                'current' => $current,
84
                'wanted'  => $wanted,
85
                'latest'  => $latest,
86
                'update'  => $update_type,
87
            };
88
89
            map_dependency_info( $packages, $name, $direct_deps );
90
        }
91
    }
92
93
    my $counts = {};
94
    for my $info ( values %{$packages} ) {
95
        if ( ref $info ne 'HASH' ) {
96
            next;
97
        }
98
        my $update = $info->{'update'} // 'unknown';
99
        $counts->{$update}++;
100
    }
101
102
    my $summary = {
103
        'total'  => scalar keys %{$packages},
104
        'counts' => $counts,
105
    };
106
107
    return ( $packages, $summary );
108
}
109
110
sub run_audit_check {
111
    my ( $self, $tool_config, $direct_deps, $verbose ) = @_;
112
113
    my $cmd = $tool_config->{'commands'}{'audit'};
114
    if ( !$cmd ) {
115
        log_message( 'warn', $verbose, 'Audit command not configured for yarn' );
116
        return ( [], {} );
117
    }
118
119
    log_message( 'debug', $verbose, "Running audit command: $cmd" );
120
    my @cmd_parts = split /\s+/smx, $cmd;
121
    my @args      = @cmd_parts[ 1 .. $#cmd_parts ];
122
    my ( $output, $exit ) = run_command( $cmd_parts[0], \@args );
123
    log_message( 'debug', $verbose, "Audit command exit code: $exit" );
124
125
    if ( !$output ) {
126
        return ( [], {} );
127
    }
128
129
    my $vulnerabilities = [];
130
    my $seen            = {};
131
    for my $line ( split /\n/smx, $output ) {
132
        if ( $line !~ /\S/smx ) {
133
            next;
134
        }
135
        if ( $line !~ /^\s*[{]/smx ) {
136
            next;
137
        }
138
        my $payload = safe_json_decode($line);
139
        if ( !$payload ) {
140
            log_message( 'warn', $verbose, "Failed to decode yarn audit line: $line" );
141
            next;
142
        }
143
144
        if ( !$payload->{'type'} || $payload->{'type'} ne 'auditAdvisory' ) {
145
            next;
146
        }
147
148
        my $advisory   = $payload->{'data'}{'advisory'}   || {};
149
        my $resolution = $payload->{'data'}{'resolution'} || {};
150
151
        my $findings =
152
            [ @{ $advisory->{'findings'} || [] } ];
153
        my $paths_seen = {};
154
        for my $finding ( @{$findings} ) {
155
            if ( ref $finding ne 'HASH' ) {
156
                next;
157
            }
158
            for my $path ( @{ $finding->{'paths'} || [] } ) {
159
                if ( !defined $path ) {
160
                    next;
161
                }
162
                $paths_seen->{$path} = 1;
163
            }
164
        }
165
166
        my $paths = [ sort keys %{$paths_seen} ];
167
168
        my $severity = lc( $advisory->{'severity'} // 'unknown' );
169
170
        my $module = $advisory->{'module_name'} // 'unknown';
171
        my $current_version =
172
              $advisory->{'findings'} && @{ $advisory->{'findings'} } && $advisory->{'findings'}[0]{'version'}
173
            ? $advisory->{'findings'}[0]{'version'}
174
            : $resolution->{'currentVersion'} // 'unknown';
175
176
        my $patched = $advisory->{'patched_versions'} // q{};
177
        if ($patched) {
178
            $patched =~ s/^[\^~><=\s]+//smx;
179
        }
180
181
        my $recommendation = $advisory->{'recommendation'} // q{};
182
183
        my $key = join q{:}, $module, $current_version, @{$paths};
184
        if ( $seen->{$key} ) {
185
            next;
186
        }
187
        $seen->{$key} = 1;
188
189
        push @{$vulnerabilities},
190
            {
191
            'package'             => $module,
192
            'severity'            => $severity,
193
            'current_version'     => $current_version,
194
            'fixed_in'            => $patched || undef,
195
            'url'                 => $advisory->{'url'} // q{},
196
            'recommendation'      => $recommendation,
197
            'dependency_paths'    => $paths,
198
            'direct_dependencies' => $self->_analyze_dependency_paths( $paths, $direct_deps ),
199
            };
200
    }
201
202
    my $severity_counts = {};
203
    for my $entry ( @{$vulnerabilities} ) {
204
        my $severity = lc( $entry->{'severity'} // 'unknown' );
205
        $severity_counts->{$severity}++;
206
    }
207
208
    my $summary = {
209
        'total'           => scalar @{$vulnerabilities},
210
        'severity_counts' => $severity_counts,
211
    };
212
213
    return ( $vulnerabilities, $summary );
214
}
215
216
sub build_resolved_dependency_data {
217
    my ( $self, $tool_config, $direct_deps, $project_root, $verbose ) = @_;
218
219
    my $list_cmd = $tool_config->{'commands'}{'list'};
220
    if ( !$list_cmd ) {
221
        log_message( 'warn', $verbose, 'yarn list command not configured, skipping resolved map' );
222
        return;
223
    }
224
225
    log_message( 'debug', $verbose, "Running yarn list command: $list_cmd" );
226
    my @cmd_parts = split /\s+/smx, $list_cmd;
227
    my @args      = @cmd_parts[ 1 .. $#cmd_parts ];
228
    my ( $output, $exit ) = run_command( $cmd_parts[0], \@args );
229
    log_message( 'debug', $verbose, "yarn list command exit code: $exit" );
230
231
    if ( $exit != 0 ) {
232
        log_message( 'warn', $verbose, "yarn list command failed ($exit), skipping resolved map" );
233
        return;
234
    }
235
236
    my $nodes = [];
237
    for my $line ( split /\n/smx, $output ) {
238
        if ( $line !~ /\S/smx ) {
239
            next;
240
        }
241
        my $payload = safe_json_decode($line);
242
        if ( !$payload ) {
243
            log_message( 'warn', $verbose, "Failed to decode yarn list line: $line" );
244
            next;
245
        }
246
247
        my $type = $payload->{'type'} // q{};
248
        if ( $type eq 'warning' || $type eq 'info' ) {
249
            next;
250
        }
251
252
        if ( $type eq 'tree' ) {
253
            my $trees = $payload->{'data'}{'trees'} || [];
254
            push @{$nodes}, @{$trees};
255
        }
256
    }
257
258
    if ( !@{$nodes} ) {
259
        log_message( 'warn', $verbose, 'yarn list produced no dependency data' );
260
        return;
261
    }
262
263
    return $self->_process_yarn_nodes( $nodes, $direct_deps, $verbose );
264
}
265
266
sub _determine_update_type {
267
    my ( $self, $current, $latest ) = @_;
268
269
    if ( !defined $current || !defined $latest ) {
270
        return 'unknown';
271
    }
272
273
    my $current_parts = $self->_version_parts($current);
274
    my $latest_parts  = $self->_version_parts($latest);
275
276
    if ( !@{$current_parts} || !@{$latest_parts} ) {
277
        return 'unknown';
278
    }
279
280
    if ( $latest_parts->[0] > $current_parts->[0] ) {
281
        return 'major';
282
    }
283
    if ( $latest_parts->[1] > $current_parts->[1] ) {
284
        return 'minor';
285
    }
286
    if ( $latest_parts->[2] > $current_parts->[2] ) {
287
        return 'patch';
288
    }
289
290
    return 'up-to-date';
291
}
292
293
sub _version_parts {
294
    my ( $self, $version ) = @_;
295
    if ( !defined $version ) {
296
        return;
297
    }
298
299
    my $clean = $version;
300
    $clean =~ s/^[\^~><=v\s]+//smx;
301
302
    my $parts   = [ split /[.]/smx, $clean ];
303
    my $numeric = [];
304
    for my $part ( @{$parts} ) {
305
        my $match = $part;
306
        push @{$numeric}, ( $match =~ /(\d+)/xms ? $1 : 0 );
307
    }
308
309
    while ( @{$numeric} < 3 ) {
310
        push @{$numeric}, 0;
311
    }
312
313
    return [ @{$numeric}[ 0 .. 2 ] ];
314
}
315
316
sub _analyze_dependency_paths {
317
    my ( $self, $paths, $direct_deps ) = @_;
318
319
    my $direct = {};
320
321
    for my $path ( @{$paths} ) {
322
        if ( !defined $path ) {
323
            next;
324
        }
325
        my $segments = [ split /\s*>\s*/smx, $path ];
326
        for my $segment ( @{$segments} ) {
327
            my $pkg = $self->_normalize_dependency_segment($segment);
328
            if ( !$pkg ) {
329
                next;
330
            }
331
            if ( exists $direct_deps->{$pkg} ) {
332
                $direct->{$pkg} = $direct_deps->{$pkg};
333
            }
334
        }
335
    }
336
337
    my $result = [];
338
    for my $key ( sort keys %{$direct} ) {
339
        my $entry = $direct->{$key};
340
        push @{$result},
341
            {
342
            'name'             => $key,
343
            'type'             => $entry->{'type'},
344
            'version_spec'     => $entry->{'version'},
345
            'resolved_version' => $entry->{'resolved_version'},
346
            };
347
    }
348
    return $result;
349
}
350
351
sub _normalize_dependency_segment {
352
    my ( $self, $segment ) = @_;
353
    if ( !defined $segment ) {
354
        return q{};
355
    }
356
357
    $segment =~ s/^\s+|\s+$//smxg;
358
    if ( !length $segment ) {
359
        return q{};
360
    }
361
362
    # Remove trailing version portion (but keep scoped package prefix)
363
    $segment =~ s/\@(?=[^\/]+$)[^\/]+$//smx;
364
365
    return $segment;
366
}
367
368
sub _process_yarn_nodes {
369
    my ( $self, $nodes, $direct_deps, $verbose ) = @_;
370
371
    my $packages        = {};
372
    my $package_index   = {};
373
    my $direct_resolved = {
374
        dependencies    => {},
375
        devDependencies => {},
376
    };
377
378
    my $precedence = {
379
        transitive      => 0,
380
        devDependencies => 1,
381
        dependencies    => 2,
382
    };
383
384
    my $visit;
385
    $visit = sub {
386
        my ($node) = @_;
387
        if ( ref $node ne 'HASH' ) {
388
            return;
389
        }
390
391
        for my $child ( @{ $node->{'children'} || [] } ) {
392
            $visit->($child);
393
        }
394
395
        if ( $node->{'shadow'} ) {
396
            return;
397
        }
398
399
        my $raw = $node->{'name'};
400
        if ( !defined $raw ) {
401
            return;
402
        }
403
404
        my ( $pkg, $descriptor ) = $self->_parse_yarn_identifier($raw);
405
        if ( !$pkg || !defined $descriptor ) {
406
            return;
407
        }
408
409
        my $version = $self->_extract_yarn_version($descriptor);
410
        if ( !defined $version || $version eq q{} ) {
411
            return;
412
        }
413
414
        my $type = 'transitive';
415
        if ( my $direct = $direct_deps->{$pkg} ) {
416
            my $bucket =
417
                $direct->{'type'} eq 'devDependencies'
418
                ? 'devDependencies'
419
                : 'dependencies';
420
            $type = $bucket;
421
            $direct_resolved->{$bucket}{$pkg} //= $version;
422
        }
423
424
        my $existing = $packages->{$pkg}{$version};
425
        if ( !defined $existing
426
            || ( $precedence->{$type} // 0 ) > ( $precedence->{$existing} // 0 ) )
427
        {
428
            $packages->{$pkg}{$version} = $type;
429
        }
430
    };
431
432
    $visit->($_) for @{$nodes};
433
434
    if ( !keys %{$packages} ) {
435
        log_message( 'warn', $verbose, 'yarn list traversal yielded no packages' );
436
        return;
437
    }
438
439
    my $package_list = [];
440
    for my $name ( sort keys %{$packages} ) {
441
        for my $version ( sort keys %{ $packages->{$name} } ) {
442
            my $type = $packages->{$name}{$version};
443
            $package_index->{$name}{$version} = $type;
444
            push @{$package_list},
445
                {
446
                name    => $name,
447
                version => $version,
448
                type    => $type,
449
                };
450
        }
451
    }
452
453
    my $spec_map = {};
454
    for my $name ( keys %{$direct_deps} ) {
455
        my $spec = $direct_deps->{$name}{'version'};
456
        if ( !defined $spec || $spec eq q{} ) {
457
            next;
458
        }
459
460
        my $bucket =
461
            $direct_deps->{$name}{'type'} eq 'devDependencies'
462
            ? 'devDependencies'
463
            : 'dependencies';
464
        my $resolved = $direct_resolved->{$bucket}{$name};
465
466
        if ( !defined $resolved || $resolved eq q{} ) {
467
            next;
468
        }
469
470
        $spec_map->{"$name\@$spec"} = $resolved;
471
472
        if ( my $normalized = normalize_semver_spec($spec) ) {
473
            $spec_map->{"$name\@$normalized"} //= $resolved;
474
        }
475
    }
476
477
    my $metadata = {
478
        source        => 'yarn list --json',
479
        tool          => 'yarn',
480
        package_count => scalar @{$package_list},
481
    };
482
483
    return {
484
        metadata => $metadata,
485
        direct   => $direct_resolved,
486
        packages => $package_list,
487
        spec_map => $spec_map,
488
        index    => $package_index,
489
    };
490
}
491
492
sub _parse_yarn_identifier {
493
    my ( $self, $identifier ) = @_;
494
    if ( !defined $identifier ) {
495
        return;
496
    }
497
498
    $identifier =~ s/^\s+//smx;
499
    $identifier =~ s/\s+$//smx;
500
501
    if ( $identifier =~ /^(@[^\/]+\/[^@]+)@(.+)$/smx ) {
502
        return ( $1, $2 );
503
    } elsif ( $identifier =~ /^([^@]+)@(.+)$/smx ) {
504
        return ( $1, $2 );
505
    }
506
507
    return;
508
}
509
510
sub _extract_yarn_version {
511
    my ( $self, $descriptor ) = @_;
512
    if ( !defined $descriptor ) {
513
        return;
514
    }
515
516
    my $value = $descriptor;
517
    $value =~ s/^\s+|\s+$//smxg;
518
519
    if ( $value =~ /^([^:]+):(.+)$/smx ) {
520
        my ( $prefix, $rest ) = ( $1, $2 );
521
        if ( $prefix eq 'npm' ) {
522
            return $rest;
523
        }
524
        return "$prefix:$rest";
525
    }
526
527
    $value =~ s/^[\^~=\s]+//smx;
528
529
    return length $value ? $value : undef;
530
}
531
532
1;
533
534
__END__
535
536
=head1 NAME
537
538
Koha::Devel::Node::Package::Manager::Yarn - Yarn package manager strategy implementation
539
540
=head1 SYNOPSIS
541
542
    use Koha::Devel::Node::Package::Manager::Yarn;
543
544
    my $yarn = Koha::Devel::Node::Package::Manager::Yarn->new();
545
    my ($outdated, $summary) = $yarn->run_outdated_check($config, $deps, $verbose);
546
547
=head1 DESCRIPTION
548
549
This module provides a concrete implementation of the package manager strategy
550
pattern for Yarn. It implements the abstract methods defined in
551
Koha::Devel::Node::Package::Manager::Base to handle Yarn-specific operations
552
including outdated checks, security audits, and dependency resolution.
553
554
=head1 METHODS
555
556
This class inherits from Koha::Devel::Node::Package::Manager::Base and implements
557
the following abstract methods:
558
559
=head2 run_outdated_check($tool_config, $direct_deps, $verbose)
560
561
Implements outdated dependency check for Yarn using 'yarn outdated --json' command.
562
563
=head2 run_audit_check($tool_config, $direct_deps, $verbose)
564
565
Implements security audit for Yarn using 'yarn audit --json' command.
566
567
=head2 build_resolved_dependency_data($tool_config, $direct_deps, $project_root, $verbose)
568
569
Builds complete dependency tree using 'yarn list --json' command.
570
571
=head1 PRIVATE METHODS
572
573
The following methods are internal to the implementation:
574
575
=over 4
576
577
=item * _determine_update_type($current, $latest)
578
579
=item * _version_parts($version)
580
581
=item * _analyze_dependency_paths($paths, $direct_deps)
582
583
=item * _normalize_dependency_segment($segment)
584
585
=item * _process_yarn_nodes($nodes, $direct_deps, $verbose)
586
587
=item * _parse_yarn_identifier($identifier)
588
589
=item * _extract_yarn_version($descriptor)
590
591
=back
592
593
=head1 AUTHOR
594
595
Koha Development Team
596
597
=head1 COPYRIGHT
598
599
Copyright 2025 Koha
600
601
=cut
(-)a/Koha/Devel/Node/Utils.pm (+1044 lines)
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/&/&amp;/smxg;
513
    $escaped =~ s/</&lt;/smxg;
514
    $escaped =~ s/>/&gt;/smxg;
515
    $escaped =~ s/"/&quot;/smxg;
516
    $escaped =~ s/'/&apos;/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
(-)a/Koha/Devel/Node/templates/dependency_bugzilla_report.md.tt (+80 lines)
Line 0 Link Here
1
# Node.js Dependency Security Report
2
3
**Generated**: [% generated_at | html %]
4
**Package Manager**: [% tool | html %]
5
6
## Security Vulnerabilities
7
8
[% IF vulnerabilities.size %]
9
[% FOREACH vuln IN vulnerabilities -%]
10
### [% vuln.package | html %] ([% vuln.severity_label | html %])
11
12
**Package**: [% vuln.package | html %]
13
**Severity**: [% vuln.severity_label | html %]
14
**Current Version**: [% vuln.current_version | html %]
15
16
[% IF vuln.fixed_in %]
17
**Fixed In**: [% vuln.fixed_in | html %]
18
**Recommendation**: Update to [% vuln.fixed_in | html %]
19
[% ELSIF vuln.recommendation %]
20
**Recommendation**: [% vuln.recommendation | html %]
21
[% ELSE %]
22
**Recommendation**: Monitor for updates
23
[% END %]
24
25
[% IF vuln.url %]
26
**Details**: [% vuln.url | html %]
27
[% END %]
28
29
[% IF vuln.dependency_paths.size %]
30
**Dependency Paths**:
31
```
32
[% FOREACH path IN vuln.dependency_paths -%]
33
[% path | html %]
34
[% END -%]
35
```
36
[% END %]
37
38
[% IF vuln.direct_dependencies.size %]
39
**Direct Dependencies**:
40
[% FOREACH dep IN vuln.direct_dependencies -%]
41
- [% dep.info | html %]
42
[% END %]
43
44
[% END %]
45
46
[% END -%]
47
[% ELSE %]
48
No vulnerabilities detected at configured severity threshold.
49
[% END %]
50
51
## Outdated Packages
52
53
[% IF outdated.size %]
54
[% FOREACH item IN outdated -%]
55
### [% item.name | html %]
56
57
**Current**: [% item.current | html %]
58
**Wanted**: [% item.wanted | html %]
59
**Latest**: [% item.latest | html %]
60
61
[% IF item.resolved %]
62
**Resolved**: [% item.resolved | html %]
63
[% END %]
64
65
[% IF item.scope %]
66
**Scope**: [% item.scope | html %]
67
[% END %]
68
69
**Update Type**: [% item.update | html %]
70
71
[% END -%]
72
[% ELSE %]
73
All listed dependencies are on the latest locked versions.
74
[% END %]
75
76
## Next Steps
77
78
1. Review vulnerabilities and schedule fixes in Bugzilla.
79
2. Plan updates for major-version or security related packages.
80
3. Validate dependency changes in a controlled environment.
(-)a/Koha/Devel/Node/templates/dependency_sbom.xml.tt (+27 lines)
Line 0 Link Here
1
<?xml version="1.0" encoding="UTF-8"?>
2
<bom xmlns="https://cyclonedx.org/schema/bom/1.4" version="1" serialNumber="urn:uuid:[% serial | html %]">
3
    <metadata>
4
        <timestamp>[% timestamp | html %]</timestamp>
5
        <tools>
6
            <tool>
7
                <vendor>Koha Community</vendor>
8
                <name>[% tool_name | html %]</name>
9
                <version>[% tool_version | html %]</version>
10
            </tool>
11
        </tools>
12
        <component type="application">
13
            <name>Koha</name>
14
            <version>[% app_version | html %]</version>
15
        </component>
16
    </metadata>
17
    <components>
18
[% FOREACH component IN components -%]
19
        <component type="library" bom-ref="[% component.purl | html %]">
20
            <name>[% component.name | html %]</name>
21
            <version>[% component.version | html %]</version>
22
            <purl>[% component.purl | html %]</purl>
23
            <scope>[% component.scope | html %]</scope>
24
        </component>
25
[% END -%]
26
    </components>
27
</bom>
(-)a/misc/devel/node_audit_compromise.pl (+321 lines)
Line 0 Link Here
1
#!/usr/bin/perl
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 Modern::Perl;
21
22
use Carp                  qw(croak);
23
use Cwd                   qw(abs_path);
24
use English               qw(-no_match_vars);
25
use File::Spec::Functions qw(catfile);
26
use Getopt::Long          qw(GetOptions);
27
use POSIX                 qw(strftime isatty);
28
use Pod::Usage            qw(pod2usage);
29
30
use FindBin;
31
use lib $FindBin::Bin;
32
33
use Koha::Devel::Node::Utils qw(
34
    log_message
35
    load_config
36
    find_project_root
37
    detect_package_manager
38
    change_to_project_root
39
    load_package_json
40
    build_direct_dependency_map
41
    parse_compromise_line
42
    run_compromise_check
43
    write_json_results
44
    determine_exit_code
45
);
46
47
use Koha::Devel::Node::Package::Manager::Base;
48
use Koha::Devel::Node::Package::Manager::Yarn;
49
use Koha::Devel::Node::Package::Manager::Npm;
50
51
sub main {
52
    my $opt_config_file = q{};
53
    my $opt_json_output = q{};
54
    my $opt_input_file  = q{};
55
    my $opt_inline      = q{};
56
    my $opt_stdin       = 0;
57
    my $opt_verbose     = 0;
58
    my $opt_help        = 0;
59
60
    GetOptions(
61
        'config-file=s' => \$opt_config_file,
62
        'json=s'        => \$opt_json_output,
63
        'input-file=s'  => \$opt_input_file,
64
        'inline=s'      => \$opt_inline,
65
        'stdin'         => \$opt_stdin,
66
        'verbose'       => \$opt_verbose,
67
        'help'          => \$opt_help,
68
    ) or pod2usage(2);
69
70
    if ($opt_help) {
71
        pod2usage(0);
72
    }
73
74
    my $queries = build_queries(
75
        {
76
            input_file => $opt_input_file,
77
            inline     => $opt_inline,
78
            stdin      => $opt_stdin,
79
        }
80
    );
81
82
    my $script_dir  = $FindBin::Bin;
83
    my $default_cfg = catfile( $script_dir, 'node_audit_config.yml' );
84
    my $config_path = $opt_config_file ? abs_path($opt_config_file) : $default_cfg;
85
    if ($opt_config_file) {
86
        $config_path //= $opt_config_file;
87
    }
88
89
    my $config       = load_config($config_path);
90
    my $project_root = find_project_root();
91
    my $package_json = load_package_json($project_root);
92
    my $direct_deps  = build_direct_dependency_map($package_json);
93
    my $tool         = detect_package_manager();
94
95
    my $tool_cfg = {};
96
    if ( $config && ref $config eq 'HASH' && $config->{'tools'} && $config->{'tools'}{$tool} ) {
97
        $tool_cfg = $config->{'tools'}{$tool};
98
    }
99
100
    my $strategy = create_package_manager_strategy($tool);
101
    my $resolved_data =
102
        $strategy->build_resolved_dependency_data( $tool_cfg, $direct_deps, $project_root, $opt_verbose );
103
104
    if ( $resolved_data && $resolved_data->{'direct'} ) {
105
        for my $section (qw(dependencies devDependencies)) {
106
            my $resolved_section = $resolved_data->{'direct'}{$section} || {};
107
            for my $name ( keys %{$resolved_section} ) {
108
                next if !$direct_deps->{$name};
109
                next if $direct_deps->{$name}{'type'} ne $section;
110
                $direct_deps->{$name}{'resolved_version'} = $resolved_section->{$name};
111
            }
112
        }
113
    }
114
115
    change_to_project_root($project_root);
116
117
    log_message( 'info', $opt_verbose, "Using package manager: $tool" );
118
119
    my $results = {
120
        'tool'         => $tool,
121
        'generated_at' => strftime( '%Y-%m-%dT%H:%M:%SZ', gmtime ),
122
        'metadata'     => {
123
            'project_root' => $project_root,
124
            'config_file'  => $config_path,
125
        },
126
        'direct_dependencies'   => $direct_deps,
127
        'resolved_dependencies' => $resolved_data,
128
    };
129
130
    my $compromise = run_compromise_check( $queries, $resolved_data, $opt_verbose );
131
    $results->{'compromise_check'} = $compromise;
132
133
    my $json_output;
134
    if ( defined $opt_json_output && length $opt_json_output ) {
135
        $json_output = $opt_json_output;
136
    } else {
137
        $json_output = catfile( $project_root, 'dependency_check_results.json' );
138
    }
139
140
    write_json_results( $json_output, $results, $opt_verbose );
141
142
    my $thresholds = {};
143
    if ( $config && ref $config eq 'HASH' && $config->{'thresholds'} ) {
144
        $thresholds = $config->{'thresholds'};
145
    }
146
147
    my $exit_code = determine_exit_code( $results, $thresholds );
148
    log_message( 'info', $opt_verbose, "Exit code: $exit_code" );
149
    return $exit_code;
150
}
151
152
sub build_queries {
153
    my ($args) = @_;
154
155
    my $lines = [];
156
157
    if ( my $file = $args->{input_file} ) {
158
        if ( !-e $file ) {
159
            croak "Input file '$file' not found";
160
        }
161
        open my $fh, '<', $file or croak "Cannot open $file: $OS_ERROR";
162
        push @{$lines}, <$fh>;
163
        close $fh or croak;
164
    }
165
166
    if ( defined $args->{inline} && length $args->{inline} ) {
167
        push @{$lines}, map { "$_\n" } split /\n/smx, $args->{inline};
168
    }
169
170
    my $should_read_stdin = $args->{stdin};
171
    if ( !$should_read_stdin && !@{$lines} ) {
172
        my $stdin_fd = fileno STDIN;
173
        if ( !defined $stdin_fd || !isatty($stdin_fd) ) {
174
            $should_read_stdin = 1;
175
        }
176
    }
177
178
    if ($should_read_stdin) {
179
        push @{$lines}, <STDIN>;
180
    }
181
182
    my $queries = [];
183
    for my $raw_line ( @{$lines} ) {
184
        my $line = $raw_line;
185
        $line =~ s/\r?\n$//smx;
186
        my $trimmed = $line;
187
        $trimmed =~ s/^\s+|\s+$//smxg;
188
189
        if ( $trimmed eq q{} ) {
190
            next;
191
        }
192
        if ( index( $trimmed, q{#} ) == 0 ) {
193
            next;
194
        }
195
        if ( $trimmed =~ /^\s*\/\//smx ) {
196
            next;
197
        }
198
199
        my $parsed = parse_compromise_line($trimmed);
200
        if ( $parsed->{'error'} ) {
201
            push @{$queries},
202
                {
203
                'input' => $trimmed,
204
                'error' => $parsed->{'error'},
205
                };
206
        } else {
207
            $parsed->{'input'} = $trimmed;
208
            push @{$queries}, $parsed;
209
        }
210
    }
211
212
    return $queries;
213
}
214
215
sub create_package_manager_strategy {
216
    my ($tool) = @_;
217
218
    if ( $tool eq 'yarn' ) {
219
        return Koha::Devel::Node::Package::Manager::Yarn->new;
220
    } elsif ( $tool eq 'npm' ) {
221
        return Koha::Devel::Node::Package::Manager::Npm->new;
222
    }
223
224
    croak "Unsupported package manager: $tool";
225
}
226
227
exit main();
228
229
__END__
230
231
=head1 NAME
232
233
node_audit_compromise.pl - Standalone Node.js compromise detection helper
234
235
=head1 SYNOPSIS
236
237
    node_audit_compromise.pl [options]
238
239
=head1 DESCRIPTION
240
241
Reads a list of package identifiers, resolves the current dependency graph, and
242
reports matches. Output is emitted as JSON for downstream automation, and exit
243
codes honour the thresholds defined in C<misc/devel/node_audit_config.yml>.
244
245
=head1 OPTIONS
246
247
=over 4
248
249
=item B<--input-file FILE>
250
251
Read package identifiers from FILE (one per line).
252
253
=item B<--inline STRING>
254
255
Provide newline-separated package identifiers directly on the command line.
256
257
=item B<--stdin>
258
259
Force reading package identifiers from STDIN. When neither C<--input-file> nor
260
C<--inline> is provided, the script automatically reads STDIN when it is not a
261
TTY.
262
263
=item B<--config-file FILE>
264
265
Use an alternate configuration file. Defaults to
266
C<misc/devel/node_audit_config.yml>.
267
268
=item B<--json FILE>
269
270
Write combined JSON results to FILE. Defaults to
271
C<dependency_check_results.json> in the project root.
272
273
=item B<--verbose>
274
275
Emit debug logging.
276
277
=item B<--help>
278
279
Show usage information.
280
281
=back
282
283
=head1 OUTPUT
284
285
The JSON payload mirrors the structure produced by
286
C<node_audit_dependencies.pl> and adds a C<compromise_check> section with:
287
288
=over 4
289
290
=item * C<summary> - totals for matches, missing packages, and invalid inputs.
291
292
=item * C<queries> - per-identifier results containing C<status> (C<match>,
293
C<no-match>, or C<invalid>), the normalised package name, and resolved versions.
294
295
=back
296
297
Helpful C<jq> snippets when reviewing the output:
298
299
=over 4
300
301
=item * C<jq '.compromise_check.summary' dependency_check_results.json> - quick
302
totals for compromise queries.
303
304
=item * C<jq '.compromise_check.queries[] | select(.status==\"match\")' dependency_check_results.json>
305
- list only matching packages.
306
307
=back
308
309
Exit codes follow the threshold settings defined in
310
C<misc/devel/node_audit_config.yml>; the helper exits non-zero when a match is
311
found and C<compromise_fail_on_match> is enabled.
312
313
=head1 AUTHOR
314
315
Koha Development Team
316
317
=head1 COPYRIGHT
318
319
Copyright 2025 Koha
320
321
=cut
(-)a/misc/devel/node_audit_config.yml (+51 lines)
Line 0 Link Here
1
# Node.js dependency management configuration
2
3
tools:
4
  yarn:
5
    check: 'yarn --version'
6
    install: 'yarn install --frozen-lockfile --ignore-scripts'
7
    commands:
8
      outdated: 'yarn outdated --json'
9
      audit: 'yarn audit --json --level moderate'
10
      list: 'yarn list --json'
11
      why: 'yarn why'
12
  npm:
13
    check: 'npm --version'
14
    install: 'npm ci --ignore-scripts'
15
    commands:
16
      outdated: 'npm outdated --json'
17
      audit: 'npm audit --json --audit-level moderate'
18
      list: 'npm list --json'
19
      why: 'npm explain'
20
21
detection_order:
22
  - yarn
23
  - npm
24
25
sbom:
26
  output_file: 'node_modules_sbom.xml'
27
  template: 'dependency_sbom.xml.tt'
28
  include_dev_dependencies: true
29
30
bugzilla:
31
  output_file: 'dependency_bugzilla_report.md'
32
  template: 'dependency_bugzilla_report.md.tt'
33
  show_vulnerabilities: true
34
  show_outdated: true
35
  show_next_steps: true
36
  summary: true
37
  dependency_path_limit: 0   # 0 disables truncation, use positive integer to limit paths per vulnerability
38
  severity_order:
39
    - critical
40
    - high
41
    - moderate
42
    - low
43
    - info
44
  metadata: {}
45
  metadata_env: []            # entries may be ENV_NAME or label=ENV_NAME
46
47
thresholds:
48
  audit_fail_level: 'moderate'
49
  major_version_fail: 1
50
  vulnerable_fail: 0          # set to 1 to fail if any vulnerability is detected
51
  compromise_fail_on_match: 1 # fail if compromised package check finds a hit
(-)a/misc/devel/node_audit_dependencies.pl (+537 lines)
Line 0 Link Here
1
#!/usr/bin/perl
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 Modern::Perl;
21
22
our $VERSION = '1.0.0';
23
24
use Carp                  qw(croak);
25
use Cwd                   qw(abs_path getcwd);
26
use English               qw(-no_match_vars);
27
use File::Basename        qw(dirname);
28
use File::Spec::Functions qw(catfile catdir updir);
29
use Getopt::Long          qw(GetOptions);
30
use JSON                  qw(decode_json encode_json);
31
use POSIX                 qw(strftime);
32
use Pod::Usage            qw(pod2usage);
33
use YAML                  qw(LoadFile);
34
use Template;
35
36
use Koha::Devel::Node::Utils qw(
37
    log_message
38
    load_config
39
    find_project_root
40
    detect_package_manager
41
    run_command
42
    safe_json_decode
43
    change_to_project_root
44
    map_dependency_info
45
    determine_update_type
46
    version_parts
47
    generate_purl
48
    generate_uuid
49
    normalize_semver_spec
50
    load_package_json
51
    build_direct_dependency_map
52
    write_json_results
53
    determine_exit_code
54
    build_bugzilla_report_data
55
    build_sbom_data
56
    detect_koha_version
57
);
58
59
use Koha::Devel::Node::Package::Manager::Base;
60
use Koha::Devel::Node::Package::Manager::Yarn;
61
use Koha::Devel::Node::Package::Manager::Npm;
62
63
sub get_context {
64
    state $context = {};
65
    return $context;
66
}
67
68
sub set_context {
69
    my ( $key, $value ) = @_;
70
    get_context()->{$key} = $value;
71
    return;
72
}
73
74
sub get_verbose {
75
    return get_context()->{'verbose'} // 0;
76
}
77
78
sub get_config {
79
    return get_context()->{'config'} // {};
80
}
81
82
sub get_project_root {
83
    return get_context()->{'project_root'};
84
}
85
86
sub get_tool {
87
    return get_context()->{'tool'};
88
}
89
90
sub get_tool_config {
91
    return get_context()->{'tool_config'} // {};
92
}
93
94
sub get_template_dir {
95
    return get_context()->{'template_dir'};
96
}
97
98
sub get_direct_deps {
99
    return get_context()->{'direct_deps'} // {};
100
}
101
102
sub get_resolved_data {
103
    return get_context()->{'resolved_data'};
104
}
105
106
sub get_package_json {
107
    return get_context()->{'package_json'} // {};
108
}
109
110
sub initialize_context {
111
    my ($args) = @_;
112
113
    set_context( 'verbose',       $args->{'verbose'} // 0 );
114
    set_context( 'config',        $args->{'config'}  // {} );
115
    set_context( 'project_root',  $args->{'project_root'} );
116
    set_context( 'tool',          $args->{'tool'} );
117
    set_context( 'tool_config',   $args->{'tool_config'} // {} );
118
    set_context( 'direct_deps',   $args->{'direct_deps'} // {} );
119
    set_context( 'resolved_data', $args->{'resolved_data'} );
120
    set_context( 'package_json',  $args->{'package_json'} // {} );
121
    set_context( 'template_dir',  $args->{'template_dir'} );
122
123
    return;
124
}
125
126
sub create_package_manager_strategy {
127
    my ($tool) = @_;
128
129
    if ( $tool eq 'yarn' ) {
130
        return Koha::Devel::Node::Package::Manager::Yarn->new;
131
    } elsif ( $tool eq 'npm' ) {
132
        return Koha::Devel::Node::Package::Manager::Npm->new;
133
    } else {
134
        croak "Unsupported package manager: $tool";
135
    }
136
}
137
138
sub main {
139
    my $opt_outdated    = 0;
140
    my $opt_audit       = 0;
141
    my $opt_sbom        = 0;
142
    my $opt_bugzilla    = 0;
143
    my $opt_verbose     = 0;
144
    my $opt_config_file = q{};
145
    my $opt_json_output = q{};
146
    my $opt_help        = 0;
147
148
    GetOptions(
149
        'outdated'      => \$opt_outdated,
150
        'audit'         => \$opt_audit,
151
        'sbom'          => \$opt_sbom,
152
        'bugzilla'      => \$opt_bugzilla,
153
        'verbose'       => \$opt_verbose,
154
        'config-file=s' => \$opt_config_file,
155
        'json=s'        => \$opt_json_output,
156
        'help'          => \$opt_help,
157
    ) or pod2usage(2);
158
159
    if ($opt_help) {
160
        pod2usage(0);
161
    }
162
163
    if ( !$opt_outdated && !$opt_audit && !$opt_sbom && !$opt_bugzilla ) {
164
        $opt_outdated = 1;
165
        $opt_audit    = 1;
166
        $opt_sbom     = 1;
167
        $opt_bugzilla = 1;
168
    }
169
170
    my $script_dir   = dirname( abs_path($PROGRAM_NAME) );
171
    my $template_dir = catdir( $script_dir, q{..}, q{..}, 'Koha', 'Devel', 'Node', 'templates' );
172
    my $default_cfg  = catfile( $script_dir, 'node_audit_config.yml' );
173
    my $config_path  = $opt_config_file ? abs_path($opt_config_file) : $default_cfg;
174
    if ($opt_config_file) {
175
        $config_path //= $opt_config_file;
176
    }
177
    my $config       = load_config($config_path);
178
    my $project_root = find_project_root();
179
    my $package_json = load_package_json($project_root);
180
    my $direct_deps  = build_direct_dependency_map($package_json);
181
    my $tool         = detect_package_manager();
182
    my $tool_cfg     = {};
183
    if ( $config && ref $config eq 'HASH' && $config->{'tools'} && $config->{'tools'}{$tool} ) {
184
        $tool_cfg = $config->{'tools'}{$tool};
185
    }
186
    my $strategy = create_package_manager_strategy($tool);
187
    my $resolved_data =
188
        $strategy->build_resolved_dependency_data( $tool_cfg, $direct_deps, $project_root, $opt_verbose );
189
190
    if ( $resolved_data && $resolved_data->{'direct'} ) {
191
        for my $section (qw(dependencies devDependencies)) {
192
            my $resolved_section = $resolved_data->{'direct'}{$section} || {};
193
            for my $name ( keys %{$resolved_section} ) {
194
                if ( !$direct_deps->{$name} ) {
195
                    next;
196
                }
197
                if ( $direct_deps->{$name}{'type'} ne $section ) {
198
                    next;
199
                }
200
                $direct_deps->{$name}{'resolved_version'} = $resolved_section->{$name};
201
            }
202
        }
203
    }
204
205
    initialize_context(
206
        {
207
            'verbose'       => $opt_verbose,
208
            'config'        => $config,
209
            'project_root'  => $project_root,
210
            'tool'          => $tool,
211
            'tool_config'   => $tool_cfg,
212
            'direct_deps'   => $direct_deps,
213
            'resolved_data' => $resolved_data,
214
            'package_json'  => $package_json,
215
            'template_dir'  => $template_dir,
216
        }
217
    );
218
219
    change_to_project_root($project_root);
220
221
    log_message( 'info', $opt_verbose, "Using package manager: $tool" );
222
223
    my $results = {
224
        'tool'         => $tool,
225
        'generated_at' => strftime( '%Y-%m-%dT%H:%M:%SZ', gmtime ),
226
        'metadata'     => {
227
            'project_root' => $project_root,
228
            'config_file'  => $config_path,
229
        },
230
        'direct_dependencies'   => $direct_deps,
231
        'resolved_dependencies' => $resolved_data,
232
    };
233
234
    if ($opt_outdated) {
235
        my ( $outdated, $summary ) =
236
            $strategy->run_outdated_check( get_tool_config(), get_direct_deps(), get_verbose() );
237
        $results->{'outdated'}         = $outdated;
238
        $results->{'outdated_summary'} = $summary;
239
    }
240
241
    if ($opt_audit) {
242
        my ( $vulns, $summary ) = $strategy->run_audit_check( get_tool_config(), get_direct_deps(), get_verbose() );
243
        $results->{'vulnerabilities'}       = $vulns;
244
        $results->{'vulnerability_summary'} = $summary;
245
    }
246
247
    if ($opt_sbom) {
248
        my $sbom_config = get_config()->{'sbom'} // {};
249
        generate_sbom_output( $results, $sbom_config );
250
    }
251
252
    if ($opt_bugzilla) {
253
        my $bugzilla_config = get_config()->{'bugzilla'} // {};
254
        generate_bugzilla_report( $results, $bugzilla_config );
255
    }
256
257
    if ($opt_json_output) {
258
        write_json_results( $opt_json_output, $results, get_verbose() );
259
    }
260
261
    my $thresholds = {};
262
    if ( $config && ref $config eq 'HASH' && $config->{'thresholds'} ) {
263
        $thresholds = $config->{'thresholds'};
264
    }
265
    my $exit_code = determine_exit_code( $results, $thresholds );
266
    log_message( 'info', $opt_verbose, "Exit code: $exit_code" );
267
    return $exit_code;
268
}
269
270
exit main();
271
272
sub generate_sbom_output {
273
    my ( $results, $sbom_cfg ) = @_;
274
275
    my $output_file = $sbom_cfg->{'output_file'}
276
        || 'node_modules_sbom.xml';
277
    my $koha_version = detect_koha_version();
278
    my ( $template_name, $data ) =
279
        build_sbom_data( $results, $sbom_cfg, get_package_json(), $VERSION, $koha_version );
280
281
    process_template( $template_name, $data, $output_file );
282
283
    log_message( 'info', get_verbose(), "SBOM generated at $output_file" );
284
    return;
285
}
286
287
sub generate_bugzilla_report {
288
    my ( $results, $bugzilla_cfg ) = @_;
289
290
    my $output_file = $bugzilla_cfg->{'output_file'}
291
        || 'dependency_bugzilla_report.md';
292
    my ( $template_name, $data ) = build_bugzilla_report_data( $results, $bugzilla_cfg );
293
294
    process_template( $template_name, $data, $output_file );
295
296
    log_message( 'info', get_verbose(), "Bugzilla report generated at $output_file" );
297
298
    return;
299
}
300
301
sub process_template {
302
    my ( $template_name, $vars, $output_file ) = @_;
303
304
    my $template_dir = get_template_dir();
305
    my $include_path = [ grep { defined && length } ($template_dir) ];
306
307
    my $cache_key = join q{|}, @{$include_path} ? @{$include_path} : ('__none__');
308
    state $template_cache;
309
310
    my $tt = $template_cache->{$cache_key};
311
    if ( !$tt ) {
312
        my $config = {
313
            ABSOLUTE => 1,
314
            ENCODING => 'utf8',
315
        };
316
        if ( @{$include_path} ) {
317
            $config->{INCLUDE_PATH} = $include_path;
318
        }
319
320
        $tt = Template->new($config)
321
            or croak Template->error;
322
        $template_cache->{$cache_key} = $tt;
323
    }
324
325
    my $options = { binmode => ':utf8' };
326
    $tt->process( $template_name, $vars, $output_file, $options )
327
        or croak $tt->error;
328
329
    return;
330
}
331
332
__END__
333
334
=head1 NAME
335
336
node_audit_dependencies.pl - Node.js dependency management and security audit tool
337
338
=head1 SYNOPSIS
339
340
    node_audit_dependencies.pl [options]
341
342
=head1 DESCRIPTION
343
344
This script provides comprehensive Node.js dependency management including
345
outdated package detection, security vulnerability auditing, and SBOM/markdown
346
report generation. It supports both npm and yarn package managers using a
347
strategy pattern implementation.
348
349
=head1 OPTIONS
350
351
=over 4
352
353
=item B<--outdated>
354
355
Run the outdated dependency check.
356
357
=item B<--audit>
358
359
Run the security audit check.
360
361
=item B<--sbom>
362
363
Generate a CycloneDX SBOM based on package.json.
364
365
=item B<--bugzilla>
366
367
Generate a Bugzilla-ready markdown summary.
368
369
=item B<--config-file FILE>
370
371
Use an alternate configuration file.
372
373
=item B<--json FILE>
374
375
Write combined JSON results to FILE.
376
377
=item B<--verbose>
378
379
Emit debug information.
380
381
=item B<--help>
382
383
Print this help message.
384
385
=back
386
387
=head1 OUTPUT
388
389
The tool can emit both human-readable and machine-readable artefacts.
390
391
=over 4
392
393
=item * C<--json FILE> writes a consolidated JSON report (the CI wrapper defaults to C<dependency_check_results.json>).
394
395
=item * C<--bugzilla> renders a markdown summary (default C<dependency_bugzilla_report.md>).
396
397
=item * C<--sbom> produces a CycloneDX-style XML software bill of materials (default C<node_modules_sbom.xml>).
398
399
=back
400
401
=head2 JSON report structure
402
403
The JSON document contains the following top-level keys:
404
405
=over 4
406
407
=item * C<generated_at>, C<tool>, C<metadata> - run metadata including the config file used.
408
409
=item * C<direct_dependencies> - direct dependencies from F<package.json> including their type and version spec.
410
411
=item * C<resolved_dependencies> - the resolved dependency graph as reported by the package manager.
412
413
=item * C<outdated>, C<outdated_summary> - packages with updates available, grouped by update type.
414
415
=item * C<vulnerabilities>, C<vulnerability_summary> - security advisories grouped by severity.
416
417
=back
418
419
Common C<jq> snippets when reviewing C<dependency_check_results.json>:
420
421
=over 4
422
423
=item * C<jq '.vulnerability_summary' dependency_check_results.json> - quick counts per severity.
424
425
=item * C<jq '.vulnerabilities[] | {package, severity, fixed_in}' dependency_check_results.json> - list actionable upgrade targets.
426
427
=item * C<jq '.outdated_summary' dependency_check_results.json> - totals per update channel.
428
429
=back
430
431
=head2 Exit codes
432
433
The script returns zero when no configured thresholds are exceeded. Non-zero exit
434
codes occur when vulnerability or outdated thresholds fail, or when compromise
435
checks (handled by L<misc/devel/node_audit_compromise.pl>) detect matches.
436
437
=head1 FUNCTIONS
438
439
=head2 create_package_manager_strategy($tool)
440
441
Creates and returns appropriate package manager strategy object.
442
443
=over 4
444
445
=item * $tool - String indicating package manager ('yarn' or 'npm')
446
447
=back
448
449
Returns: Strategy object (Koha::Devel::Node::Package::Manager::Yarn or Koha::Devel::Node::Package::Manager::Npm)
450
451
=head2 Memoization Functions
452
453
The following functions implement memoization using Perl's C<state> keyword
454
to eliminate argument drilling throughout the application:
455
456
=head3 get_context()
457
458
Returns the memoized context hashref.
459
460
=head3 set_context($key, $value)
461
462
Sets a value in the memoized context.
463
464
=head3 initialize_context($args)
465
466
Initializes the memoized context with common parameters.
467
468
=over 4
469
470
=item * $args - Hashref containing initial context values
471
472
=back
473
474
=head3 get_verbose()
475
476
Returns verbose flag from context.
477
478
=head3 get_config()
479
480
Returns configuration hash from context.
481
482
=head3 get_project_root()
483
484
Returns project root path from context.
485
486
=head3 get_tool()
487
488
Returns package manager name from context.
489
490
=head3 get_tool_config()
491
492
Returns tool-specific configuration from context.
493
494
=head3 get_direct_deps()
495
496
Returns direct dependencies hash from context.
497
498
=head3 get_resolved_data()
499
500
Returns resolved dependency data from context.
501
502
=head3 get_package_json()
503
504
Returns package.json data from context.
505
506
=head2 main()
507
508
Main entry point for the script. Handles command line options,
509
initializes context, and orchestrates dependency checking operations.
510
511
=head2 generate_sbom_output($results, $sbom_config)
512
513
Generates CycloneDX SBOM XML file from dependency data.
514
515
=head2 sbom_component($name, $version, $scope)
516
517
Creates SBOM component hash for a package.
518
519
=head2 generate_bugzilla_report($results, $bugzilla_config)
520
521
Generates Bugzilla-ready markdown report from dependency data.
522
523
=head1 INTERNAL ARCHITECTURE
524
525
This script uses a memoized context pattern with Perl's C<state> keyword
526
to avoid argument drilling. Common parameters (verbose, config, etc.) are
527
stored in a persistent context and accessed through getter functions.
528
529
=head1 AUTHOR
530
531
Koha Development Team
532
533
=head1 COPYRIGHT
534
535
Copyright 2025 Koha
536
537
=cut
(-)a/misc/devel/node_ci_audit.pl (-1 / +262 lines)
Line 0 Link Here
0
- 
1
#!/usr/bin/perl
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 Modern::Perl;
21
22
use Carp                  qw(croak);
23
use Cwd                   qw(abs_path);
24
use English               qw(-no_match_vars);
25
use File::Basename        qw(dirname);
26
use File::Spec::Functions qw(catdir catfile updir);
27
use FindBin               qw($Bin);
28
my $script_dir   = abs_path($Bin);
29
my $project_root = abs_path( catdir( $script_dir, updir(), updir() ) );
30
31
use Koha::Devel::Node::Utils qw(
32
    load_config
33
    change_to_project_root
34
    detect_package_manager
35
    run_command
36
);
37
38
sub main {
39
    my $check_script = catfile( $project_root, 'misc', 'devel', 'node_audit_dependencies.pl' );
40
    my $config_file  = catfile( $project_root, 'misc', 'devel', 'node_audit_config.yml' );
41
42
    if ( !-f $check_script ) {
43
        croak "node_audit_dependencies.pl not found at $check_script\n";
44
    }
45
    if ( !-f $config_file ) {
46
        croak "Configuration file not found at $config_file\n";
47
    }
48
49
    my $config = load_config($config_file);
50
51
    my $tool = detect_package_manager();
52
53
    say 'Koha Node.js dependency check'   or croak;
54
    say "Detected package manager: $tool" or croak;
55
56
    my $tool_conf   = $config->{'tools'}{$tool} || {};
57
    my $install_cmd = $tool_conf->{'install'}   || q{};
58
    my $config_path = $config_file;    # used later
59
60
    if ( !$install_cmd ) {
61
        croak "Install command not configured for $tool\n";
62
    }
63
64
    say "Install command: $install_cmd" or croak;
65
66
    change_to_project_root($project_root);
67
68
    say 'Installing dependencies with frozen lockfile...' or croak;
69
    my @cmd_parts = split /\s+/smx, $install_cmd;
70
    my @args      = @cmd_parts[ 1 .. $#cmd_parts ];
71
    my ( $output, $exit_code ) = run_command( $cmd_parts[0], \@args );
72
    if ( $exit_code != 0 ) {
73
        croak 'Installing dependencies failed';
74
    }
75
76
    my $env = {%ENV};
77
78
    my ( $cmd_ref, $stdin_path, $stdin_label ) = build_dependency_command(
79
        {
80
            'config_file'  => $config_path,
81
            'check_script' => $check_script,
82
            'env'          => $env,
83
        }
84
    );
85
86
    my $system_exit_code;
87
    if ($stdin_path) {
88
        open my $stdin_fh, '<', $stdin_path
89
            or croak "Unable to open stdin file $stdin_path: $OS_ERROR";
90
        {
91
            local *STDIN = $stdin_fh;
92
            $system_exit_code = system { $cmd_ref->[0] } @{$cmd_ref};
93
        }
94
        close $stdin_fh;
95
    } else {
96
        $system_exit_code = system { $cmd_ref->[0] } @{$cmd_ref};
97
    }
98
99
    my $rc = $system_exit_code >> 8;
100
101
    say "Dependency check completed with exit code: $rc" or croak;
102
103
    if ( defined $stdin_label && length $stdin_label ) {
104
        say "  - Compromise check input: $stdin_label" or croak;
105
    }
106
107
    if ( $stdin_path && ( $env->{NODE_DEP_CHECK_INPUT_FILE} // q{} ) eq q{} ) {
108
        unlink $stdin_path;
109
    }
110
111
    return $rc;
112
}
113
114
# Call main function and exit with its return code
115
exit main();
116
117
sub build_dependency_command {
118
    my ($args) = @_;
119
120
    my $config_file  = $args->{config_file};
121
    my $check_script = $args->{check_script};
122
    my $env          = $args->{env} || {};
123
124
    my $modes_raw =
125
        exists $env->{NODE_DEP_CHECK_MODES} && length $env->{NODE_DEP_CHECK_MODES}
126
        ? $env->{NODE_DEP_CHECK_MODES}
127
        : 'outdated,audit,sbom,bugzilla';
128
129
    my $mode_tokens = [ grep { length } map { lc s/\s+//smxgr } split /,/smx, $modes_raw ];
130
131
    my $mode_flags = {
132
        outdated         => '--outdated',
133
        audit            => '--audit',
134
        sbom             => '--sbom',
135
        bugzilla         => '--bugzilla',
136
        compromise       => '--check-packages',
137
        'check-packages' => '--check-packages',
138
    };
139
140
    my $selected;
141
    for my $token ( @{$mode_tokens} ) {
142
        next if !exists $mode_flags->{$token};
143
        $selected->{ $mode_flags->{$token} } = 1;
144
    }
145
146
    if ( !%{$selected} ) {
147
        $mode_tokens = [qw(outdated audit sbom bugzilla)];
148
        $selected    = { map { $mode_flags->{$_} => 1 } @{$mode_tokens} };
149
    }
150
151
    my $compromise = delete $selected->{'--check-packages'};
152
    if ($compromise) {
153
        if ( %{$selected} ) {
154
            croak 'Compromise mode cannot be combined with other dependency-check modes';
155
        }
156
157
        my $comp_script = catfile( $project_root, 'misc', 'devel', 'node_audit_compromise.pl' );
158
        my $cmd         = [ 'perl', $comp_script, '--config-file', $config_file ];
159
160
        if ( !exists $env->{NODE_DEP_CHECK_VERBOSE} || $env->{NODE_DEP_CHECK_VERBOSE} ne '0' ) {
161
            push @{$cmd}, '--verbose';
162
        }
163
164
        my $json_output = $env->{NODE_DEP_CHECK_JSON} // catfile( $project_root, 'dependency_check_results.json' );
165
        if ( length $json_output ) {
166
            push @{$cmd}, '--json', $json_output;
167
        }
168
169
        my $stdin_label;
170
        if ( my $input_file = $env->{NODE_DEP_CHECK_INPUT_FILE} ) {
171
            if ( !-e $input_file ) {
172
                croak "Compromise input file '$input_file' not found\n";
173
            }
174
            push @{$cmd}, '--input-file', $input_file;
175
            $stdin_label = $input_file;
176
        } elsif ( defined $env->{NODE_DEP_CHECK_INPUT} && length $env->{NODE_DEP_CHECK_INPUT} ) {
177
            push @{$cmd}, '--inline', $env->{NODE_DEP_CHECK_INPUT};
178
            $stdin_label = 'NODE_DEP_CHECK_INPUT';
179
        } else {
180
            croak "Compromise mode requested but NODE_DEP_CHECK_INPUT_FILE or NODE_DEP_CHECK_INPUT not provided\n";
181
        }
182
183
        return ( $cmd, undef, $stdin_label );
184
    }
185
186
    my $cmd = [ 'perl', $check_script, '--config-file', $config_file ];
187
188
    if ( !exists $env->{NODE_DEP_CHECK_VERBOSE} || $env->{NODE_DEP_CHECK_VERBOSE} ne '0' ) {
189
        push @{$cmd}, '--verbose';
190
    }
191
192
    my $json_output = $env->{NODE_DEP_CHECK_JSON} // catfile( $project_root, 'dependency_check_results.json' );
193
    if ( length $json_output ) {
194
        push @{$cmd}, '--json', $json_output;
195
    }
196
197
    push @{$cmd}, sort keys %{$selected};
198
199
    return ( $cmd, undef, undef );
200
}
201
202
__END__
203
204
=head1 NAME
205
206
node_ci_audit.pl - CI-oriented wrapper for node_audit_dependencies.pl
207
208
=head1 DESCRIPTION
209
210
Installs Node.js dependencies using the configured package manager and then
211
invokes L<misc/devel/node_audit_dependencies.pl> (or the compromise helper) with
212
the appropriate flags for continuous integration jobs.
213
214
=head1 ENVIRONMENT
215
216
The following environment variables alter the behaviour of the script:
217
218
=over 4
219
220
=item * C<NODE_DEP_CHECK_MODES> - comma-separated list of modes to run. Defaults
221
to C<outdated,audit,sbom,bugzilla>. Include C<compromise> to run
222
C<node_audit_compromise.pl> instead.
223
224
=item * C<NODE_DEP_CHECK_JSON> - path for the combined JSON output
225
(defaults to F<dependency_check_results.json> in the project root).
226
227
=item * C<NODE_DEP_CHECK_VERBOSE> - set to C<0> to disable the C<--verbose> flag.
228
229
=item * C<NODE_DEP_CHECK_INPUT_FILE>, C<NODE_DEP_CHECK_INPUT> - sources for
230
compromise queries when C<compromise> mode is selected.
231
232
=back
233
234
=head1 OUTPUT
235
236
When the JSON output is enabled, refer to the structure documented in
237
L<misc/devel/node_audit_dependencies.pl>. Typical CI checks include:
238
239
=over 4
240
241
=item * C<jq '.vulnerability_summary' dependency_check_results.json> - severity
242
counts for audit findings.
243
244
=item * C<jq '.outdated_summary' dependency_check_results.json> - upgrade counts
245
per update cadence.
246
247
=back
248
249
=head1 EXIT STATUS
250
251
The wrapper returns the exit code from the underlying audit script so that CI
252
systems can fail the job when thresholds are exceeded.
253
254
=head1 AUTHOR
255
256
Koha Development Team
257
258
=head1 COPYRIGHT
259
260
Copyright 2025 Koha
261
262
=cut

Return to bug 40778