diff --git a/.github/actions/dependency-audit/README.md b/.github/actions/dependency-audit/README.md new file mode 100644 index 00000000000..c4ab9b18819 --- /dev/null +++ b/.github/actions/dependency-audit/README.md @@ -0,0 +1,83 @@ +# Dependency security audit + +Audits UI5 projects for vulnerable dependencies with [`audit-ci`](https://github.com/IBM/audit-ci), +from three places — **local shell, GitHub Actions, and Jenkins** — using one set of scripts. + +Two kinds of target are audited and reported **separately**: + +- **Repositories** — a checked-out repo, in three scopes: `locked` (`npm ci`), `devprod` + (`npm install`, lockfile ignored), and `prod` (`--skip-dev`). +- **npm packages** — the latest published versions consumers actually install (e.g. `@ui5/cli@^4`, + `@ui5/cli@next`), each installed into a throwaway project outside the repo and audited there. + +## Rationale + +- **Layered.** Three layers that change for different reasons: + *audit* (produce results), *format* (render), *orchestration* (decide what to run and whether to + fail). The audit scripts carry no platform assumptions, so the same code runs everywhere. +- **Never fail + a separate gate.** `audit-*` scripts always exit 0 and write results; `gate.mjs` + alone decides build pass/fail. This is why the **summary is always produced** even when findings + exist — the report is written before (and independently of) the failure. On GitHub, a step that + writes `$GITHUB_STEP_SUMMARY` and then exits non-zero never renders its summary, so separating the + two is essential, not cosmetic. +- **A plain results directory is the only contract.** Each audit writes + `/.json` + `.log` and one `manifest.jsonl` line. Formatters and the gate read that; + they never re-run audits. +- **Node.js, zero dependencies.** The work is ~80% JSON shaping — a job + for `JSON.parse`/`Map`/`.sort`, not `jq`+`awk`. One language, no extra system tools, and the + scripts use only Node built-ins so they run unchanged inside other repos and throwaway dirs + (`audit-ci` itself is fetched via `npx`). +- **One target list (DRY).** `targets.json` feeds the scheduled run, the manual run, and Jenkins — + no list is written twice. +- **GitHub: parallel matrix + always-present summary.** Each target is its own job (`fail-fast: + false`), so targets fail independently; a final `summary` job (`if: always()`) merges every job's + artifact into one consolidated report. +- **Severity level is explicit.** `audit-ci` reports nothing unless given a level. Repos use their + own `audit-ci` config; standalone npm audits (no config) pass `--low` to match. + +## Running it + +### GitHub Actions +- **Scheduled** (`.github/workflows/security-audit-scheduled.yml`) — daily, all targets. +- **Manual** (`.github/workflows/security-audit.yml`) — `workflow_dispatch`; tick the targets to run. + +Both call the reusable engine (`security-audit-reusable.yml`); the report appears in the run's +**Summary** tab, and the run fails if any target has findings. + +### Jenkins +`Jenkinsfile` (repo root) mirrors the scheduled run using the same scripts and `format-cli.mjs`. +Requires `git` and Node.js (`^22.20 || >=24`) on the agent. + +### Locally + +Audit everything in `targets.json` (clones repos + installs published packages — slower): + +```bash +R=/tmp/audit-results +node .github/actions/dependency-audit/scripts/audit-all.mjs \ + --targets .github/actions/dependency-audit/targets.json --results "$R" +node .github/actions/dependency-audit/scripts/format-cli.mjs --results "$R" +node .github/actions/dependency-audit/scripts/gate.mjs --results "$R" # exit 1 if findings +``` + +Audit a single published package (fast): + +```bash +R=/tmp/audit-results +node .github/actions/dependency-audit/scripts/audit-npm.mjs --label "CLI v4" --results "$R" "@ui5/cli@^4.0.0" +node .github/actions/dependency-audit/scripts/format-cli.mjs --results "$R" +``` + +Audit the repo you are standing in (run from the repo root): + +```bash +R=/tmp/audit-results +node /path/to/ui5-cli-mono/.github/actions/dependency-audit/scripts/audit-repo.mjs --label "my-repo" --results "$R" +node /path/to/ui5-cli-mono/.github/actions/dependency-audit/scripts/format-cli.mjs --results "$R" +``` + +## Adding a target + +Edit `targets.json` (add a `repos[]` or `npm[]` entry with a unique `id`). The scheduled run and +Jenkins pick it up automatically. For the manual workflow, add a matching boolean input in +`.github/workflows/security-audit.yml`. diff --git a/.github/actions/dependency-audit/action.yml b/.github/actions/dependency-audit/action.yml new file mode 100644 index 00000000000..572fa7953a1 --- /dev/null +++ b/.github/actions/dependency-audit/action.yml @@ -0,0 +1,44 @@ +name: "Dependency security audit (one target)" +description: > + Audit a single target id (a repository or an npm package from targets.json), upload its results as + an artifact, and fail THIS job on findings. Used once per matrix job; the caller's summary job + merges all artifacts into one job summary. + +inputs: + id: + description: "Target id from targets.json (e.g. repo-cli-v4, npm-cli-v5)." + required: true + node-version: + description: "Node.js version to set up (npm 11+ recommended for stable audit paths)." + default: "24" + +runs: + using: composite + steps: + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ inputs.node-version }} + + # Produce results only — the audit script never fails. Repos are cloned by the driver, so no + # extra checkout of the audited project is needed (and no dependency code runs: --ignore-scripts). + - name: Audit ${{ inputs.id }} + shell: bash + run: > + node "${{ github.action_path }}/scripts/audit-all.mjs" + --targets "${{ github.action_path }}/targets.json" + --results "${{ runner.temp }}/audit-results" + --only "${{ inputs.id }}" + + # Upload BEFORE gating (and always), so the summary job can render even when this job fails. + - name: Upload results + if: always() + uses: actions/upload-artifact@v4 # TODO: pin to a commit SHA + with: + name: audit-${{ inputs.id }} + path: ${{ runner.temp }}/audit-results + if-no-files-found: warn + + - name: Fail on findings + shell: bash + run: node "${{ github.action_path }}/scripts/gate.mjs" --results "${{ runner.temp }}/audit-results" diff --git a/.github/actions/dependency-audit/scripts/audit-all.mjs b/.github/actions/dependency-audit/scripts/audit-all.mjs new file mode 100644 index 00000000000..6c5325632f0 --- /dev/null +++ b/.github/actions/dependency-audit/scripts/audit-all.mjs @@ -0,0 +1,51 @@ +// Orchestration driver shared by GitHub Actions and Jenkins (DRY): read targets.json, optionally +// narrow with --only, clone each repo target to a throwaway dir and audit it, and audit each npm +// target. Produces results only — formatting (format-*.mjs) and gating (gate.mjs) are separate. +// Never exits non-zero itself; a clone failure is recorded as a finding so the gate catches it. + +import {mkdtempSync, readFileSync, rmSync} from "node:fs"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import {isMain, parseFlags, run, slug, writeResult} from "./lib.mjs"; +import {auditRepo} from "./audit-repo.mjs"; +import {auditNpm} from "./audit-npm.mjs"; + +export function runAll({targetsFile, resultsDir, only}) { + const targets = JSON.parse(readFileSync(targetsFile, "utf8")); + const wanted = only && only.length ? new Set(only) : null; + const enabled = (target) => !wanted || wanted.has(target.id); + + for (const repo of (targets.repos || []).filter(enabled)) { + const label = `${repo.repository}@${repo.ref}`; + const url = repo.url || `https://github.com/${repo.repository}`; + const work = mkdtempSync(join(tmpdir(), "audit-repo-")); + try { + // --ignore-scripts is irrelevant to clone; the audits themselves use it. Shallow + single ref. + const clone = run("git", ["clone", "--depth", "1", "--branch", repo.ref, url, work]); + if (clone.status !== 0) { + writeResult(resultsDir, { + kind: "repo", slug: `${slug(label)}-clone`, label: `${label} · clone`, + target: label, scope: "clone", allow: [], found: true, + }, {log: `✗ git clone failed for ${url}@${repo.ref}\n${clone.stderr}`}); + continue; + } + auditRepo({label, resultsDir, cwd: work}); + } finally { + rmSync(work, {recursive: true, force: true}); + } + } + + for (const pkg of (targets.npm || []).filter(enabled)) { + auditNpm({label: pkg.label, resultsDir, specs: [pkg.spec]}); + } +} + +if (isMain(import.meta.url)) { + const {flags} = parseFlags(process.argv.slice(2)); + runAll({ + targetsFile: flags.targets || "targets.json", + resultsDir: flags.results || "audit-results", + only: flags.only ? flags.only.split(",").map((s) => s.trim()).filter(Boolean) : null, + }); + process.exit(0); // never fail — gate.mjs decides build status +} diff --git a/.github/actions/dependency-audit/scripts/audit-npm.mjs b/.github/actions/dependency-audit/scripts/audit-npm.mjs new file mode 100644 index 00000000000..11b1cc45b0e --- /dev/null +++ b/.github/actions/dependency-audit/scripts/audit-npm.mjs @@ -0,0 +1,58 @@ +// Audit published npm packages. For each spec: create an empty project in a throwaway dir OUTSIDE +// the repo, install the spec, and audit the resulting tree — so the scan reflects what consumers +// get from `npm install ` today, not this repo's source. Never fails (always exits 0). +// +// Standalone npm audits run WITHOUT an audit-ci config (raw view of published packages). + +import {mkdtempSync, rmSync, writeFileSync} from "node:fs"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import {isMain, logGroup, parseFlags, run, slug, writeResult} from "./lib.mjs"; + +export function auditNpm({label, resultsDir, specs}) { + for (const spec of specs) { + const columnLabel = specs.length === 1 ? label : `${label} (${spec})`; + const work = mkdtempSync(join(tmpdir(), "audit-npm-")); + try { + writeFileSync(join(work, "package.json"), + JSON.stringify({name: "audit-temp", version: "0.0.0", private: true}) + "\n"); + + const install = run("npm", ["install", "--no-audit", "--no-fund", "--ignore-scripts", spec], {cwd: work}); + let reportJson = ""; + let log = install.stderr; + let found; + if (install.status !== 0) { + // An install failure is itself a problem the gate should surface. + found = true; + log += `\n✗ install of ${spec} failed (exit ${install.status})`; + } else { + // audit-ci reports NOTHING unless a severity level is set. The repo config uses + // "low"; standalone npm audits have no config, so pass --low explicitly to match + // (fail on low and above — everything `npm audit` surfaces). + const res = run("npx", ["--yes", "audit-ci", "--low", "--output-format", "json"], {cwd: work}); + reportJson = res.stdout; + log += res.stderr; + found = res.status !== 0; + } + + writeResult(resultsDir, { + kind: "npm", + slug: slug(columnLabel), + label: columnLabel, + target: spec, + scope: "installed", + allow: [], + found, + }, {reportJson, log}); + logGroup(`Audit — ${columnLabel} (${spec})`, found ? "✗ findings / install error" : "✓ passed"); + } finally { + rmSync(work, {recursive: true, force: true}); + } + } +} + +if (isMain(import.meta.url)) { + const {flags, positionals} = parseFlags(process.argv.slice(2)); + auditNpm({label: flags.label, resultsDir: flags.results, specs: positionals}); + process.exit(0); // never fail +} diff --git a/.github/actions/dependency-audit/scripts/audit-repo.mjs b/.github/actions/dependency-audit/scripts/audit-repo.mjs new file mode 100644 index 00000000000..fe980071603 --- /dev/null +++ b/.github/actions/dependency-audit/scripts/audit-repo.mjs @@ -0,0 +1,70 @@ +// Audit a single checked-out repository in three scopes. Never fails (always exits 0). +// +// locked npm ci -> audit-ci (dev + prod) +// devprod npm install (no lockfile, no modules) -> audit-ci (dev + prod) +// prod same install -> audit-ci --skip-dev (prod only) +// +// Results are written to the results dir; whether findings fail the build is decided by gate.mjs. + +import {existsSync, rmSync} from "node:fs"; +import {join} from "node:path"; +import {allowlistFromConfig, isMain, logGroup, parseFlags, run, slug, writeResult} from "./lib.mjs"; + +const CONFIG_CANDIDATES = ["audit-ci.jsonc", "audit-ci.json", ".audit-ci.jsonc", ".audit-ci.json"]; + +function detectConfig(cwd, override) { + if (override) { + return override; + } + for (const candidate of CONFIG_CANDIDATES) { + if (existsSync(join(cwd, candidate))) { + return candidate; + } + } + return ""; +} + +export function auditRepo({label, resultsDir, cwd = process.cwd(), config}) { + const cfg = detectConfig(cwd, config); + const allow = cfg ? allowlistFromConfig(join(cwd, cfg)) : []; + if (cfg) { + logGroup(`Config — ${label}`, `using audit-ci config: ${cfg}`); + } + + const auditScope = (scope, extraArgs = []) => { + // audit-ci reports nothing without a severity level. A repo config sets its own level; + // if the audited repo has no config, fall back to --low (fail on low and above). + const levelArgs = cfg ? ["--config", cfg] : ["--low"]; + const args = ["--yes", "audit-ci", ...levelArgs, ...extraArgs, "--output-format", "json"]; + const res = run("npx", args, {cwd}); + const found = res.status !== 0; + writeResult(resultsDir, { + kind: "repo", + slug: `${slug(label)}-${scope}`, + label: `${label} · ${scope}`, + target: label, + scope, + allow, + found, + }, {reportJson: res.stdout, log: res.stderr}); + logGroup(`Audit — ${label} · ${scope}`, found ? `✗ findings (exit ${res.status})` : "✓ passed"); + }; + + // 1) Locked tree, as committed. + logGroup(`Install (locked) — ${label}`, run("npm", ["ci", "--ignore-scripts"], {cwd}).stderr); + auditScope("locked"); + + // 2) + 3) Re-resolve every range to its latest satisfying version, then audit full and prod-only. + rmSync(join(cwd, "node_modules"), {recursive: true, force: true}); + rmSync(join(cwd, "package-lock.json"), {force: true}); + logGroup(`Install (latest) — ${label}`, + run("npm", ["install", "--no-audit", "--no-fund", "--ignore-scripts"], {cwd}).stderr); + auditScope("devprod"); + auditScope("prod", ["--skip-dev"]); +} + +if (isMain(import.meta.url)) { + const {flags} = parseFlags(process.argv.slice(2)); + auditRepo({label: flags.label, resultsDir: flags.results, config: flags.config}); + process.exit(0); // never fail +} diff --git a/.github/actions/dependency-audit/scripts/format-cli.mjs b/.github/actions/dependency-audit/scripts/format-cli.mjs new file mode 100644 index 00000000000..4d9c4526fd6 --- /dev/null +++ b/.github/actions/dependency-audit/scripts/format-cli.mjs @@ -0,0 +1,43 @@ +// Formatter: results dir -> plain-text report on stdout, for Jenkins consoles and local runs. +// Same input contract as format-github.mjs; keeps repositories and npm packages separate. + +import {extractAdvisories, isMain, parseFlags, readManifest, readReport} from "./lib.mjs"; + +function describe(advisories) { + if (advisories.length === 0) { + return "none"; + } + return advisories.map((a) => `${a.severity}:${a.pkg} (${a.id})`).join(", "); +} + +export function buildCliReport(resultsDir) { + const records = readManifest(resultsDir); + const out = ["=== Security audit — repositories ==="]; + + const repoRecords = records.filter((r) => r.kind === "repo"); + const repoTargets = [...new Set(repoRecords.map((r) => r.target))]; + if (repoTargets.length === 0) { + out.push(" (none audited)"); + } + for (const target of repoTargets) { + out.push(`\n# ${target}`); + for (const r of repoRecords.filter((rec) => rec.target === target)) { + out.push(` [${r.scope}] ${describe(extractAdvisories(readReport(resultsDir, r), r.allow))}`); + } + } + + out.push("\n=== Security audit — npm packages ==="); + const npmRecords = records.filter((r) => r.kind === "npm"); + if (npmRecords.length === 0) { + out.push(" (none audited)"); + } + for (const r of npmRecords) { + out.push(` ${r.label} [${r.target}] ${describe(extractAdvisories(readReport(resultsDir, r), r.allow))}`); + } + return out.join("\n"); +} + +if (isMain(import.meta.url)) { + const {flags, positionals} = parseFlags(process.argv.slice(2)); + process.stdout.write(buildCliReport(flags.results || positionals[0] || "audit-results") + "\n"); +} diff --git a/.github/actions/dependency-audit/scripts/format-github.mjs b/.github/actions/dependency-audit/scripts/format-github.mjs new file mode 100644 index 00000000000..60e7c159ec8 --- /dev/null +++ b/.github/actions/dependency-audit/scripts/format-github.mjs @@ -0,0 +1,67 @@ +// Formatter: results dir -> GitHub step summary. Two sections, never consolidated: one per-repo +// scope table, and one npm-packages table with a column per package. Writes to $GITHUB_STEP_SUMMARY +// (falls back to stdout when run locally). Read-only over the results dir — no audit logic here. + +import {appendFileSync} from "node:fs"; +import { + buildRows, extractAdvisories, isMain, parseFlags, readManifest, readReport, renderMarkdownTable, +} from "./lib.mjs"; + +const REPO_COLUMNS = [ + {key: "prod", header: "prod · latest (ships)"}, + {key: "locked", header: "locked · ci"}, + {key: "devprod", header: "dev + prod · latest"}, +]; + +export function buildSummary(resultsDir) { + const records = readManifest(resultsDir); + const out = []; + + // --- Repositories: one sub-table per repo, scopes as columns. --- + out.push("## Security audit — repositories", ""); + const repoRecords = records.filter((r) => r.kind === "repo"); + const repoTargets = [...new Set(repoRecords.map((r) => r.target))]; + if (repoTargets.length === 0) { + out.push("_No repositories audited._", ""); + } + for (const target of repoTargets) { + const perColumn = repoRecords + .filter((r) => r.target === target) + .map((r) => ({columnKey: r.scope, advisories: extractAdvisories(readReport(resultsDir, r), r.allow)})); + const rows = buildRows(perColumn); + out.push(`### ${target}`, "", renderMarkdownTable(REPO_COLUMNS, rows, {priorityKey: "prod"}), ""); + } + + // --- npm packages: one column per package (kept separate from repositories). --- + out.push("## Security audit — npm packages", ""); + const npmRecords = records.filter((r) => r.kind === "npm"); + if (npmRecords.length === 0) { + out.push("_No npm packages audited._", ""); + } else { + const columns = npmRecords.map((r) => ({key: r.label, header: r.label})); + const perColumn = npmRecords.map((r) => ({ + columnKey: r.label, + advisories: extractAdvisories(readReport(resultsDir, r), r.allow), + })); + out.push(renderMarkdownTable(columns, buildRows(perColumn)), ""); + } + + out.push( + "**How to read:** ❌ = the package/scope is affected by that advisory; ✅ = not affected." + + " A row affected under **prod · latest** reaches a fresh install today — fix first. A row" + + " affected only under **locked · ci** clears by refreshing the lockfile. Advisories" + + " allowlisted in a repo's audit-ci config are excluded; npm packages are audited without an" + + " allowlist.", + ); + return out.join("\n"); +} + +if (isMain(import.meta.url)) { + const {flags, positionals} = parseFlags(process.argv.slice(2)); + const summary = buildSummary(flags.results || positionals[0] || "audit-results"); + if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary + "\n"); + } else { + process.stdout.write(summary + "\n"); + } +} diff --git a/.github/actions/dependency-audit/scripts/gate.mjs b/.github/actions/dependency-audit/scripts/gate.mjs new file mode 100644 index 00000000000..4dee3e66405 --- /dev/null +++ b/.github/actions/dependency-audit/scripts/gate.mjs @@ -0,0 +1,20 @@ +// Gate: exit non-zero when any audit record reported findings. Run AFTER a formatter, so the +// summary is always written first. This is the only place build pass/fail is decided. + +import {isMain, parseFlags, readManifest} from "./lib.mjs"; + +if (isMain(import.meta.url)) { + const {flags, positionals} = parseFlags(process.argv.slice(2)); + const resultsDir = flags.results || positionals[0] || "audit-results"; + const findings = readManifest(resultsDir).filter((r) => r.found); + if (findings.length > 0) { + const prefix = process.env.GITHUB_ACTIONS ? "::error::" : ""; + console.error(`${prefix}Dependency audit reported findings in ${findings.length} scope(s):`); + for (const f of findings) { + console.error(` - ${f.label}`); + } + process.exit(1); + } + console.log("No audit findings."); + process.exit(0); +} diff --git a/.github/actions/dependency-audit/scripts/lib.mjs b/.github/actions/dependency-audit/scripts/lib.mjs new file mode 100644 index 00000000000..0ac79290cc3 --- /dev/null +++ b/.github/actions/dependency-audit/scripts/lib.mjs @@ -0,0 +1,183 @@ +// Shared helpers for the dependency-audit scripts. +// +// Node built-ins only: these scripts run inside *other* checked-out repositories and in throwaway +// install dirs, so they must never depend on this repo's node_modules. `audit-ci` is fetched at +// run time via `npx --yes audit-ci`. + +import {spawnSync} from "node:child_process"; +import {appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync} from "node:fs"; +import {join} from "node:path"; +import {pathToFileURL} from "node:url"; + +export const SEVERITY_RANK = {critical: 0, high: 1, moderate: 2, low: 3, info: 4}; + +// True when this module file is the process entry point (i.e. run directly, not imported). +export function isMain(importMetaUrl) { + return importMetaUrl === pathToFileURL(process.argv[1]).href; +} + +// Minimal flag parser: "--key value" pairs plus bare positionals. No external dependency. +export function parseFlags(argv) { + const flags = {}; + const positionals = []; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg.startsWith("--")) { + flags[arg.slice(2)] = argv[i + 1]; + i++; + } else { + positionals.push(arg); + } + } + return {flags, positionals}; +} + +// Run a command, capturing output. Never throws on a non-zero exit — the caller inspects `status`. +export function run(cmd, args, opts = {}) { + const res = spawnSync(cmd, args, {encoding: "utf8", maxBuffer: 64 * 1024 * 1024, ...opts}); + return { + status: res.status ?? (res.error ? 1 : 0), + stdout: res.stdout ?? "", + stderr: res.stderr ?? "", + error: res.error, + }; +} + +export function isGitHub() { + return Boolean(process.env.GITHUB_ACTIONS); +} + +// Fold a block in the GitHub Actions log; plain passthrough elsewhere (Jenkins/CLI stay readable). +export function logGroup(title, body = "") { + if (isGitHub()) { + process.stdout.write(`::group::${title}\n${body}\n::endgroup::\n`); + } else { + process.stdout.write(`----- ${title} -----\n${body ? body + "\n" : ""}`); + } +} + +export function slug(text) { + return String(text).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); +} + +// Parse JSON that may contain // and /* */ comments (audit-ci.jsonc), keeping "http://" intact. +export function parseJsonc(text) { + const stripped = text + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); + return JSON.parse(stripped); +} + +// Advisory ids the given audit-ci config allowlists (the part before any "|path"). [] if no config. +export function allowlistFromConfig(configPath) { + if (!configPath || !existsSync(configPath)) { + return []; + } + try { + const cfg = parseJsonc(readFileSync(configPath, "utf8")); + return (cfg.allowlist || []).map((entry) => String(entry).split("|")[0]); + } catch { + return []; + } +} + +// Normalise an audit-ci JSON report into a deduped advisory list, dropping allowlisted ids. +export function extractAdvisories(report, allow = []) { + const advisories = (report && report.advisories) || {}; + const bySlug = new Map(); + for (const entry of Object.values(advisories)) { + const via = Array.isArray(entry?.via) ? entry.via : []; + for (const item of via) { + if (!item || typeof item !== "object") { + continue; + } + const url = item.url || ""; + const id = (url || `src-${item.source}`).replace(/.*\//, ""); + if (allow.includes(id)) { + continue; + } + if (!bySlug.has(id)) { + bySlug.set(id, {id, pkg: item.name, severity: item.severity, url}); + } + } + } + return [...bySlug.values()]; +} + +// Append one result: /.json (report), /.log, and a manifest.jsonl line. +export function writeResult(resultsDir, record, {reportJson = "", log = ""} = {}) { + const relJson = join(record.kind, `${record.slug}.json`); + const relLog = join(record.kind, `${record.slug}.log`); + mkdirSync(join(resultsDir, record.kind), {recursive: true}); + writeFileSync(join(resultsDir, relJson), reportJson); + writeFileSync(join(resultsDir, relLog), log); + appendFileSync(join(resultsDir, "manifest.jsonl"), JSON.stringify({...record, json: relJson, log: relLog}) + "\n"); +} + +export function readManifest(resultsDir) { + const file = join(resultsDir, "manifest.jsonl"); + if (!existsSync(file)) { + return []; + } + return readFileSync(file, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line)); +} + +export function readReport(resultsDir, record) { + const path = join(resultsDir, record.json); + if (!existsSync(path)) { + return null; + } + const text = readFileSync(path, "utf8").trim(); + if (!text) { + return null; + } + try { + return JSON.parse(text); + } catch { + return null; + } +} + +// Collapse per-column advisory lists into rows keyed by advisory id, each with a presence Set. +// perColumn: [{columnKey, advisories: [{id, pkg, severity, url}]}] +export function buildRows(perColumn) { + const rows = new Map(); + for (const {columnKey, advisories} of perColumn) { + for (const adv of advisories) { + if (!rows.has(adv.id)) { + rows.set(adv.id, {...adv, present: new Set()}); + } + rows.get(adv.id).present.add(columnKey); + } + } + return [...rows.values()]; +} + +// Render a Markdown table. columns: [{key, header}]. priorityKey: rows present there sort first. +export function renderMarkdownTable(columns, rows, {priorityKey} = {}) { + const head = ["Advisory", "Package", "Severity", ...columns.map((c) => c.header)]; + const align = ["---", "---", "---", ...columns.map(() => ":--:")]; + const lines = [`| ${head.join(" | ")} |`, `| ${align.join(" | ")} |`]; + if (rows.length === 0) { + lines.push(`| _none_ |${" |".repeat(head.length - 1)}`); + return lines.join("\n"); + } + const sorted = [...rows].sort((a, b) => { + if (priorityKey) { + const pa = a.present.has(priorityKey) ? 0 : 1; + const pb = b.present.has(priorityKey) ? 0 : 1; + if (pa !== pb) { + return pa - pb; + } + } + const sr = (SEVERITY_RANK[a.severity] ?? 4) - (SEVERITY_RANK[b.severity] ?? 4); + return sr !== 0 ? sr : a.id.localeCompare(b.id); + }); + for (const row of sorted) { + const adv = row.url ? `[${row.id}](${row.url})` : row.id; + // ❌ = advisory present (affected / fails the check), ✅ = not present (clean). + const cells = columns.map((c) => (row.present.has(c.key) ? "❌" : "✅")); + lines.push(`| ${adv} | \`${row.pkg}\` | ${row.severity} | ${cells.join(" | ")} |`); + } + return lines.join("\n"); +} diff --git a/.github/actions/dependency-audit/scripts/merge-results.mjs b/.github/actions/dependency-audit/scripts/merge-results.mjs new file mode 100644 index 00000000000..07aa52384dc --- /dev/null +++ b/.github/actions/dependency-audit/scripts/merge-results.mjs @@ -0,0 +1,32 @@ +// Merge per-job result artifacts into one results dir for the summary job. Each matrix job uploads +// its own results dir as an artifact; actions/download-artifact places them under //. +// This concatenates every manifest.jsonl and overlays the repo/ and npm/ result files into . + +import {appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync} from "node:fs"; +import {join} from "node:path"; +import {isMain, parseFlags} from "./lib.mjs"; + +export function mergeResults({input, output}) { + mkdirSync(output, {recursive: true}); + const subdirs = existsSync(input) ? + readdirSync(input, {withFileTypes: true}).filter((d) => d.isDirectory()).map((d) => d.name) : + []; + for (const sub of subdirs) { + const base = join(input, sub); + const manifest = join(base, "manifest.jsonl"); + if (existsSync(manifest)) { + appendFileSync(join(output, "manifest.jsonl"), readFileSync(manifest, "utf8")); + } + for (const kind of ["repo", "npm"]) { + const src = join(base, kind); + if (existsSync(src)) { + cpSync(src, join(output, kind), {recursive: true}); + } + } + } +} + +if (isMain(import.meta.url)) { + const {flags} = parseFlags(process.argv.slice(2)); + mergeResults({input: flags.input || "dl", output: flags.output || "audit-results"}); +} diff --git a/.github/actions/dependency-audit/self/action.yml b/.github/actions/dependency-audit/self/action.yml new file mode 100644 index 00000000000..8230812137a --- /dev/null +++ b/.github/actions/dependency-audit/self/action.yml @@ -0,0 +1,71 @@ +name: "Dependency security audit (self)" +description: > + Self-audit for a repository that owns a published npm package: audits the checked-out source (three + scopes) AND the latest published version of the package, writes a comparison summary, and fails the + job on findings. Reuses the shared scripts in the sibling scripts/ directory. The per-target action + (../action.yml) is untouched. + +inputs: + node-version: + description: "Node.js version to set up (npm 11+ recommended for stable audit paths)." + default: "24" + package-name: + description: "npm package to audit as published. Empty = derive from /package.json." + default: "" + package-dir: + description: "Directory holding the package.json to derive the name from (relative to repo root)." + default: "." + dist-tag: + description: "Published version range/tag to audit (e.g. latest, next, ^4)." + default: "latest" + +runs: + using: composite + steps: + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ inputs.node-version }} + + # Audit the checked-out source (locked / dev+prod / prod) in the current workspace. + - name: Audit own source + shell: bash + run: > + node "${{ github.action_path }}/../scripts/audit-repo.mjs" + --label "source" + --results "${{ runner.temp }}/audit-results" + + # Resolve the published package name: explicit input, else from /package.json. + - name: Resolve package name + id: pkg + shell: bash + run: | + if [ -n "${{ inputs.package-name }}" ]; then + name="${{ inputs.package-name }}" + else + name="$(node -p "require('./${{ inputs.package-dir }}/package.json').name")" + fi + echo "name=${name}" >> "${GITHUB_OUTPUT}" + echo "Auditing published package: ${name}@${{ inputs.dist-tag }}" + + # Audit the latest published artifact consumers actually install. + - name: Audit published package + shell: bash + run: > + node "${{ github.action_path }}/../scripts/audit-npm.mjs" + --label "${{ steps.pkg.outputs.name }}" + --results "${{ runner.temp }}/audit-results" + "${{ steps.pkg.outputs.name }}@${{ inputs.dist-tag }}" + + # Write the summary BEFORE gating, so it is always rendered even when the gate fails the job. + - name: Write job summary + shell: bash + run: > + node "${{ github.action_path }}/../scripts/format-github.mjs" + --results "${{ runner.temp }}/audit-results" + + - name: Fail on findings + shell: bash + run: > + node "${{ github.action_path }}/../scripts/gate.mjs" + --results "${{ runner.temp }}/audit-results" diff --git a/.github/actions/dependency-audit/targets.json b/.github/actions/dependency-audit/targets.json new file mode 100644 index 00000000000..17a4bb1a104 --- /dev/null +++ b/.github/actions/dependency-audit/targets.json @@ -0,0 +1,14 @@ +{ + "repos": [ + { "id": "repo-mcp-main", "repository": "UI5/mcp-server", "ref": "main" }, + { "id": "repo-linter-main", "repository": "UI5/linter", "ref": "main" }, + { "id": "repo-cli-main", "repository": "UI5/cli", "ref": "main" }, + { "id": "repo-cli-v4", "repository": "UI5/cli", "ref": "v4" } + ], + "npm": [ + { "id": "npm-cli-v4", "label": "CLI v4", "spec": "@ui5/cli@^4.0.0" }, + { "id": "npm-cli-v5", "label": "CLI v5", "spec": "@ui5/cli@next" }, + { "id": "npm-linter", "label": "Linter", "spec": "@ui5/linter@latest" }, + { "id": "npm-mcp", "label": "MCP", "spec": "@ui5/mcp-server@latest" } + ] +} diff --git a/.github/workflows/security-audit-reusable.yml b/.github/workflows/security-audit-reusable.yml new file mode 100644 index 00000000000..17a4c89bed8 --- /dev/null +++ b/.github/workflows/security-audit-reusable.yml @@ -0,0 +1,94 @@ +name: Security Audit (reusable) + +# Reusable engine for the audit workflows. Fans every selected target out into its own matrix job +# (parallel, independent pass/fail), then a summary job merges all per-job artifacts into one job +# summary — and it runs even when audit jobs fail, so the summary is always present. +# +# Audited repositories are cloned read-only by the driver and audited with "--ignore-scripts" (no +# dependency code runs); there is no caching and permissions are empty, so the CodeQL "cache +# poisoning" concern does not apply. + +on: + workflow_call: + inputs: + only: + description: "Comma-separated target ids to audit (empty = every target in targets.json)." + type: string + default: "" + +permissions: {} + +jobs: + # Turn targets.json (filtered by "only") into a matrix of target ids. + prepare: + runs-on: ubuntu-latest + outputs: + ids: ${{ steps.ids.outputs.ids }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Compute target ids + id: ids + shell: bash + env: + ONLY: ${{ inputs.only }} + run: | + ids="$(node -e ' + const t = require("./.github/actions/dependency-audit/targets.json"); + const all = [...t.repos, ...t.npm].map((x) => x.id); + const only = (process.env.ONLY || "").split(",").map((s) => s.trim()).filter(Boolean); + const selected = only.length ? all.filter((id) => only.includes(id)) : all; + console.log(JSON.stringify(selected)); + ')" + echo "ids=${ids}" >> "${GITHUB_OUTPUT}" + echo "Targets: ${ids}" + + # One job per target, in parallel. fail-fast:false so every target is audited even if one fails; + # each job fails independently (gate inside the action) when its target has findings. + audit: + needs: prepare + if: needs.prepare.outputs.ids != '[]' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + id: ${{ fromJSON(needs.prepare.outputs.ids) }} + name: ${{ matrix.id }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Audit ${{ matrix.id }} + uses: ./.github/actions/dependency-audit + with: + id: ${{ matrix.id }} + + # Always runs: merges every per-job artifact into one results dir and writes the job summary. + summary: + needs: audit + if: always() + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + - name: Download all per-target results + uses: actions/download-artifact@v4 # TODO: pin to a commit SHA + with: + path: ${{ runner.temp }}/dl + pattern: audit-* + - name: Merge results and write summary + shell: bash + run: | + node .github/actions/dependency-audit/scripts/merge-results.mjs \ + --input "${{ runner.temp }}/dl" --output "${{ runner.temp }}/audit-results" + node .github/actions/dependency-audit/scripts/format-github.mjs \ + --results "${{ runner.temp }}/audit-results" diff --git a/.github/workflows/security-audit-scheduled.yml b/.github/workflows/security-audit-scheduled.yml new file mode 100644 index 00000000000..a67bde83c54 --- /dev/null +++ b/.github/workflows/security-audit-scheduled.yml @@ -0,0 +1,17 @@ +name: Security Audit (scheduled) + +# Daily audit of every target in .github/actions/dependency-audit/targets.json. Delegates to the +# reusable engine, which runs one parallel job per target and a summary job that is always present. + +on: + schedule: + - cron: "36 4 * * *" # once a day + workflow_dispatch: + +permissions: {} + +jobs: + audit: + uses: ./.github/workflows/security-audit-reusable.yml + with: + only: "" # all targets diff --git a/.github/workflows/security-audit-self.yml b/.github/workflows/security-audit-self.yml new file mode 100644 index 00000000000..d5146e761c4 --- /dev/null +++ b/.github/workflows/security-audit-self.yml @@ -0,0 +1,39 @@ +name: Security Audit (self) + +# Audits this repository's own source AND its latest published npm package in one run. By default it +# targets packages/cli (@ui5/cli); override the inputs to point at another package or dist-tag. + +on: + workflow_dispatch: + inputs: + package-dir: + description: "Directory of the package.json to derive the package name from." + default: packages/cli + package-name: + description: "Override the npm package name (empty = derive from package-dir)." + default: "" + dist-tag: + description: "Published version range/tag to audit (e.g. latest, next, ^4)." + default: latest + +permissions: {} + +jobs: + self-audit: + name: Security Audit (self) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Local ref: the self action lives in this repo and is available after checkout. Another repo + # self-audits by replacing this with the remote action, pinned to a commit SHA: + # uses: d3xter666/ui5-cli-mono/.github/actions/dependency-audit/self@ + - name: Self audit + uses: ./.github/actions/dependency-audit/self + with: + package-dir: ${{ inputs.package-dir }} + package-name: ${{ inputs.package-name }} + dist-tag: ${{ inputs.dist-tag }} diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 5ea17b63696..11acb7538cd 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -1,38 +1,74 @@ -name: Security Audit +name: Security Audit (manual) + +# On-demand audit. Tick the targets you want; the selected ids are passed to the reusable engine, +# which fans them out into parallel jobs with an always-present summary. Keep the ids below in sync +# with .github/actions/dependency-audit/targets.json. on: - schedule: - - cron: "36 4 * * *" # Run once a day workflow_dispatch: + inputs: + repo-cli-main: + description: "Repo: UI5/cli@main" + type: boolean + default: true + repo-cli-v4: + description: "Repo: UI5/cli@v4" + type: boolean + default: true + repo-linter-main: + description: "Repo: UI5/linter@main" + type: boolean + default: true + repo-mcp-main: + description: "Repo: UI5/mcp-server@main" + type: boolean + default: true + npm-cli-v4: + description: "npm: @ui5/cli@^4.0.0" + type: boolean + default: true + npm-cli-v5: + description: "npm: @ui5/cli@next" + type: boolean + default: true + npm-linter: + description: "npm: @ui5/linter@latest" + type: boolean + default: true + npm-mcp: + description: "npm: @ui5/mcp-server@latest" + type: boolean + default: true -# No permissions are required for this workflow permissions: {} jobs: - security-scan: - name: Security Audit + # Collect the ticked target ids into a comma-separated list for the reusable engine. + collect: runs-on: ubuntu-latest - strategy: - fail-fast: false # Continue all jobs even if one fails - matrix: - branch: ["main", "v4"] # List of branches to run the security audit on - + outputs: + only: ${{ steps.pick.outputs.only }} steps: - - name: Checkout '${{ matrix.branch }}' branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ matrix.branch }} - - # Use Node.js 24 to get npm 11 instead of npm 10. The default runner Node.js version is 22, which - # comes with npm 10. This could lead to different installation paths that could conflict with the - # allowlist in audit-ci.jsonc. - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: "24" - - - name: Install dependencies - run: npm ci + - name: Select targets + id: pick + shell: bash + env: + TICKED: >- + ${{ inputs.repo-cli-main && 'repo-cli-main' || '' }} + ${{ inputs.repo-cli-v4 && 'repo-cli-v4' || '' }} + ${{ inputs.repo-linter-main && 'repo-linter-main' || '' }} + ${{ inputs.repo-mcp-main && 'repo-mcp-main' || '' }} + ${{ inputs.npm-cli-v4 && 'npm-cli-v4' || '' }} + ${{ inputs.npm-cli-v5 && 'npm-cli-v5' || '' }} + ${{ inputs.npm-linter && 'npm-linter' || '' }} + ${{ inputs.npm-mcp && 'npm-mcp' || '' }} + run: | + only="$(echo ${TICKED} | tr ' ' ',' | sed 's/,,*/,/g; s/^,//; s/,$//')" + echo "only=${only}" >> "${GITHUB_OUTPUT}" + echo "Selected targets: ${only:-}" - - name: Use audit-ci - run: npm run audit + audit: + needs: collect + uses: ./.github/workflows/security-audit-reusable.yml + with: + only: ${{ needs.collect.outputs.only }} diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 00000000000..cad9eaeb0eb --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,55 @@ +// Jenkins mirror of the scheduled GitHub audit. Reuses the SAME scripts under +// .github/actions/dependency-audit/scripts (no audit logic is reimplemented here): audit every +// target in targets.json, print a plain-text report to the console, archive the raw results, and +// fail the build on findings via gate.mjs. +// +// Requirements on the agent: git and Node.js (>= 22.20 || >= 24, matching this monorepo). If Node +// is provided through the Jenkins NodeJS plugin, wrap the stages in `nodejs('') { ... }`. + +def auditDir = '.github/actions/dependency-audit' +def results = 'audit-results' + +pipeline { + agent any + + triggers { + cron('H 4 * * *') // once a day, like the scheduled GitHub workflow + } + + options { + timestamps() + } + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Audit all targets') { + steps { + sh "node ${auditDir}/scripts/audit-all.mjs --targets ${auditDir}/targets.json --results ${results}" + } + } + + stage('Report') { + steps { + sh "node ${auditDir}/scripts/format-cli.mjs --results ${results}" + } + } + + stage('Gate') { + steps { + // Non-zero exit fails the build; the report above has already been printed. + sh "node ${auditDir}/scripts/gate.mjs --results ${results}" + } + } + } + + post { + always { + archiveArtifacts artifacts: "${results}/**", allowEmptyArchive: true + } + } +}