-
Notifications
You must be signed in to change notification settings - Fork 267
1093 lines (981 loc) · 49.3 KB
/
Copy pathdependency-cursor-review.yml
File metadata and controls
1093 lines (981 loc) · 49.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Managed by repo-content-updater
# Dependency Cursor Review workflow
#
# Runs Cursor CLI analysis for Dependabot/Renovate PRs by using:
# - Dependabot PR body release notes + commit list
# - An upstream dependency checkout
# - Local usage hints in the target repo
#
# Source documentation: https://cursor.com/docs/cli/github-actions
name: Dependency Cursor Review
on:
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
inputs:
pr_number:
description: "Dependabot/Renovate PR number to analyze"
required: true
type: number
concurrency:
group: ${{ github.event_name == 'workflow_dispatch' && format('{0}-{1}', github.workflow_ref, github.event.inputs.pr_number) || github.event_name == 'pull_request' && format('{0}-{1}', github.workflow_ref, github.event.pull_request.number) || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
dependency-review:
if: github.repository_owner == 'Chia-Network' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && (github.event.pull_request.user.login == 'dependabot[bot]' || github.event.pull_request.user.login == 'renovate[bot]')))
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Resolve target PR context
id: target_pr
uses: actions/github-script@v9
with:
script: |
let pr;
if (context.eventName === 'pull_request') {
pr = context.payload.pull_request;
} else {
const raw = context.payload.inputs?.pr_number;
const prNumber = Number(raw);
if (!Number.isInteger(prNumber) || prNumber <= 0) {
core.setFailed(`Invalid pr_number input: ${raw}`);
return;
}
const { data } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
pr = data;
}
if (!pr) {
core.setFailed('Could not resolve target pull request context.');
return;
}
const allowedBots = ['dependabot[bot]', 'renovate[bot]'];
if (!allowedBots.includes(pr.user?.login)) {
core.setFailed(`Target PR #${pr.number} is not opened by an allowed bot. Author: ${pr.user?.login}`);
return;
}
core.setOutput('number', String(pr.number));
core.setOutput('title', pr.title || '');
core.setOutput('body', pr.body || '');
core.setOutput('head_sha', pr.head?.sha || '');
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ steps.target_pr.outputs.head_sha }}
persist-credentials: false
- name: Install Cursor CLI
shell: bash
run: |
installer="$(mktemp)"
trap 'rm -f "$installer"' EXIT
curl https://cursor.com/install -fsSL -o "$installer"
if [ ! -s "$installer" ]; then
echo "Cursor installer download was empty."
exit 1
fi
bash "$installer"
for bin_dir in "$HOME/.cursor/bin" "$HOME/.local/bin"; do
if [ -d "$bin_dir" ]; then
echo "$bin_dir" >> "$GITHUB_PATH"
export PATH="$bin_dir:$PATH"
fi
done
if ! command -v agent >/dev/null 2>&1; then
echo "Could not locate 'agent' binary after Cursor CLI install."
exit 1
fi
- name: Extract Dependabot comment context
id: dependabot_context
uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
const body = ${{ toJSON(steps.target_pr.outputs.body) }} || '';
function escapeRegex(text) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function extractDetailsSection(text, summaryLabel) {
// Allow optional trailing text after the label (e.g. Renovate appends " (pkgalias)").
const summaryRe = new RegExp(
`<details[^>]*>\\s*<summary>\\s*${escapeRegex(summaryLabel)}[^<]*</summary>`,
'i'
);
const summaryMatch = summaryRe.exec(text);
if (!summaryMatch) return '';
const start = summaryMatch.index;
const tagRe = /<details\b[^>]*>|<\/details>/gi;
tagRe.lastIndex = start;
let depth = 0;
let end = -1;
let tag;
while ((tag = tagRe.exec(text)) !== null) {
if (tag[0].toLowerCase().startsWith('<details')) {
depth += 1;
} else {
depth -= 1;
}
if (depth === 0) {
end = tagRe.lastIndex;
break;
}
}
if (end === -1) return '';
const block = text.slice(start, end);
return block
.replace(summaryRe, '')
.replace(/<\/details>\s*$/i, '')
.trim();
}
// Prefer the dependency link from Dependabot's lead sentence.
// Fallback to any GitHub repo URL if that sentence format changes.
// Both github.com and redirect.github.com (used by Renovate) are matched.
const dependencyRepoMatch = body.match(
/^\s*(?:Bumps|Update(?:s|d)?)\s+\[[^\]]+\]\(\s*https?:\/\/(?:redirect\.)?github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)(?:[\/#?][^)\s]*)?\s*\)/im
);
const repoMatch = dependencyRepoMatch || body.match(
/https?:\/\/(?:redirect\.)?github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)(?=\/|$|[)\]>\s"'?#])/i
);
const upstreamRepo = repoMatch ? repoMatch[1].replace(/\.git$/i, '') : '';
// Release notes: Dependabot uses <details><summary>Release notes</summary>,
// Renovate uses <details><summary>org/repo (pkg)</summary> under a ### Release Notes heading.
let releaseNotes = extractDetailsSection(body, 'Release notes');
if (!releaseNotes && upstreamRepo) {
releaseNotes = extractDetailsSection(body, upstreamRepo);
}
if (!releaseNotes) {
const rnHeading = body.match(/###\s*Release\s+Notes?\s*\n([\s\S]*?)(?=\n---|\n###\s|$)/i);
if (rnHeading) releaseNotes = rnHeading[1].trim();
}
// Commits: Dependabot uses <details><summary>Commits</summary>.
// Renovate omits this section, so fall back to extracting any SHA-like hex strings from the body.
let commits = extractDetailsSection(body, 'Commits');
if (!commits) {
const shaMatches = body.match(/\b[0-9a-f]{7,40}\b/gi);
if (shaMatches && shaMatches.length > 0) commits = shaMatches.join('\n');
}
const title = ${{ toJSON(steps.target_pr.outputs.title) }} || '';
// Dependabot-style titles (3 groups: pkg, fromVersion, toVersion).
const titleMatch =
title.match(/[Uu]pdate\s+(.+?)\s+requirement\s+from\s+([^\s]+)\s+to\s+([^\s]+)/) ||
title.match(/[Bb]ump\s+(.+?)\s+from\s+([^\s]+)\s+to\s+([^\s]+)/) ||
title.match(/[Uu]pdate\s+(.+?)\s+from\s+([^\s]+)\s+to\s+([^\s]+)/);
// Renovate-style titles (2 groups: pkg, toVersion — no "from" clause).
const renovateTitleMatch = !titleMatch && (
title.match(/(?:update|pin)\s+dependency\s+(.+?)\s+to\s+v?([^\s]+)/i) ||
title.match(/(?:update|pin)\s+(.+?)\s+(?:action|digest|docker\s+tag)\s+to\s+v?([^\s]+)/i) ||
title.match(/(?:update|pin)\s+(.+?)\s+to\s+v?([^\s]+)/i)
);
let packageName, fromVersion, toVersion;
if (titleMatch) {
packageName = titleMatch[1].trim();
fromVersion = titleMatch[2].trim();
toVersion = titleMatch[3].trim();
} else if (renovateTitleMatch) {
packageName = renovateTitleMatch[1].trim();
fromVersion = '';
toVersion = renovateTitleMatch[2].trim();
} else {
packageName = '';
fromVersion = '';
toVersion = '';
}
// Body-based version fallback: recover missing from/to from Renovate's table (e.g. `1.2.3` -> `1.2.4`).
if (!fromVersion || !toVersion) {
const bodyVersionMatch = body.match(/`([^`\s]+)`\s*(?:->|→)\s*`([^`\s]+)`/);
if (bodyVersionMatch) {
if (!fromVersion) fromVersion = bodyVersionMatch[1].replace(/^v/i, '');
if (!toVersion) toVersion = bodyVersionMatch[2].replace(/^v/i, '');
}
}
if (!upstreamRepo) {
core.setFailed('PR body is missing an upstream GitHub repository link.');
return;
}
const normalizedReleaseNotes =
releaseNotes || 'PR body did not include a Release notes details section.';
const normalizedCommits =
commits || 'PR body did not include a Commits details section.';
const out = {
prNumber: Number('${{ steps.target_pr.outputs.number }}'),
upstreamRepo,
packageName,
fromVersion,
toVersion,
releaseNotes: normalizedReleaseNotes,
commits: normalizedCommits,
};
fs.writeFileSync('dependabot_comment_context.json', JSON.stringify(out, null, 2));
fs.writeFileSync('dependabot_release_notes.md', normalizedReleaseNotes);
fs.writeFileSync('dependabot_commits.md', normalizedCommits);
core.setOutput('upstream_repo', upstreamRepo);
core.setOutput('package_name', packageName);
core.setOutput('from_version', fromVersion);
core.setOutput('to_version', toVersion);
- name: Prepare upstream checkout directory
run: mkdir -p ".upstream-dependency"
- name: Checkout upstream repository
uses: actions/checkout@v7
with:
repository: ${{ steps.dependabot_context.outputs.upstream_repo }}
path: .upstream-dependency
fetch-depth: 0
persist-credentials: false
- name: Remove upstream agent-instruction files
shell: bash
run: |
rm -rf .upstream-dependency/.cursor || true
rm -f .upstream-dependency/.cursorrules || true
rm -f .upstream-dependency/.cursorignore || true
rm -f .upstream-dependency/AGENTS.md || true
rm -f .upstream-dependency/CLAUDE.md || true
- name: Run upstream malware scan
id: malware_scan
shell: bash
env:
PACKAGE_NAME: ${{ steps.dependabot_context.outputs.package_name }}
FROM_VERSION: ${{ steps.dependabot_context.outputs.from_version }}
TO_VERSION: ${{ steps.dependabot_context.outputs.to_version }}
MALWARE_WARN_ONLY: >-
1
MALWARE_IOC_PATTERNS: >-
["axios@1\\.14\\.1", "axios@0\\.30\\.4", "plain-crypto-js", "sfrclak\\.com", "@shadanai/openclaw", "@shadanai/[a-z0-9._-]+", "2026\\.3\\.28-2", "2026\\.3\\.28-3", "2026\\.3\\.31-1", "2026\\.3\\.31-2"]
MALWARE_IOC_ALLOWLIST: >-
[]
MALWARE_UNICODE_ALLOWLIST: >-
[]
MALWARE_CONFUSABLE_ALLOWLIST: >-
[]
MALWARE_HEURISTIC_ALLOWLIST: >-
[]
run: |
sudo apt-get update
sudo apt-get install -y ripgrep jq
set -euo pipefail
output_json="malware_scan_report.json"
output_summary="malware_scan_summary.md"
changed_files_txt="upstream_changed_files.txt"
work_dir=".malware-scan"
rm -rf "$work_dir"
mkdir -p "$work_dir"
errors_file="$work_dir/errors.txt"
touched_file="$work_dir/changed_files_raw.txt"
file_list0="$work_dir/changed_files.nul"
unicode_jsonl="$work_dir/unicode.jsonl"
confusable_jsonl="$work_dir/confusable.jsonl"
ioc_jsonl="$work_dir/ioc.jsonl"
heuristic_jsonl="$work_dir/heuristic.jsonl"
: > "$errors_file"
: > "$touched_file"
: > "$file_list0"
: > "$unicode_jsonl"
: > "$confusable_jsonl"
: > "$ioc_jsonl"
: > "$heuristic_jsonl"
if ! jq -e 'type == "array"' >/dev/null 2>&1 <<<"${MALWARE_IOC_PATTERNS:-[]}"; then MALWARE_IOC_PATTERNS="[]"; fi
if ! jq -e 'type == "array"' >/dev/null 2>&1 <<<"${MALWARE_IOC_ALLOWLIST:-[]}"; then MALWARE_IOC_ALLOWLIST="[]"; fi
if ! jq -e 'type == "array"' >/dev/null 2>&1 <<<"${MALWARE_UNICODE_ALLOWLIST:-[]}"; then MALWARE_UNICODE_ALLOWLIST="[]"; fi
if ! jq -e 'type == "array"' >/dev/null 2>&1 <<<"${MALWARE_CONFUSABLE_ALLOWLIST:-[]}"; then MALWARE_CONFUSABLE_ALLOWLIST="[]"; fi
if ! jq -e 'type == "array"' >/dev/null 2>&1 <<<"${MALWARE_HEURISTIC_ALLOWLIST:-[]}"; then MALWARE_HEURISTIC_ALLOWLIST="[]"; fi
mapfile -t ioc_allowlist < <(jq -r '.[]?' <<<"$MALWARE_IOC_ALLOWLIST")
mapfile -t unicode_allowlist < <(jq -r '.[]?' <<<"$MALWARE_UNICODE_ALLOWLIST")
mapfile -t confusable_allowlist < <(jq -r '.[]?' <<<"$MALWARE_CONFUSABLE_ALLOWLIST")
mapfile -t heuristic_allowlist < <(jq -r '.[]?' <<<"$MALWARE_HEURISTIC_ALLOWLIST")
mapfile -t ioc_patterns < <(jq -r '.[]?' <<<"$MALWARE_IOC_PATTERNS")
warn_only=true
case "${MALWARE_WARN_ONLY:-1}" in
0|false|False|no) warn_only=false ;;
esac
resolve_ref() {
local version="$1"
local candidate
[ -n "$version" ] || return 1
for candidate in \
"refs/tags/${version}^{commit}" \
"refs/tags/v${version}^{commit}" \
"refs/tags/${version}" \
"refs/tags/v${version}" \
"${version}" \
"v${version}"
do
if git -C .upstream-dependency rev-parse --verify --quiet "$candidate" >/dev/null 2>&1; then
git -C .upstream-dependency rev-parse --verify --quiet "$candidate" | head -n1
return 0
fi
done
return 1
}
from_ref=""
to_ref=""
resolved_range=""
resolution_strategy="unresolved"
from_ref="$(resolve_ref "${FROM_VERSION:-}" || true)"
to_ref="$(resolve_ref "${TO_VERSION:-}" || true)"
if [ -n "$from_ref" ] && [ -n "$to_ref" ]; then
resolved_range="${from_ref}..${to_ref}"
if ! git -C .upstream-dependency diff --name-only "$resolved_range" > "$touched_file" 2>>"$errors_file"; then
resolution_strategy="tag_range_failed"
resolved_range=""
: > "$touched_file"
else
resolution_strategy="tag_range"
fi
fi
if [ ! -s "$touched_file" ] && [ -f "dependabot_commits.md" ]; then
while IFS= read -r sha; do
resolved_sha="$(git -C .upstream-dependency rev-parse --verify --quiet "${sha}^{commit}" 2>/dev/null || true)"
[ -n "$resolved_sha" ] || continue
git -C .upstream-dependency show --name-only --pretty=format: "$resolved_sha" >> "$touched_file" 2>>"$errors_file" || true
done < <(rg -o --pcre2 '\b[0-9a-f]{7,40}\b' dependabot_commits.md | sort -u)
if [ -s "$touched_file" ]; then
resolution_strategy="commit_list"
fi
fi
if [ ! -s "$touched_file" ] && [ -n "$to_ref" ]; then
resolution_strategy="to_version_single_commit"
git -C .upstream-dependency show --name-only --pretty=format: "$to_ref" > "$touched_file" 2>>"$errors_file" || true
fi
repo_abs="$(cd .upstream-dependency && pwd -P)"
while IFS= read -r rel; do
[ -n "$rel" ] || continue
abs_path="$(readlink -f ".upstream-dependency/$rel" 2>/dev/null || true)"
if [ -n "$abs_path" ] && [ -f "$abs_path" ] && [[ "$abs_path" == "$repo_abs/"* ]]; then
printf '%s\n' "$rel"
fi
done < "$touched_file" | sort -u > "$changed_files_txt"
while IFS= read -r rel; do
[ -n "$rel" ] || continue
printf '%s\0' ".upstream-dependency/$rel" >> "$file_list0"
done < "$changed_files_txt"
is_allowlisted() {
local entry="$1"
shift
local pattern
for pattern in "$@"; do
[ -n "$pattern" ] || continue
if [[ "$entry" =~ $pattern ]]; then
return 0
fi
done
return 1
}
append_finding() {
local out_file="$1"
local kind="$2"
local pattern="$3"
local rel="$4"
local line="$5"
local match="$6"
local line_num=0
local max_match_len=500
if [[ "$line" =~ ^[0-9]+$ ]]; then
line_num="$line"
fi
if [ "${#match}" -gt "$max_match_len" ]; then
match="${match:0:$max_match_len}...[truncated]"
fi
jq -nc \
--arg kind "$kind" \
--arg pattern "$pattern" \
--arg file "$rel" \
--arg match "$match" \
--argjson line "$line_num" \
'{kind: $kind, pattern: $pattern, file: $file, line: $line, match: $match}' >> "$out_file"
}
scan_with_rg() {
local kind="$1"
local regex="$2"
local out_file="$3"
local case_flag="$4"
shift 4
local allowlist=("$@")
local hit file rest line text rel entry
if [ ! -s "$file_list0" ]; then
return 0
fi
local rg_args=(--pcre2 --hidden -nH --no-heading --color=never)
if [ "$case_flag" = "i" ]; then
rg_args+=(-i)
fi
rg_args+=("$regex")
while IFS= read -r hit; do
file="${hit%%:*}"
rest="${hit#*:}"
line="${rest%%:*}"
text="${rest#*:}"
rel="${file#.upstream-dependency/}"
entry="${kind}:${rel}:${line}:${text}"
if is_allowlisted "$entry" "${allowlist[@]}"; then
continue
fi
append_finding "$out_file" "$kind" "$regex" "$rel" "$line" "$text"
done < <(xargs -0 rg "${rg_args[@]}" < "$file_list0" || true)
}
# GlassWorm defense: raw-byte unicode/control scan. These characters can be
# visually invisible in rendered diffs, so we scan bytes before LLM review.
unicode_regex='[\x{FE00}-\x{FE0F}\x{E0100}-\x{E01EF}\x{200B}-\x{200D}\x{FEFF}\x{3164}\x{115F}\x{1160}\x{202A}-\x{202E}\x{2066}-\x{2069}]'
# Confusable glyph scan to catch visually similar operator/identifier swaps.
confusable_regex='[\x{FF0F}\x{2215}\x{2044}\x{2217}\x{066D}\x{01C3}\x{FF01}\x{FE57}\x{A789}\x{FF02}\x{FF07}\x{FF40}\x{FE68}\x{FF3C}]'
scan_with_rg "unicode" "$unicode_regex" "$unicode_jsonl" "" "${unicode_allowlist[@]}"
scan_with_rg "confusable" "$confusable_regex" "$confusable_jsonl" "" "${confusable_allowlist[@]}"
for ioc_pattern in "${ioc_patterns[@]}"; do
[ -n "$ioc_pattern" ] || continue
scan_with_rg "ioc_match" "$ioc_pattern" "$ioc_jsonl" "i" "${ioc_allowlist[@]}"
done
heuristic_specs=(
'eval_function_blank_arg::\b(?:eval|Function)\s*\(\s*([\"'"'"'`])\s*\1\s*\)'
'codepoint_decoder::(?:codePointAt|fromCodePoint|charCodeAt)\s*\('
'dynamic_require_import::\b(?:require|import)\s*\(\s*(?:atob|Buffer\.from|decodeURIComponent|[\"'"'"'`].*https?://)'
'shell_process_spawn::\b(?:child_process|spawn|exec|subprocess|os\.system)\b'
'filesystem_persistence::(?:/etc/(?:init\\.d|rc\\.local|cron\\.)|/Library/Launch(?:Agents|Daemons)|\\.config/autostart|CurrentVersion\\\\Run|crontab)'
'network_c2_indicator::https?://(?:[0-9]{1,3}(?:\.[0-9]{1,3}){3}|[^/\s\"'"'"')]+(?:\.onion|\.top|\.xyz|\.click|\.gq|\.tk|\.ml|\.cf|\.ga|\.work|\.support)|(?:pastebin\.com|raw\.githubusercontent\.com|discord(?:app)?\.com/api/webhooks|api\.telegram\.org|ipfs\.io|gateway\.pinata\.cloud|tinyurl\.com|bit\.ly|sfrclak\.com))'
'credential_exfil_indicator::(?:token|secret|api[_-]?key|authorization|cookie|passwd|password).{0,80}(?:fetch|axios|http|https|curl|wget|post|send)'
'lifecycle_script::"(?:preinstall|install|postinstall)"\s*:'
'obfuscation_indicator::\b(?:atob|btoa|base64|xor|decodeURIComponent)\b'
)
for spec in "${heuristic_specs[@]}"; do
kind="${spec%%::*}"
regex="${spec#*::}"
scan_with_rg "$kind" "$regex" "$heuristic_jsonl" "i" "${heuristic_allowlist[@]}"
done
if [ -s "$changed_files_txt" ]; then
while IFS= read -r rel; do
[ -n "$rel" ] || continue
if [[ "$rel" == .github/workflows/* ]]; then
entry="workflow_path_touch:${rel}:0:path"
if ! is_allowlisted "$entry" "${heuristic_allowlist[@]}"; then
append_finding "$heuristic_jsonl" "workflow_path_touch" '\.github/workflows/' "$rel" "0" "path-touch"
fi
fi
if [[ "$rel" =~ \.(png|jpg|jpeg|gif|bmp|webp|svg|ico|mp3|mp4|mov|avi|wav)$ ]]; then
entry="steganography_media_change:${rel}:0:media-file-changed"
if ! is_allowlisted "$entry" "${heuristic_allowlist[@]}"; then
append_finding "$heuristic_jsonl" "steganography_media_change" 'media-file-change' "$rel" "0" "media-file-changed"
fi
fi
done < "$changed_files_txt"
fi
# Minified/bundled payload heuristic: very long lines outside common build output dirs.
if [ -s "$file_list0" ]; then
while IFS= read -r hit; do
file="${hit%%:*}"
rest="${hit#*:}"
line="${rest%%:*}"
text="${rest#*:}"
rel="${file#.upstream-dependency/}"
if [[ "$rel" =~ ^(dist/|build/|coverage/|vendor/) ]]; then
continue
fi
entry="minified_payload_indicator:${rel}:${line}:${text}"
if ! is_allowlisted "$entry" "${heuristic_allowlist[@]}"; then
append_finding "$heuristic_jsonl" "minified_payload_indicator" '.{1200,}' "$rel" "$line" "$text"
fi
done < <(xargs -0 rg --pcre2 --hidden -nH --no-heading --color=never '.{1200,}' < "$file_list0" || true)
fi
# Dependency integrity and Dependabot-context checks.
node_vendor_count="$( (rg -n '^(node_modules/|vendor/)' "$changed_files_txt" || true) | wc -l | tr -d ' ' )"
lockfile_count="$( (rg -n '(?:^|/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|npm-shrinkwrap\.json|Gemfile\.lock|go\.sum|Cargo\.lock|poetry\.lock|Pipfile\.lock)$' "$changed_files_txt" || true) | wc -l | tr -d ' ' )"
if [ -n "${TO_VERSION:-}" ] && [ -z "$to_ref" ]; then
entry="ghost_version_or_missing_tag:${TO_VERSION}:0:missing-tag"
if ! is_allowlisted "$entry" "${heuristic_allowlist[@]}"; then
append_finding "$heuristic_jsonl" "ghost_version_or_missing_tag" 'missing-tag' "${PACKAGE_NAME:-unknown}" "0" "${TO_VERSION}"
fi
fi
parse_semver_num() {
local version="$1"
version="${version#v}"
version="${version%%[-+]*}"
local major minor patch
IFS='.' read -r major minor patch <<< "$version"
if [[ "$major" =~ ^[0-9]+$ ]] && [[ "${minor:-0}" =~ ^[0-9]+$ ]] && [[ "${patch:-0}" =~ ^[0-9]+$ ]]; then
echo "$((10#$major)) $((10#${minor:-0})) $((10#${patch:-0}))"
fi
}
from_semver="$(parse_semver_num "${FROM_VERSION:-}" || true)"
to_semver="$(parse_semver_num "${TO_VERSION:-}" || true)"
if [ -n "$from_semver" ] && [ -n "$to_semver" ]; then
read -r fmaj fmin _ <<< "$from_semver"
read -r tmaj tmin _ <<< "$to_semver"
if [ "$tmaj" -gt "$fmaj" ] || { [ "$tmaj" -eq "$fmaj" ] && [ "$((tmin - fmin))" -gt 5 ]; }; then
entry="version_jump_anomaly:${PACKAGE_NAME:-unknown}:0:${FROM_VERSION:-}->${TO_VERSION:-}"
if ! is_allowlisted "$entry" "${heuristic_allowlist[@]}"; then
append_finding "$heuristic_jsonl" "version_jump_anomaly" 'semver-jump' "${PACKAGE_NAME:-unknown}" "0" "${FROM_VERSION:-}->${TO_VERSION:-}"
fi
fi
fi
if [ -n "$from_ref" ] && [ -n "$to_ref" ]; then
from_dep_count="$(git -C .upstream-dependency show "${from_ref}:package.json" 2>/dev/null | jq -r '((.dependencies // {})|length)+((.optionalDependencies // {})|length)+((.peerDependencies // {})|length)' 2>/dev/null || true)"
to_dep_count="$(git -C .upstream-dependency show "${to_ref}:package.json" 2>/dev/null | jq -r '((.dependencies // {})|length)+((.optionalDependencies // {})|length)+((.peerDependencies // {})|length)' 2>/dev/null || true)"
if [[ "$from_dep_count" =~ ^[0-9]+$ ]] && [[ "$to_dep_count" =~ ^[0-9]+$ ]]; then
if [ "$to_dep_count" -gt "$((from_dep_count + 8))" ]; then
entry="dependency_count_jump:${PACKAGE_NAME:-unknown}:0:${from_dep_count}->${to_dep_count}"
if ! is_allowlisted "$entry" "${heuristic_allowlist[@]}"; then
append_finding "$heuristic_jsonl" "dependency_count_jump" 'dependency-count' "${PACKAGE_NAME:-unknown}" "0" "${from_dep_count}->${to_dep_count}"
fi
fi
if [ "${PACKAGE_NAME:-}" = "axios" ] && [ "$from_dep_count" -gt 0 ] && [ "$to_dep_count" -gt "$((from_dep_count + 2))" ]; then
entry="axios_dependency_count_anomaly:${PACKAGE_NAME:-unknown}:0:${from_dep_count}->${to_dep_count}"
if ! is_allowlisted "$entry" "${heuristic_allowlist[@]}"; then
append_finding "$heuristic_jsonl" "axios_dependency_count_anomaly" 'axios-dependency-jump' "${PACKAGE_NAME:-unknown}" "0" "${from_dep_count}->${to_dep_count}"
fi
fi
fi
fi
if [ -n "$resolved_range" ]; then
while IFS= read -r rel; do
[ -n "$rel" ] || continue
added_count="0"
if [[ "$rel" =~ (package-lock\.json|npm-shrinkwrap\.json)$ ]]; then
added_count="$( (git -C .upstream-dependency diff -U0 "$resolved_range" -- "$rel" | rg -n '^\+[^+].*"node_modules/[^"]+"\s*:' || true) | wc -l | tr -d ' ' )"
elif [[ "$rel" =~ yarn\.lock$ ]]; then
added_count="$( (git -C .upstream-dependency diff -U0 "$resolved_range" -- "$rel" | rg -n '^\+[^+].+@[^:]+:' || true) | wc -l | tr -d ' ' )"
elif [[ "$rel" =~ (pnpm-lock\.yaml|go\.sum|Cargo\.lock|Gemfile\.lock|poetry\.lock|Pipfile\.lock)$ ]]; then
added_count="$( (git -C .upstream-dependency diff -U0 "$resolved_range" -- "$rel" | rg -n '^\+[^+]' || true) | wc -l | tr -d ' ' )"
fi
if [ "${added_count:-0}" -gt 0 ]; then
entry="transitive_dependencies_added:${rel}:0:${added_count}"
if ! is_allowlisted "$entry" "${heuristic_allowlist[@]}"; then
append_finding "$heuristic_jsonl" "transitive_dependencies_added" 'transitive-diff' "$rel" "0" "$added_count"
fi
fi
done < "$changed_files_txt"
fi
# Lock checksum/integrity anomaly checks (structure-level, not registry verification).
if [ -s "$file_list0" ]; then
while IFS= read -r hit; do
file="${hit%%:*}"
rest="${hit#*:}"
line="${rest%%:*}"
text="${rest#*:}"
rel="${file#.upstream-dependency/}"
entry="lock_hash_anomaly:${rel}:${line}:${text}"
if ! is_allowlisted "$entry" "${heuristic_allowlist[@]}"; then
append_finding "$heuristic_jsonl" "lock_hash_anomaly" 'integrity-format' "$rel" "$line" "$text"
fi
done < <(xargs -0 rg --pcre2 --hidden -nH --no-heading --color=never '"integrity"\s*:\s*"(?!sha(?:1|256|384|512)-)[^"]+"' < "$file_list0" || true)
fi
# Typosquatting indicators from changed dependency metadata.
if [ -s "$file_list0" ]; then
while IFS= read -r hit; do
file="${hit%%:*}"
rest="${hit#*:}"
line="${rest%%:*}"
text="${rest#*:}"
rel="${file#.upstream-dependency/}"
entry="typosquatting_indicator:${rel}:${line}:${text}"
if ! is_allowlisted "$entry" "${heuristic_allowlist[@]}"; then
append_finding "$heuristic_jsonl" "typosquatting_indicator" '(xn--|[-._]{2,}|[^[:ascii:]])' "$rel" "$line" "$text"
fi
done < <(xargs -0 rg --pcre2 --hidden -nH --no-heading --color=never '(?i)(xn--[a-z0-9-]+|@[a-z0-9._-]*[._-]{2,}[a-z0-9._-]*|\"[^"]*[^[:ascii:]][^"]*\"\s*:)' < "$file_list0" || true)
fi
# Maintainer drift check for npm packages when npm is available.
if command -v npm >/dev/null 2>&1 && [ -n "${PACKAGE_NAME:-}" ] && [ -n "${FROM_VERSION:-}" ] && [ -n "${TO_VERSION:-}" ]; then
from_maint="$(npm view "${PACKAGE_NAME}@${FROM_VERSION}" maintainers --json 2>/dev/null | jq -cS '. // []' 2>/dev/null || true)"
to_maint="$(npm view "${PACKAGE_NAME}@${TO_VERSION}" maintainers --json 2>/dev/null | jq -cS '. // []' 2>/dev/null || true)"
if [ -n "$from_maint" ] && [ -n "$to_maint" ] && [ "$from_maint" != "$to_maint" ]; then
entry="maintainer_drift:${PACKAGE_NAME}:0:${FROM_VERSION}->${TO_VERSION}"
if ! is_allowlisted "$entry" "${heuristic_allowlist[@]}"; then
append_finding "$heuristic_jsonl" "maintainer_drift" 'npm-maintainers' "${PACKAGE_NAME}" "0" "${FROM_VERSION}->${TO_VERSION}"
fi
fi
fi
unicode_json="$work_dir/unicode.json"
confusable_json="$work_dir/confusable.json"
ioc_json="$work_dir/ioc.json"
heuristic_json="$work_dir/heuristic.json"
jq -s '.' "$unicode_jsonl" > "$unicode_json"
jq -s '.' "$confusable_jsonl" > "$confusable_json"
jq -s '.' "$ioc_jsonl" > "$ioc_json"
jq -s '.' "$heuristic_jsonl" > "$heuristic_json"
unicode_count="$(jq 'length' "$unicode_json")"
confusable_count="$(jq 'length' "$confusable_json")"
ioc_count="$(jq 'length' "$ioc_json")"
heuristic_count="$(jq 'length' "$heuristic_json")"
total_count=$((unicode_count + confusable_count + ioc_count + heuristic_count))
changed_count="$(jq -Rs 'split("\n") | map(select(length > 0)) | length' "$changed_files_txt")"
status="clean"
if [ "$total_count" -gt 0 ]; then
if [ "$warn_only" = true ]; then
status="warn"
else
status="fail"
fi
fi
changed_files_json_file="$work_dir/changed_files.json"
errors_json_file="$work_dir/errors.json"
jq -Rs 'split("\n") | map(select(length > 0))' "$changed_files_txt" > "$changed_files_json_file"
jq -Rs 'split("\n") | map(select(length > 0))' "$errors_file" > "$errors_json_file"
warn_only_json=false
if [ "$warn_only" = true ]; then warn_only_json=true; fi
jq -n \
--arg status "$status" \
--argjson warn_only "$warn_only_json" \
--arg resolution_strategy "$resolution_strategy" \
--arg resolved_range "$resolved_range" \
--arg resolved_from "$from_ref" \
--arg resolved_to "$to_ref" \
--argjson changed_files_count "$changed_count" \
--slurpfile changed_files "$changed_files_json_file" \
--slurpfile errors "$errors_json_file" \
--slurpfile unicode "$unicode_json" \
--slurpfile confusable "$confusable_json" \
--slurpfile ioc "$ioc_json" \
--slurpfile heuristic "$heuristic_json" \
'{
status: $status,
warn_only: $warn_only,
resolution_strategy: $resolution_strategy,
resolved_range: $resolved_range,
resolved_from: $resolved_from,
resolved_to: $resolved_to,
changed_files_count: $changed_files_count,
changed_files: $changed_files[0],
errors: $errors[0],
findings: {
unicode: $unicode[0],
confusable: $confusable[0],
ioc: $ioc[0],
heuristic: $heuristic[0]
}
}' > "$output_json"
{
echo "## Malware Scan Summary"
echo
echo "- Status: **$status**"
echo "- Warn only mode: \`$warn_only\`"
echo "- Changed upstream files scanned: \`$changed_count\`"
echo "- Resolution strategy: \`$resolution_strategy\`"
echo "- Changed node/vendor paths: \`${node_vendor_count:-0}\`"
echo "- Changed lockfiles: \`${lockfile_count:-0}\`"
if [ -n "$resolved_range" ]; then
echo "- Resolved upstream range: \`$resolved_range\`"
fi
echo "- Resolved refs: from=\`${from_ref:-n/a}\` to=\`${to_ref:-n/a}\`"
echo "- Unicode findings (post-allowlist): \`$unicode_count\`"
echo "- Confusable findings (post-allowlist): \`$confusable_count\`"
echo "- IOC findings (post-allowlist): \`$ioc_count\`"
echo "- Heuristic findings (post-allowlist): \`$heuristic_count\`"
if [ "$total_count" -gt 0 ]; then
echo
echo "### Top findings"
jq -r -s 'add | .[:20] | .[] | "- `\(.file):\(.line)` \(.kind) :: `\(.match | gsub("`"; ""))`"' \
"$unicode_json" "$confusable_json" "$ioc_json" "$heuristic_json"
fi
} > "$output_summary"
_gheof="GHEOF_$(uuidgen)"
{
echo "status=$status"
echo "changed_files_count=$changed_count"
echo "summary<<${_gheof}"
cat "$output_summary"
echo "${_gheof}"
} >> "$GITHUB_OUTPUT"
if [ "$total_count" -gt 0 ] && [ "$warn_only" = true ]; then
echo "::warning::Malware scan produced $total_count finding(s). Continuing because warn-only mode is enabled."
elif [ "$total_count" -gt 0 ] && [ "$warn_only" = false ]; then
echo "Malware scan failed with $total_count finding(s)." >&2
exit 1
fi
- name: Gather local usage hints
shell: bash
env:
PACKAGE_NAME: ${{ steps.dependabot_context.outputs.package_name }}
run: |
if [ -z "$PACKAGE_NAME" ]; then
echo "No package detected from PR metadata." > package_usage.txt
else
{
echo "Search pattern: $PACKAGE_NAME"
echo
rg -n --fixed-strings --hidden --glob '!.git' --glob '!node_modules' --glob '!.upstream-dependency/**' -- "$PACKAGE_NAME" . || true
} > package_usage.txt
fi
- name: Run Cursor analysis
timeout-minutes: 10
env:
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
PR_TITLE: ${{ steps.target_pr.outputs.title }}
PR_BODY: ${{ steps.target_pr.outputs.body }}
PACKAGE_NAME: ${{ steps.dependabot_context.outputs.package_name }}
FROM_VERSION: ${{ steps.dependabot_context.outputs.from_version }}
TO_VERSION: ${{ steps.dependabot_context.outputs.to_version }}
shell: bash
run: |
if [ -z "$CURSOR_API_KEY" ]; then
echo '{"result":"CURSOR_API_KEY is not set; analysis was skipped."}' > cursor_output.json
exit 0
fi
python3 - <<'PY'
import os
def read_file(path):
try:
with open(path, "r", encoding="utf-8") as f:
return f.read()
except FileNotFoundError:
return ""
base_context = f"""
This is a dependency bot PR review request.
PR title:
{os.getenv("PR_TITLE", "")}
PR body:
{os.getenv("PR_BODY", "")}
Package metadata:
- package: {os.getenv("PACKAGE_NAME", "")}
- from: {os.getenv("FROM_VERSION", "")}
- to: {os.getenv("TO_VERSION", "")}
PR context JSON:
{read_file("dependabot_comment_context.json")}
Release notes:
{read_file("dependabot_release_notes.md")}
Commits:
{read_file("dependabot_commits.md")}
Local usage hints (non-authoritative rg hits):
{read_file("package_usage.txt")[:12000]}
Repository layout:
- Current repository root: .
- Upstream dependency repository: .upstream-dependency (full git history is available)
""".strip()
malware_context = f"""
{base_context}
Malware scan summary:
{read_file("malware_scan_summary.md")}
Malware scan report JSON:
{read_file("malware_scan_report.json")[:16000]}
""".strip()
malware_prompt = f"""
{malware_context}
Task 1: Supply-chain malware review.
Review the dependency update for signs of compromise. Use this checklist and explicitly consider each category:
### Classic obfuscation
- Obfuscated code (base64, exec, eval, XOR, encoded strings)
- Network calls to unexpected hosts (non-package-related URLs)
- File system writes to startup/persistence locations
- Process spawning, shell commands
- Steganography or data hiding in media files
- Credential/token exfiltration
- Typosquatting indicators
- Suspicious npm lifecycle scripts (preinstall, install, postinstall) in package.json
- Dynamic require() or import() of obfuscated or encoded URLs
- Minified or bundled payloads added outside normal build artifacts
### Invisible Unicode / GlassWorm technique
This class of attack can appear blank in rendered code review. Flag:
- Unicode Private Use Area characters (U+FE00–U+FE0F, U+E0100–U+E01EF)
- Zero-width characters (U+200B, U+200C, U+200D, U+FEFF)
- Hangul filler characters (U+3164, U+115F, U+1160)
- Bidi control characters (U+202A–U+202E, U+2066–U+2069, Trojan Source)
- Homoglyph substitutions in operators/identifiers (e.g. /, ∗, ǃ)
- Strings that look empty in diff but have non-zero bytes
- eval()/Function() receiving visually blank strings
- Decoder patterns using codePointAt()/fromCodePoint()/charCodeAt() for hidden payload assembly
- Commit metadata consistency anomalies suggesting force-push/rewrite concealment
### Dependency integrity
- Unexpected new transitive dependencies vs prior dependency graph
- Known-safe packages with sudden dependency count increase (e.g., axios expected dependency count)
- Lock file hashes/checksums inconsistent with expected integrity formats or release metadata
- Version jumps skipping many semver minors, or ghost versions missing corresponding tags/releases
- Maintainer/publisher identity drift from historical account patterns
### Dependabot-specific context
- Focus on files changed in node_modules/, vendor/, and dependency/lock manifests (package-lock.json, yarn.lock, pnpm-lock.yaml, Gemfile.lock, go.sum, Cargo.toml, Cargo.lock, .cargo/config.toml, .cargo/config, pyproject.toml, poetry.lock, Pipfile.lock, requirements.txt, requirements-dev.txt, requirements/*.txt, etc.)
- Flag new transitive dependencies introduced alongside the direct update
- Flag new preinstall/postinstall scripts that were not present previously
- Treat .github/workflows/ modifications as highly suspicious in a pure dependency update PR
Use the provided malware scanner report as hard evidence and incorporate it into your conclusion.
If scanner findings and your interpretation disagree, call that out explicitly.
Start your response with exactly one line:
Verdict: malicious
or:
Verdict: benign
Then explain your reasoning briefly with top evidence.
Do not include intermediate reasoning or self-talk.
Keep it concise and actionable.
""".strip()
compatibility_prompt = f"""
{base_context}
Task 2: Compatibility and adoption analysis.
1) Where in this repo the dependency appears to be used (treat rg hints as directional, not exhaustive).
2) Whether those usage sites intersect with likely changed APIs based on release notes, commits, and direct inspection of .upstream-dependency.
3) Risks / unknowns for runtime/build compatibility.
4) Recommendation: merge / merge-with-caveats / hold.
Do not include intermediate reasoning or self-talk.
Keep it concise and actionable.
""".strip()
with open("cursor_prompt_malware.txt", "w", encoding="utf-8") as f:
f.write(malware_prompt)
with open("cursor_prompt_compatibility.txt", "w", encoding="utf-8") as f:
f.write(compatibility_prompt)
PY
run_agent_prompt() {
local prompt_file="$1"
local output_file="$2"
if ! agent -f --mode ask -p --output-format json < "$prompt_file" > "$output_file"; then
FALLBACK_PROMPT="$(python3 -c 'from pathlib import Path; import sys; path = sys.argv[1]; max_bytes = 60000; raw = Path(path).read_bytes(); text = raw[:max_bytes].decode("utf-8", errors="ignore"); text += "\n\n[Prompt truncated for CLI argument compatibility.]" if len(raw) > max_bytes else ""; print(text, end="")' "$prompt_file")"
agent -f --mode ask -p --output-format json "$FALLBACK_PROMPT" > "$output_file"
fi
}
run_agent_prompt "cursor_prompt_malware.txt" "cursor_output_malware.json"
run_agent_prompt "cursor_prompt_compatibility.txt" "cursor_output_compatibility.json"
python3 - <<'PY'
import json
def load_any(path):
try:
with open(path, "r", encoding="utf-8") as f:
raw = f.read()
except FileNotFoundError:
return {"result": f"Missing output file: {path}"}
try:
return json.loads(raw)
except Exception:
return {"result": raw}
def extract_text(payload):
if not isinstance(payload, dict):
try:
return json.dumps(payload, indent=2)
except Exception:
return str(payload)
for key in ("result", "output", "text", "message"):
val = payload.get(key)
if isinstance(val, str) and val.strip():
return val
try:
return json.dumps(payload, indent=2)
except Exception:
return str(payload)
malware_payload = load_any("cursor_output_malware.json")
compatibility_payload = load_any("cursor_output_compatibility.json")
malware_text = extract_text(malware_payload)
compatibility_text = extract_text(compatibility_payload)
combined_text = (
"## Supply-Chain Malware Review\n\n"
f"{malware_text}\n\n"
"## Compatibility Analysis\n\n"
f"{compatibility_text}"
)
combined = {
"result": combined_text,
"malware_review": malware_payload,
"compatibility_review": compatibility_payload,
}
with open("cursor_output.json", "w", encoding="utf-8") as f:
json.dump(combined, f, indent=2)
PY
- name: Upload malware scan artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: dependabot-malware-scan-${{ steps.target_pr.outputs.number }}
if-no-files-found: warn
path: |
malware_scan_report.json
malware_scan_summary.md
upstream_changed_files.txt
- name: Post or update PR comment
uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
const marker = '<!-- cursor-dependabot-review -->';
const analysisMaxLen = 48000;
const malwareMaxLen = 10000;
const githubCommentLimit = 65536;
const malwareScanStatus = ${{ toJSON(steps.malware_scan.outputs.status) }} || '';
const malwareScanChangedCount = ${{ toJSON(steps.malware_scan.outputs.changed_files_count) }} || '';
const malwareScanSummaryOutput = ${{ toJSON(steps.malware_scan.outputs.summary) }} || '';
function readText(path, fallback = '') {
try {
return fs.readFileSync(path, 'utf8');
} catch (_) {
return fallback;
}
}