Skip to content
Merged
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
61 changes: 47 additions & 14 deletions lib/deps-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -83,7 +91,9 @@ Options:
--json emit the full result as JSON and nothing else
--json-file=<file> write that JSON to <file> as well, beside the report
--licences=<file> 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

Expand All @@ -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; }
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand Down Expand Up @@ -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));
Expand All @@ -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 ==
Expand Down Expand Up @@ -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}`),
],
});
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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.`);
Expand Down
9 changes: 9 additions & 0 deletions lib/deps-audit.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 15 additions & 5 deletions mk/audit.mk
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -602,7 +605,7 @@ master, which only its image carries, is left out there.
endef

define help_deps-audit
make deps-audit [ARGS="<audit arguments>"] [JSON=<file>]
make deps-audit [ARGS="<audit arguments>"] [JSON=<file>] [NO_ADVISORIES=1]

Checks whether a dependency that ships has a published vulnerability, or a
licence the app cannot ship it under. A release
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)"
Expand Down
Loading
Loading