Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .github/actions/dependency-audit/README.md
Original file line number Diff line number Diff line change
@@ -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
`<kind>/<slug>.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`.
44 changes: 44 additions & 0 deletions .github/actions/dependency-audit/action.yml
Original file line number Diff line number Diff line change
@@ -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"
51 changes: 51 additions & 0 deletions .github/actions/dependency-audit/scripts/audit-all.mjs
Original file line number Diff line number Diff line change
@@ -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
}
58 changes: 58 additions & 0 deletions .github/actions/dependency-audit/scripts/audit-npm.mjs
Original file line number Diff line number Diff line change
@@ -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 <spec>` 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
}
70 changes: 70 additions & 0 deletions .github/actions/dependency-audit/scripts/audit-repo.mjs
Original file line number Diff line number Diff line change
@@ -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
}
43 changes: 43 additions & 0 deletions .github/actions/dependency-audit/scripts/format-cli.mjs
Original file line number Diff line number Diff line change
@@ -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");
}
Loading
Loading