From 465d0261ac1033703d12153b7a387f50065d64a0 Mon Sep 17 00:00:00 2001 From: ernolf Date: Fri, 30 Oct 2026 00:28:48 +0100 Subject: [PATCH] feat(audit): leave the advisories out of deps-audit with NO_ADVISORIES=1 Signed-off-by: ernolf --- lib/deps-audit.mjs | 61 ++++++++++++++++++++++++++++++++---------- lib/deps-audit.php | 9 +++++++ mk/audit.mk | 20 ++++++++++---- skills/ncmake/SKILL.md | 2 +- 4 files changed, 72 insertions(+), 20 deletions(-) diff --git a/lib/deps-audit.mjs b/lib/deps-audit.mjs index c2686c7..5b1c947 100644 --- a/lib/deps-audit.mjs +++ b/lib/deps-audit.mjs @@ -20,6 +20,12 @@ // registry. Which licences go together is data, licences.json next to this file, // and its examples are tested before an app is judged (--self-test runs only that). // +// --no-advisories, or NO_ADVISORIES=1 through make, leaves the vulnerabilities +// out: no npm audit runs, the composer half keeps only the abandoned packages, and +// neither the report nor the JSON says whether a package has an advisory. That is +// for a run whose output is public, where a finding about a vulnerability would +// name it before a fix is out. +// // The composer half has run before this, in the build image of the PHP floor: // make deps-audit leaves the output of lib/deps-audit.php in // build/deps-audit/composer.json. The npm half runs here, with the package @@ -57,6 +63,8 @@ const defaults = { jsonFile: null, licences: fileURLToPath(new URL('./licences.json', import.meta.url)), selfTest: false, + // make hands NO_ADVISORIES=1 in as DEPS_AUDIT_NO_ADVISORIES. + advisories: process.env.DEPS_AUDIT_NO_ADVISORIES !== '1', }; const SAMPLE = 3; // advisories or packages named per finding without --details const LOOKUPS = 16; // registry lookups of a licence that run at the same time @@ -83,7 +91,9 @@ Options: --json emit the full result as JSON and nothing else --json-file= write that JSON to as well, beside the report --licences= the licence table (default: licences.json beside this file) - --self-test test the licence table against its own examples, and + --no-advisories leave the vulnerabilities out: abandoned packages and + licences only, for a run whose output is public + --self-test test the licence table against its own examples, and nothing else -h, --help this text @@ -106,6 +116,7 @@ function parseArgs(argv) { case '--json': opt.json = true; break; case '--details': opt.details = true; break; case '--self-test': opt.selfTest = true; break; + case '--no-advisories': opt.advisories = false; break; default: if (arg.startsWith('--json-file=')) { opt.jsonFile = arg.slice('--json-file='.length); break; } if (arg.startsWith('--licences=')) { opt.licences = arg.slice('--licences='.length); break; } @@ -356,8 +367,10 @@ function firstFixed(versions, locked, range) { // == The composer half == // What lib/deps-audit.php printed in the floor image. A missing file means the // composer half did not run, which make reports itself; composer.failed holds the -// exit code of a run that did not finish. -function readComposer(root) { +// exit code of a run that did not finish. Without advisories the composer half has +// dropped them already; they are skipped here as well, for a run of the analyser +// on a build/deps-audit an earlier run left. +function readComposer(root, opt) { const lock = readJson(path.join(root, 'composer.lock')); if (!lock) return null; const raw = path.join(root, RAW); @@ -381,7 +394,7 @@ function readComposer(root) { r.unavailable = { why, lines: composerError(out?.error ?? read(path.join(raw, 'composer.err')) ?? '') }; return r; } - for (const [name, list] of Object.entries(out.audit.advisories ?? {})) { + for (const [name, list] of Object.entries(opt.advisories ? out.audit.advisories ?? {} : {})) { const fix = out.fixes?.[name] ?? {}; r.packages.push({ name, @@ -792,7 +805,8 @@ async function lookUpLicences(packages, root, env) { return open.length; } -function readNode(root) { +// npm audit reports no abandoned packages, so without advisories it does not run. +function readNode(root, opt) { const manifest = readJson(path.join(root, 'package.json')); if (!manifest) return null; const { name: pm, lock } = nodeManager(root); @@ -805,6 +819,7 @@ function readNode(root) { const lockText = pm === 'npm' ? null : read(path.join(root, lock)) ?? ''; r.licensed = pm === 'npm' ? shippedNpm(lockJson) : pm === 'pnpm' ? shippedPnpm(lockText) : shippedYarn(lockText, manifest); r.shipped = r.licensed.length; + if (!opt.advisories) return r; const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'deps-audit-')); try { @@ -866,7 +881,7 @@ function readNode(root) { async function check(opt, table) { const root = path.resolve(opt.dir); if (!fs.existsSync(root)) die(`no such directory: ${opt.dir}`); - const sources = [readComposer(root), readNode(root)].filter(Boolean); + const sources = [readComposer(root, opt), readNode(root, opt)].filter(Boolean); const composerJson = readJson(path.join(root, 'composer.json')); const composerMissing = !fs.existsSync(path.join(root, 'composer.lock')) && Object.keys(composerJson?.require ?? {}).some((k) => k !== 'php' && !/^(ext|lib|php)-/.test(k)); @@ -880,7 +895,7 @@ async function check(opt, table) { p.verdict = p.unread ? 'unread' : p.licence === null ? 'missing' : judge(table, p.licence, groups); } } - return { root, app, licences: { app: licences, table: table.file }, composerMissing, sources, findings: [] }; + return { root, app, advisories: opt.advisories, licences: { app: licences, table: table.file }, composerMissing, sources, findings: [] }; } // == Findings == @@ -957,7 +972,9 @@ function findings(r, opt, table) { manager: s.manager === 'yarn-berry' ? 'yarn' : s.manager, lock: s.lock, lines: [ - `The advisories for ${s.lock} could not be read: ${s.unavailable.why}. Nothing in it was checked.`, + r.advisories + ? `The advisories for ${s.lock} could not be read: ${s.unavailable.why}. Nothing in it was checked.` + : `The audit of ${s.lock} could not be read: ${s.unavailable.why}. Its abandoned packages were not checked.`, ...s.unavailable.lines.map((l) => ` ${l}`), ], }); @@ -987,7 +1004,17 @@ function findings(r, opt, table) { } out.push(...licenceFindings(r, table, opt, how)); const audited = r.sources.filter((s) => !s.unavailable && !s.missing); - if (audited.length && !out.some((f) => f.id.startsWith('vulnerable-'))) { + if (!r.advisories) { + // The same finding for every app: without it, a public report would tell the + // vulnerable apps by the ok finding they lack. + out.push({ + level: 'info', + id: 'advisories-unchecked', + lines: [ + 'The advisories were not checked (NO_ADVISORIES=1 or --no-advisories): this report says nothing about vulnerable packages.', + ], + }); + } else if (audited.length && !out.some((f) => f.id.startsWith('vulnerable-'))) { out.push({ level: 'ok', id: 'deps-audit', @@ -1091,17 +1118,23 @@ function render(r) { const what = r.sources.filter((s) => s.lock).map((s) => `${s.lock}${s.shipped !== null ? ` (${plural(s.shipped, 'shipped package', 'shipped packages')})` : ''}`); p(`deps-audit ${r.root}`); p(` ${app ? `${app.id ?? 'no app id'}${app.version ? ` ${app.version}` : ''}` : 'no appinfo/info.xml'}, ` - + `${what.length ? `audited ${what.join(' and ')}` : 'no lock file to audit'}.`); + + `${what.length ? `audited ${what.join(' and ')}` : 'no lock file to audit'}${r.advisories ? '' : ', advisories not checked'}.`); p(); p('FINDINGS'); if (!r.findings.length) p(' Nothing to audit: the app has neither composer.lock nor an npm lock file.'); for (const l of renderFindings(r.findings)) p(l); p(); p(LEGEND); - p('composer audit --locked --no-dev read composer.lock in the build image of the'); - p('PHP floor; the npm lock file was read by the audit of its package manager,'); - p('production dependencies only. A fixed release is the first released version'); - p('above the locked one that the advisory spares. The licences come from the lock'); + if (r.advisories) { + p('composer audit --locked --no-dev read composer.lock in the build image of the'); + p('PHP floor; the npm lock file was read by the audit of its package manager,'); + p('production dependencies only. A fixed release is the first released version'); + p('above the locked one that the advisory spares. The licences come from the lock'); + } else { + p('composer audit --locked --no-dev read composer.lock in the build image of the'); + p('PHP floor for the abandoned packages alone; no advisory was kept, and the npm'); + p('lock file was not audited. The licences come from the lock'); + } p('files, or from the registry where a lock file has none, and are held against'); p('the licence in appinfo/info.xml by the licence table.'); p(`The report is in ${REPORTS}/deps-audit.txt, the JSON in ${REPORTS}/deps-audit.json.`); diff --git a/lib/deps-audit.php b/lib/deps-audit.php index 81d3cd7..d2972d0 100644 --- a/lib/deps-audit.php +++ b/lib/deps-audit.php @@ -16,8 +16,13 @@ // Composer decides everything that depends on the constraints; this only picks // the version to ask about. Plugins stay off: a plugin of the app would run code // of a package the audit is about. +// +// With DEPS_AUDIT_NO_ADVISORIES=1 (make deps-audit NO_ADVISORIES=1) only the +// abandoned packages are kept, and the bit of the exit code that says an advisory +// was found is cleared, so build/deps-audit holds nothing about a vulnerability. $composer = getenv('DEPS_AUDIT_COMPOSER') ?: 'composer'; +$advisories = getenv('DEPS_AUDIT_NO_ADVISORIES') !== '1'; // composer frames an error to the width of the terminal and breaks its words // there; a wide one keeps the message on one line. putenv('COLUMNS=1000'); @@ -86,6 +91,10 @@ function stable(string $v): bool $audit = run(['audit', '--locked', '--no-dev', '--format=json', '--abandoned=report']); $json = json_decode($audit['out'], true); +if (!$advisories && is_array($json)) { + $json = ['abandoned' => $json['abandoned'] ?? []]; + $audit['rc'] &= ~1; +} $result = [ 'rc' => $audit['rc'], 'audit' => is_array($json) ? $json : null, diff --git a/mk/audit.mk b/mk/audit.mk index 732564b..97fd064 100644 --- a/mk/audit.mk +++ b/mk/audit.mk @@ -195,6 +195,9 @@ compat_run = out=$(compat_dir)/$(php_version); php -n -r "echo PHP_VERSION;" > # copied into build/deps-audit beside the output it leaves there. The npm half # runs in the analyser, which carries the app's Node line as build-verify does. deps_dir = build/deps-audit +# NO_ADVISORIES=1, or --no-advisories in ARGS, reaches both halves, so the +# composer half drops the advisories before they are written to build/deps-audit. +deps_no_advisories = $(if $(or $(filter 1,$(NO_ADVISORIES)),$(filter --no-advisories,$(ARGS))),1) # == secrets-audit == # gitleaks runs as the Linux binary of its GitHub release, checked against the @@ -421,17 +424,17 @@ php-compat-run: deps-audit: check-app @$(audit_core) @$(call ncmake_lib_need,$(deps_libs)) - @echo "==> deps-audit$(if $(strip $(ARGS)), $(ARGS)) (RUNTIME=$(RUNTIME))" >&2 + @echo "==> deps-audit$(if $(strip $(ARGS)), $(ARGS))$(if $(filter 1,$(NO_ADVISORIES)), NO_ADVISORIES=1) (RUNTIME=$(RUNTIME))" >&2 @rm -rf $(deps_dir) && mkdir -p $(deps_dir) || exit 1; \ if [ -f composer.lock ]; then \ echo "==> composer audit on PHP $(php_min)" >&2; \ cp "$(ncmake_lib_dir)/deps-audit.php" $(deps_dir)/ && \ $(MAKE) -f $(firstword $(MAKEFILE_LIST)) --no-print-directory deps-audit-composer PHP=min || echo "$$?" >$(deps_dir)/composer.failed; \ fi; \ - $(ncmake_lib_run) '$(call ncmake_lib_guard,deps-audit) DEPS_AUDIT_CMDLINE="$(deps_cmdline)" node $(ncmake_lib_path)/deps-audit.mjs $(ARGS) $(ncmake_json_arg)' + $(ncmake_lib_run) '$(call ncmake_lib_guard,deps-audit) DEPS_AUDIT_CMDLINE="$(deps_cmdline)" DEPS_AUDIT_NO_ADVISORIES="$(deps_no_advisories)" node $(ncmake_lib_path)/deps-audit.mjs $(ARGS) $(ncmake_json_arg)' deps-audit-composer: - @$(php_run) 'php $(deps_dir)/deps-audit.php >$(deps_dir)/composer.json 2>$(deps_dir)/composer.err' + @$(php_run) 'DEPS_AUDIT_NO_ADVISORIES="$(deps_no_advisories)" php $(deps_dir)/deps-audit.php >$(deps_dir)/composer.json 2>$(deps_dir)/composer.err' secrets-audit: check-app @$(audit_core) @@ -602,7 +605,7 @@ master, which only its image carries, is left out there. endef define help_deps-audit -make deps-audit [ARGS=""] [JSON=] +make deps-audit [ARGS=""] [JSON=] [NO_ADVISORIES=1] Checks whether a dependency that ships has a published vulnerability, or a licence the app cannot ship it under. A release @@ -635,6 +638,12 @@ not answer, in which case nothing of that lock file was checked, and a dependency without a lock file to audit. Both vulnerability findings carry "security": true in the JSON. +NO_ADVISORIES=1 (or ARGS=--no-advisories) leaves the vulnerabilities out, for +a run whose output is public: no npm audit runs, composer audit keeps only the +abandoned packages, and the report and the JSON say nothing about an advisory. +In its place is the same [i] advisories-unchecked for every app, and the JSON +has "advisories": false. + The same packages are held against the licence appinfo/info.xml declares, by the licence table lib/licences.json of ncmake, a data file: a bundle is one work, so a copyleft package in it puts its terms on the whole app. composer.lock @@ -664,6 +673,7 @@ ARGS is passed to the analyser: make deps-audit ARGS=--details every advisory and package of a finding make deps-audit ARGS=--json > deps.json machine-readable, nothing else make deps-audit JSON=deps.json the report, and the JSON in deps.json + make deps-audit NO_ADVISORIES=1 abandoned packages and licences only make deps-audit ARGS=--self-test the licence table against its examples make deps-audit ARGS=--help the analyser's own option list @@ -911,7 +921,7 @@ help:: @echo " $(ct)php-compat$(c0) Whether the app runs on the PHP floor and ceiling of its servers" @echo " $(cd)optional $(cv)PHP=all$(cd), see $(cv)make help-php-compat$(c0)" @echo " $(ct)deps-audit$(c0) Whether a dependency that ships has a published vulnerability or a licence the app cannot ship" - @echo " $(cd)optional $(cv)ARGS=\"--details\"$(cd), see $(cv)make help-deps-audit$(c0)" + @echo " $(cd)optional $(cv)ARGS=\"--details\"$(cd), $(cv)NO_ADVISORIES=1$(cd), see $(cv)make help-deps-audit$(c0)" @echo " $(ct)secrets-audit$(c0) Whether a key or a token is in what ships, or in the history" @echo " $(cd)optional $(cv)SCOPE=shipped|history$(cd), $(cv)ARGS=\"--reveal\"$(cd), see $(cv)make help-secrets-audit$(c0)" @echo " $(ct)secrets-ignore$(c0) Add the findings of secrets-audit that are false positives to .gitleaksignore $(cm)[m]$(c0)" diff --git a/skills/ncmake/SKILL.md b/skills/ncmake/SKILL.md index 73a6b25..a007568 100644 --- a/skills/ncmake/SKILL.md +++ b/skills/ncmake/SKILL.md @@ -264,7 +264,7 @@ Six more analysers, in `mk/audit.mk`, about the checkout rather than the bundle. `php-compat` runs the checkout on two PHP versions, each in the build image of that minor (`ghcr.io/ernolf/ncmake-php:`): the floor `make build` resolves against, and the ceiling, the newest PHP minor that the server of `` runs on, read from `lib/versioncheck.php` of its stable branch, or of master for a major without one; a higher `` raises it, but no further than the ceiling of master. `PHP=all` adds every minor in between. On each it runs `composer check-platform-reqs --no-dev` and `php -l` over `appinfo/`, `lib/`, `templates/` and `vendor/`. `[!]` marks a package or the app's `composer.json` that refuses one of those PHP versions, a shipped file that does not parse, a `` below the ceiling (a server on a newer PHP refuses to enable the app), a `` above the ceiling of master (a PHP no server runs yet, on which a server would later enable the app unchecked) and a PHP version that could not be checked; `[i]` a deprecation, an extension the build image lacks, a file of a dev package and a check without `vendor/` or `composer.lock`. Run it after `make build`, so `vendor/` holds what ships; the output of each run stays in `build/php-compat/`. A `` above the ceiling of master runs once more, in `ghcr.io/ernolf/ncmake-php:-next`, and `NEXT=1` runs the newest PHP ahead of master, `ghcr.io/ernolf/ncmake-php:next`, for any app; the findings of that run are `[i]`, named after the exact PHP (`8.6.0RC3`), and the report says whether that PHP has a release yet. Its output stays in `build/php-compat/next`; `RUNTIME=bare` leaves it out. It needs the network and exits 1 on a `[!]` finding. -`deps-audit` asks the package managers' own audits about what ships, dev dependencies left out: `composer audit --locked --no-dev` in the build image of the PHP floor, and `npm audit --omit=dev`, `pnpm audit --prod`, `yarn npm audit --environment production --recursive` or, for Yarn 1, `yarn audit --groups dependencies`, as the lock file decides, on the Node line the app declares, pnpm and yarn through corepack. Each vulnerable package is one `[!]` finding with its advisories, the highest severity among them and the first released version above the locked one that fixes them all; `vulnerable-dependency` when the declared constraints allow that version, with the update command, `vulnerable-dependency-pinned` when a constraint excludes it, naming the constraint and who declares it. composer decides that with `composer why-not`; for npm the ranges come from `package-lock.json` or `yarn.lock`, and `pnpm-lock.yaml` records only those of `package.json`, which the finding says for a package further down. `[i]` marks an abandoned composer package with its replacement (`abandoned-dependency`), an advisory source that did not answer (`audit-unavailable`, nothing of that lock file checked) and dependencies without a lock file (`lock-missing`). Both vulnerability findings carry `"security": true` in the JSON. The same packages are held against the licence `appinfo/info.xml` declares, by the licence table `lib/licences.json`, a data file whose examples are tested before an app is judged (`ARGS=--self-test` runs only that): `licence-incompatible` for a licence that cannot be combined with the app's (`GPL-2.0-only` in an `AGPL-3.0-or-later` app, GPL or AGPL in an app under MIT or Apache-2.0), `licence-non-free` for `CC-BY-NC-*`, `UNLICENSED` and the like, `licence-missing` for a package without a licence, all `[!]`; `licence-unknown` for a licence that is no SPDX expression or one the table gives no verdict on, and `licence-summary`, the shipped packages grouped by licence, both `[i]`. An `OR` expression passes when one of its licences does. `composer.lock` and `package-lock.json` (lockfile version 2 on) record the licences; for `pnpm-lock.yaml` and `yarn.lock` the analyser walks the lock file from the dependencies of `package.json` and reads each licence from the registry. The report goes to `build/reports/deps-audit.txt` and `.json`, the raw output of the audits to `build/deps-audit`, and the target exits 0 with findings: the gates are the lint-deps and lint-licences workflows of ncmake-workflows. It needs the network for the advisories, the released versions and the licences a lock file does not record; it reads `vendor/` and `node_modules/` not at all, so it runs on a fresh clone. +`deps-audit` asks the package managers' own audits about what ships, dev dependencies left out: `composer audit --locked --no-dev` in the build image of the PHP floor, and `npm audit --omit=dev`, `pnpm audit --prod`, `yarn npm audit --environment production --recursive` or, for Yarn 1, `yarn audit --groups dependencies`, as the lock file decides, on the Node line the app declares, pnpm and yarn through corepack. Each vulnerable package is one `[!]` finding with its advisories, the highest severity among them and the first released version above the locked one that fixes them all; `vulnerable-dependency` when the declared constraints allow that version, with the update command, `vulnerable-dependency-pinned` when a constraint excludes it, naming the constraint and who declares it. composer decides that with `composer why-not`; for npm the ranges come from `package-lock.json` or `yarn.lock`, and `pnpm-lock.yaml` records only those of `package.json`, which the finding says for a package further down. `[i]` marks an abandoned composer package with its replacement (`abandoned-dependency`), an advisory source that did not answer (`audit-unavailable`, nothing of that lock file checked) and dependencies without a lock file (`lock-missing`). Both vulnerability findings carry `"security": true` in the JSON. `NO_ADVISORIES=1` (or `ARGS=--no-advisories`) leaves the vulnerabilities out, for a run whose output is public: no npm audit runs, composer audit keeps only the abandoned packages, the report and the JSON (`"advisories": false`) say nothing about an advisory, and every app gets the same `[i]` `advisories-unchecked` instead. The same packages are held against the licence `appinfo/info.xml` declares, by the licence table `lib/licences.json`, a data file whose examples are tested before an app is judged (`ARGS=--self-test` runs only that): `licence-incompatible` for a licence that cannot be combined with the app's (`GPL-2.0-only` in an `AGPL-3.0-or-later` app, GPL or AGPL in an app under MIT or Apache-2.0), `licence-non-free` for `CC-BY-NC-*`, `UNLICENSED` and the like, `licence-missing` for a package without a licence, all `[!]`; `licence-unknown` for a licence that is no SPDX expression or one the table gives no verdict on, and `licence-summary`, the shipped packages grouped by licence, both `[i]`. An `OR` expression passes when one of its licences does. `composer.lock` and `package-lock.json` (lockfile version 2 on) record the licences; for `pnpm-lock.yaml` and `yarn.lock` the analyser walks the lock file from the dependencies of `package.json` and reads each licence from the registry. The report goes to `build/reports/deps-audit.txt` and `.json`, the raw output of the audits to `build/deps-audit`, and the target exits 0 with findings: the gates are the lint-deps and lint-licences workflows of ncmake-workflows. It needs the network for the advisories, the released versions and the licences a lock file does not record; it reads `vendor/` and `node_modules/` not at all, so it runs on a fresh clone. `secrets-audit` runs gitleaks with `--redact` in two scopes: what ships (`SCOPE=shipped`: the files `make stage` takes from the checkout, copied aside, or the tarball `TARBALL=` names) and the history (`SCOPE=history`: every commit, or the git log range `COMMITS=`); `SCOPE=all` is the default. Next to the gitleaks rules it names a file by its name: `*.key`, `id_rsa` and its siblings, a `*.pem` with a private key block, `*.p12`, `*.pfx`, an `.env` file (not `.env.example` and the like), the `auth.json` of composer with a credential and an `.npmrc` with a token. `.ncmake/certificate.crt` is committed on purpose and never reported. `secret-shipped` and `secret-in-history` are `[!]`, carry `"security": true` and a fingerprint, and never show the secret; `ARGS=--reveal` shows the line of each finding on the terminal only, never in the JSON, and is refused where `CI` is set. `.gitleaks.toml` and `.gitleaksignore` of the repository apply. `make secrets-ignore` runs `secrets-audit` with the same options, lists its findings numbered, asks which are false positives (number, range or `all`) and why, and appends each one's fingerprint under a comment with rule, file, commit and reason to `.gitleaksignore`; `REASON=` gives the reason up front, without a terminal it only lists, and `COMMIT=1`/`PR=1` commit it on the branch `ncmake/secrets/ignore`. In an app set up for REUSE a new `.gitleaksignore` starts with the copyright and licence `REUSE.toml` states for itself, so `make reuse` stays green. `make tag` checks what ships before its confirmation and `make dist` the staged tree before it packs it, through the core's `ship_check`, and both stop on `secret-shipped` (`NO_SECRETS_AUDIT=1` goes on). gitleaks is the binary of its GitHub release, pinned by digest in the module, kept in `tools/` of the ncmake cache and fetched by the lint module, so `make dev-init` brings what it needs. It exits 1 on a `[!]` finding and reads no network.