-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpackage-security-checks.yml
More file actions
550 lines (455 loc) · 20.9 KB
/
Copy pathpackage-security-checks.yml
File metadata and controls
550 lines (455 loc) · 20.9 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
name: Package security checks (central)
on:
workflow_call:
inputs:
cutoff_age_days:
description: "DependencyAge cutoff in days"
required: false
type: string
default: "7"
scan_all:
description: "Scan all packages (monorepo) true/false"
required: false
type: boolean
default: true
report_only:
description: "Report-only true/false"
required: false
type: boolean
default: true
secrets:
BBC_TESTING_TOOL_REPO_TOKEN:
required: true
pull_request:
merge_group:
schedule:
# Weekly run (ticket requirement: every PR OR weekly via cron)
- cron: "0 7 * * 1"
workflow_dispatch:
inputs:
cutoff_age_days:
description: "DependencyAge cutoff in days"
required: false
default: "7"
scan_all:
description: "Scan all packages (monorepo) true/false"
required: false
default: "true"
report_only:
description: "Report-only true/false"
required: false
default: "true"
permissions:
contents: read
jobs:
package-security-checks:
runs-on: ubuntu-latest
# Expose outputs for orchestrators (security-summary.yml)
outputs:
is_node: ${{ steps.detect_node.outputs.is_node }}
tool_exit_code: ${{ steps.runchecks.outputs.tool_exit_code }}
report_only: ${{ steps.runchecks.outputs.report_only }}
effective_cutoff_age_days: ${{ steps.runchecks.outputs.effective_cutoff_age_days }}
effective_scan_all: ${{ steps.runchecks.outputs.effective_scan_all }}
steps:
- name: Checkout repo under test
uses: actions/checkout@v4
with:
path: repo-under-test
- name: Prepare output directory
working-directory: repo-under-test
shell: bash
run: |
set -euo pipefail
mkdir -p .bbc-security
# Ensure files exist so artifacts are consistent even on skip/fail
: > .bbc-security/console.txt
: > .bbc-security/package-security-checks-report.ndjson
printf '{ "checks": [] }\n' > .bbc-security/report.json
: > .bbc-security/node-details.md
- name: Detect Node project
id: detect_node
working-directory: repo-under-test
shell: bash
run: |
set -euo pipefail
if find . -name node_modules -prune -o -name ".git" -prune -o -name "package.json" -print -quit | grep -q .; then
echo "is_node=true" >> "$GITHUB_OUTPUT"
else
echo "is_node=false" >> "$GITHUB_OUTPUT"
fi
- name: Write skip summary (not a Node repo)
if: steps.detect_node.outputs.is_node != 'true'
working-directory: repo-under-test
shell: bash
run: |
set -euo pipefail
cat > .bbc-security/node-details.md <<'MD'
<!-- bbc-security-node-dependency-compliance -->
### Node Dependency Compliance details
<details><summary>Click to expand</summary>
ℹ️ Not a Node.js repository (no `package.json` found). Checks skipped.
</details>
MD
cat .bbc-security/node-details.md >> "$GITHUB_STEP_SUMMARY"
- name: Checkout tool repo (Paul)
if: steps.detect_node.outputs.is_node == 'true'
uses: actions/checkout@v4
with:
repository: bbc-testing/bbc-package-security-checks1
path: tool
token: ${{ secrets.BBC_TESTING_TOOL_REPO_TOKEN }}
# Tool requires Node 21+ (uses fs.glob). Use Node 22 LTS.
- name: Setup Node
if: steps.detect_node.outputs.is_node == 'true'
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install tool deps
if: steps.detect_node.outputs.is_node == 'true'
working-directory: tool
shell: bash
run: |
set -euo pipefail
npm ci
- name: Preflight (lockfile sanity)
if: steps.detect_node.outputs.is_node == 'true'
working-directory: repo-under-test
shell: bash
run: |
set -euo pipefail
has_npm=false
has_pnpm=false
if find . -name node_modules -prune -o -name ".git" -prune -o -name "package-lock.json" -print -quit | grep -q .; then
has_npm=true
fi
if find . -name node_modules -prune -o -name ".git" -prune -o -name "pnpm-lock.yaml" -print -quit | grep -q .; then
has_pnpm=true
fi
if [ "$has_npm" = "false" ] && [ "$has_pnpm" = "false" ]; then
echo "No supported lockfile found (package-lock.json or pnpm-lock.yaml)." | tee -a .bbc-security/console.txt
exit 1
fi
if [ "$has_npm" = "true" ] && [ "$has_pnpm" = "true" ]; then
echo "Both package-lock.json and pnpm-lock.yaml found. Keep only one lockfile type per repo." | tee -a .bbc-security/console.txt
exit 1
fi
- name: Run checks
if: steps.detect_node.outputs.is_node == 'true'
id: runchecks
working-directory: repo-under-test
shell: bash
env:
DEFAULT_CUTOFF_AGE_DAYS: "7"
DEFAULT_SCAN_ALL: "true"
DEFAULT_REPORT_ONLY: "true"
# workflow_call inputs
CALL_CUTOFF_AGE_DAYS: ${{ inputs.cutoff_age_days }}
CALL_SCAN_ALL: ${{ inputs.scan_all }}
CALL_REPORT_ONLY: ${{ inputs.report_only }}
# workflow_dispatch inputs (strings)
DISPATCH_CUTOFF_AGE_DAYS: ${{ github.event.inputs.cutoff_age_days }}
DISPATCH_SCAN_ALL: ${{ github.event.inputs.scan_all }}
DISPATCH_REPORT_ONLY: ${{ github.event.inputs.report_only }}
run: |
set +e
set -u
set -o pipefail
mkdir -p .bbc-security
# Prefer workflow_call inputs when present, otherwise workflow_dispatch, otherwise defaults
CUTOFF_AGE_DAYS="${CALL_CUTOFF_AGE_DAYS:-${DISPATCH_CUTOFF_AGE_DAYS:-$DEFAULT_CUTOFF_AGE_DAYS}}"
SCAN_ALL="${CALL_SCAN_ALL:-${DISPATCH_SCAN_ALL:-$DEFAULT_SCAN_ALL}}"
REPORT_ONLY="${CALL_REPORT_ONLY:-${DISPATCH_REPORT_ONLY:-$DEFAULT_REPORT_ONLY}}"
# Normalize booleans (inputs can be True/False/empty)
SCAN_ALL="$(echo "${SCAN_ALL}" | tr '[:upper:]' '[:lower:]')"
REPORT_ONLY="$(echo "${REPORT_ONLY}" | tr '[:upper:]' '[:lower:]')"
if [ "${SCAN_ALL}" != "true" ] && [ "${SCAN_ALL}" != "false" ]; then SCAN_ALL="$DEFAULT_SCAN_ALL"; fi
if [ "${REPORT_ONLY}" != "true" ] && [ "${REPORT_ONLY}" != "false" ]; then REPORT_ONLY="$DEFAULT_REPORT_ONLY"; fi
# Workspace / monorepo guard (Paul feedback)
IS_WORKSPACE=false
if [ -f "package.json" ]; then
node -e "const fs=require('fs');try{const pj=JSON.parse(fs.readFileSync('package.json','utf8'));const ws=pj&&pj.workspaces;const ok=Array.isArray(ws)||(ws&&typeof ws==='object'&&Array.isArray(ws.packages));process.exit(ok?0:1);}catch{process.exit(1);}"
if [ $? -eq 0 ]; then IS_WORKSPACE=true; fi
fi
if [ -f "pnpm-workspace.yaml" ]; then
IS_WORKSPACE=true
fi
if [ "${IS_WORKSPACE}" = "true" ]; then
echo "Workspace detected. Forcing scan_all=false to avoid incorrect nested package graphs." | tee -a .bbc-security/console.txt
SCAN_ALL="false"
fi
ARGS=(--json --cutoff-age="${CUTOFF_AGE_DAYS}")
if [ "${SCAN_ALL}" = "true" ]; then
ARGS+=(--all)
fi
echo "Running tool with args: ${ARGS[*]}" | tee -a .bbc-security/console.txt
node ../tool/bin/bbc-package-security-checks.js "${ARGS[@]}" 2>&1 | tee -a .bbc-security/console.txt
TOOL_EXIT=${PIPESTATUS[0]}
echo "tool_exit_code=${TOOL_EXIT}" >> "$GITHUB_OUTPUT"
echo "report_only=${REPORT_ONLY}" >> "$GITHUB_OUTPUT"
echo "effective_cutoff_age_days=${CUTOFF_AGE_DAYS}" >> "$GITHUB_OUTPUT"
echo "effective_scan_all=${SCAN_ALL}" >> "$GITHUB_OUTPUT"
# Extract JSON objects from console output and write NDJSON
node - <<'NODE'
const fs = require("fs");
const input = fs.readFileSync(".bbc-security/console.txt", "utf8");
const out = [];
let inStr = false, esc = false, depth = 0, start = -1;
for (let i = 0; i < input.length; i++) {
const ch = input[i];
if (inStr) {
if (esc) esc = false;
else if (ch === "\\") esc = true;
else if (ch === '"') inStr = false;
continue;
}
if (ch === '"') { inStr = true; continue; }
if (ch === "{") {
if (depth === 0) start = i;
depth++;
} else if (ch === "}") {
if (depth > 0) depth--;
if (depth === 0 && start !== -1) {
const cand = input.slice(start, i + 1);
try {
const obj = JSON.parse(cand);
if (obj && typeof obj === "object" && obj.packageInfo && obj.status && Array.isArray(obj.checks)) {
out.push(JSON.stringify(obj));
}
} catch {}
start = -1;
}
}
}
fs.writeFileSync(".bbc-security/package-security-checks-report.ndjson", out.join("\n") + (out.length ? "\n" : ""));
NODE
# Normalize to report.json (flat checks array)
node - <<'NODE'
const fs = require("fs");
const p = ".bbc-security/package-security-checks-report.ndjson";
const report = { checks: [] };
if (!fs.existsSync(p)) {
fs.writeFileSync(".bbc-security/report.json", JSON.stringify(report, null, 2));
process.exit(0);
}
const lines = fs.readFileSync(p, "utf8").split(/\n+/).filter(Boolean);
const reports = lines.map(l => JSON.parse(l));
for (const r of reports) {
for (const c of (r.checks || [])) {
report.checks.push({
package: r.packageInfo?.name || "unknown",
packagePath: r.packageInfo?.path || "unknown",
title: c.title || "Unnamed check",
description: c.description || "",
result: {
status: c.result?.status || "UNKNOWN",
headline: c.result?.headline || "",
violations: Array.isArray(c.result?.violations) ? c.result.violations : [],
ignored: Array.isArray(c.result?.ignored) ? c.result.ignored : []
}
});
}
}
fs.writeFileSync(".bbc-security/report.json", JSON.stringify(report, null, 2));
NODE
# Report-only mode never fails the job
if [ "${REPORT_ONLY}" = "true" ]; then
echo "Report-only mode: tool exit code ${TOOL_EXIT} (not failing the job)." | tee -a .bbc-security/console.txt
exit 0
fi
exit "${TOOL_EXIT}"
- name: Build rendered Node compliance markdown
if: always() && steps.detect_node.outputs.is_node == 'true'
working-directory: repo-under-test
shell: bash
env:
REPORT_ONLY: ${{ steps.runchecks.outputs.report_only }}
TOOL_EXIT_CODE: ${{ steps.runchecks.outputs.tool_exit_code }}
run: |
set -euo pipefail
node <<'JS' > .bbc-security/node-details.md
const fs = require("fs");
const path = require("path");
const marker = "<!-- bbc-security-node-dependency-compliance -->";
// InfoSec guidance deep-links (placeholders - replace later with real Confluence URLs)
const INFOSEC = {
guidance: "TEMPLATE_LINK_INFOSEC_GUIDANCE",
minimumAge: "TEMPLATE_LINK_MINIMUM_AGE",
lockfiles: "TEMPLATE_LINK_LOCKFILES",
ignoreScripts: "TEMPLATE_LINK_IGNORE_SCRIPTS",
exactVersions: "TEMPLATE_LINK_EXACT_VERSIONS",
dependabotCooldown: "TEMPLATE_LINK_DEPENDABOT_COOLDOWN",
workspaceScope: "TEMPLATE_LINK_WORKSPACE_SCOPE"
};
function safeReadJSON(p) {
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
}
function escapeCell(s) {
return String(s || "")
.replace(/\r?\n/g, " ")
.replace(/\|/g, "\\|")
.trim();
}
// Dynamic lockfile detection (repo-under-test)
const hasNpmLock = fs.existsSync(path.join(process.cwd(), "package-lock.json"));
const hasPnpmLock = fs.existsSync(path.join(process.cwd(), "pnpm-lock.yaml"));
const lockType = hasPnpmLock ? "pnpm" : (hasNpmLock ? "npm" : "unknown");
function infosecLink(id) {
const map = {
"dependency-age": INFOSEC.minimumAge,
"lockfile": INFOSEC.lockfiles,
"install-scripts": INFOSEC.ignoreScripts,
"exact-versions": INFOSEC.exactVersions,
"dependabot": INFOSEC.dependabotCooldown,
"workspace-scope": INFOSEC.workspaceScope
};
return map[id] || INFOSEC.guidance;
}
function classifyPolicy(title) {
const t = (title || "").toLowerCase();
// Keep this intentionally simple and stable (matches current tool check names)
if (t.includes("age")) return { id: "dependency-age", label: "Minimum dependency age (1 week)" };
if (t.includes("pin") || t.includes("pinn") || t.includes("exact")) return { id: "exact-versions", label: "Prefer exact dependency versions" };
if (t.includes("script")) return { id: "install-scripts", label: "Disable install scripts" };
if (t.includes("lock")) return { id: "lockfile", label: "Use lockfiles and commit them" };
// Default catch-all
return { id: "guidance", label: "InfoSec Guidance" };
}
function buildRec(title, status, violations, lockType) {
const st = (status || "").toUpperCase();
if (st === "PASS") return "No action needed.";
const policy = classifyPolicy(title);
const link = infosecLink(policy.id);
const base = [];
const v = Array.isArray(violations) ? violations.map(x => String(x)) : [];
if (policy.id === "exact-versions") {
base.push("Pin dependencies to exact versions (avoid ranges like `^` and `~`).");
const hasGithubSemverCaret = v.some(line => String(line).includes("#semver:^"));
if (hasGithubSemverCaret) {
base.push("For GitHub deps, replace `#semver:^x.y.z` with `#semver:x.y.z`.");
}
base.push("Update the lockfile and commit the change.");
base.push(`InfoSec guidance: ${link}`);
return base.join(" ");
}
if (policy.id === "install-scripts") {
base.push("Disable install/lifecycle scripts for dependency installs.");
if (lockType === "npm") {
base.push("Ensure `.npmrc` includes `ignore-scripts=true`.");
} else if (lockType === "pnpm") {
base.push("Ensure `.npmrc` includes `ignore-scripts=true` (pnpm reads `.npmrc`).");
} else {
base.push("Configure your package manager to ignore install scripts.");
}
base.push(`InfoSec guidance: ${link}`);
return base.join(" ");
}
if (policy.id === "dependency-age") {
base.push("Avoid recently published dependencies under the configured cutoff (default 7 days).");
base.push("If an urgent fix is required, apply an exception with appropriate review.");
base.push(`InfoSec guidance: ${link}`);
return base.join(" ");
}
if (policy.id === "lockfile") {
base.push("Ensure you use a lockfile and commit it to version control.");
base.push(`InfoSec guidance: ${link}`);
return base.join(" ");
}
base.push("Review the violations and update dependencies/config to resolve.");
base.push(`InfoSec guidance: ${INFOSEC.guidance}`);
return base.join(" ");
}
const report = safeReadJSON(".bbc-security/report.json");
const checks = report?.checks || [];
const reportOnly = (process.env.REPORT_ONLY || "true") === "true";
const toolExit = String(process.env.TOOL_EXIT_CODE || "").trim();
// Compute overall status for display (dynamic)
let pass = 0, warn = 0, fail = 0, unknown = 0;
for (const c of checks) {
const st = String(c?.result?.status || "UNKNOWN").toUpperCase();
if (st === "PASS") pass++;
else if (st === "WARN") warn++;
else if (st === "FAIL") fail++;
else unknown++;
}
const overall =
fail > 0 ? { icon: "❌", label: "ISSUES FOUND" } :
warn > 0 ? { icon: "⚠️", label: "WARNINGS" } :
checks.length ? { icon: "✅", label: "PASS" } :
{ icon: "ℹ️", label: "INFO" };
console.log("_Node Dependency Compliance checks are based on InfoSec Guidance but owned by Engineering Enablement._");
console.log("");
console.log(`**Overall status:** ${overall.icon} ${overall.label}`);
console.log("");
if (!checks.length) {
console.log("_No tool report data was produced. Check the artifact logs._");
console.log("");
process.exit(0);
}
console.log("<details><summary>Click to expand</summary>");
console.log("");
const byPkg = new Map();
for (const row of checks) {
const pkg = row.package || "unknown";
const list = byPkg.get(pkg) || [];
list.push(row);
byPkg.set(pkg, list);
}
for (const [pkg, rows] of byPkg.entries()) {
const pkgPath = rows[0]?.packagePath || "unknown";
console.log("| Check | Status | Notes / Violations | Recommendation |");
console.log("|-------|--------|--------------------|----------------|");
const byTitle = new Map();
const order = { FAIL: 3, WARN: 2, PASS: 1, UNKNOWN: 0 };
for (const r of rows) {
const title = r.title || "Unnamed check";
const status = String(r.result?.status || "UNKNOWN").toUpperCase();
const headline = r.result?.headline || "";
const violations = Array.isArray(r.result?.violations) ? r.result.violations : [];
const existing = byTitle.get(title);
if (!existing) {
byTitle.set(title, { title, status, headline, violations: [...violations] });
} else {
if ((order[status] || 0) > (order[existing.status] || 0)) existing.status = status;
if (!existing.headline && headline) existing.headline = headline;
existing.violations.push(...violations);
}
}
for (const v of byTitle.values()) {
const uniq = [...new Set(v.violations.map(x => String(x).trim()))].filter(Boolean);
const notesParts = [];
if (v.headline) notesParts.push(v.headline);
if (uniq.length) {
const sample = uniq.slice(0, 3).join("; ");
const extra = uniq.length > 3 ? ` (+${uniq.length - 3} more)` : "";
notesParts.push(sample + extra);
}
if (!notesParts.length) notesParts.push(v.status === "PASS" ? "No issues detected." : "-");
const notes = escapeCell(notesParts.join(" — ")).slice(0, 500);
const rec = escapeCell(buildRec(v.title, v.status, uniq, lockType)).slice(0, 500);
console.log(`| ${escapeCell(v.title)} | ${escapeCell(v.status)} | ${notes} | ${rec} |`);
}
console.log("");
}
console.log("</details>");
console.log("");
JS
- name: Append to Actions summary
if: always()
shell: bash
run: |
if [ -f repo-under-test/.bbc-security/node-details.md ]; then
cat repo-under-test/.bbc-security/node-details.md >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: package-security-checks
path: |
repo-under-test/.bbc-security/console.txt
repo-under-test/.bbc-security/package-security-checks-report.ndjson
repo-under-test/.bbc-security/report.json
repo-under-test/.bbc-security/node-details.md
if-no-files-found: warn
retention-days: 14