From c0f3b2dc507b622670d4400e6ba98f26a6cd2d5c Mon Sep 17 00:00:00 2001 From: Tommy Brosman Date: Tue, 28 Apr 2026 12:16:23 -0700 Subject: [PATCH 01/13] Initial version of a Claude-created script for checking packages againt the pnpm trust policy. Details: - Treats trust as binary (has-provenance vs no-provenance). pnpm has finer tiers (trusted publisher > provenance > none) but the registry's public abbreviated metadata only reliably exposes provenance. So this script may miss trusted-publisher -> provenance regressions, but won't produce false positives. - Uses publish time from the npm registry to determine "earlier" versions, matching pnpm's documented "based solely on publish date" wording. - Ignores prereleases when evaluating stable versions (matches pnpm v10.24+ behavior). - Respects trustPolicyExclude from pnpm-workspace.yaml. - Doesn't model trustPolicyIgnoreAfter (yet) This script is a little verbose as it manually parses the lockfile (see parseLockfilePackages) instead of calling a pnpm API. It also parses it as text instead of YAML. --- scripts/audit-trust-policy.mjs | 499 +++++++++++++++++++++++++++++++++ 1 file changed, 499 insertions(+) create mode 100644 scripts/audit-trust-policy.mjs diff --git a/scripts/audit-trust-policy.mjs b/scripts/audit-trust-policy.mjs new file mode 100644 index 000000000000..e373d87afb73 --- /dev/null +++ b/scripts/audit-trust-policy.mjs @@ -0,0 +1,499 @@ +#!/usr/bin/env node +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +/** + * audit-trust-policy.mjs + * + * Best-effort approximation of pnpm's `trustPolicy: no-downgrade` rule applied + * to a pnpm-lock.yaml without forcing a re-resolution. + * + * What it does: + * 1. Reads `pnpm-lock.yaml` and extracts every unique `@` from + * the top-level `packages:` section. + * 2. Reads `pnpm-workspace.yaml` and parses `trustPolicyExclude`. + * 3. For each unique package name, fetches abbreviated metadata from the npm + * registry once. + * 4. For each pinned version, compares its trust evidence against every + * earlier-published, non-prerelease version of the same package. Flags any + * pinned version that is weaker than at least one earlier version. + * + * Trust evidence model: + * This script treats trust as a single binary signal: a version either has a + * provenance attestation in `dist.attestations.provenance` or it does not. + * pnpm's actual algorithm has finer tiers (trusted publisher > provenance > + * none); the registry's public abbreviated metadata does not reliably expose + * the trusted-publisher tier without parsing sigstore bundles, so this script + * may miss trusted-publisher -> provenance regressions. It will not produce + * false positives relative to the binary model. + * + * Usage: + * node scripts/audit-trust-policy.mjs [options] + * + * Options: + * --lockfile Path to pnpm-lock.yaml (default: ./pnpm-lock.yaml) + * --workspace Path to pnpm-workspace.yaml for trustPolicyExclude + * (default: ./pnpm-workspace.yaml) + * --concurrency Max concurrent registry requests (default: 8) + * --registry Registry base URL (default: https://registry.npmjs.org) + * --json Emit JSON instead of a text table + * --verbose Print progress and per-package diagnostics + * --help, -h Show this help and exit + * + * Exit codes: + * 0 if no violations were found + * 1 if at least one violation was found + * 2 if the script could not run (missing files, bad arguments, etc.) + */ + +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +// --------------------------------------------------------------------------- +// Argument parsing +// --------------------------------------------------------------------------- + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + switch (a) { + case "--help": + case "-h": + out.help = true; + break; + case "--json": + out.json = true; + break; + case "--verbose": + out.verbose = true; + break; + case "--lockfile": + out.lockfile = argv[++i]; + break; + case "--workspace": + out.workspace = argv[++i]; + break; + case "--concurrency": + out.concurrency = Number(argv[++i]); + break; + case "--registry": + out.registry = argv[++i]; + break; + default: + console.error(`Unknown argument: ${a}`); + process.exit(2); + } + } + return out; +} + +function printHelp() { + const banner = readFileSync(new URL(import.meta.url), "utf-8") + .split("\n") + .filter((line) => line.startsWith(" *") || line.startsWith("/*!")) + .map((line) => line.replace(/^\/\*!?| ?\*\/?| ?\* ?/g, "")) + .join("\n"); + console.log(banner); +} + +const args = parseArgs(process.argv.slice(2)); +if (args.help) { + printHelp(); + process.exit(0); +} + +const lockfilePath = resolve(args.lockfile ?? "pnpm-lock.yaml"); +const workspacePath = resolve(args.workspace ?? "pnpm-workspace.yaml"); +const concurrency = Number.isFinite(args.concurrency) && args.concurrency > 0 ? args.concurrency : 8; +const registry = (args.registry ?? "https://registry.npmjs.org").replace(/\/+$/, ""); +const asJson = args.json === true; +const verbose = args.verbose === true; + +if (!existsSync(lockfilePath)) { + console.error(`Lockfile not found: ${lockfilePath}`); + process.exit(2); +} + +// --------------------------------------------------------------------------- +// Lockfile parsing: extract unique @ from top-level `packages:` +// --------------------------------------------------------------------------- + +/** + * Parses pnpm-lock.yaml and returns the set of unique `@` keys + * from the top-level `packages:` section. Strips peer-dependency suffixes + * (e.g. `react-dom@18.2.0(react@18.2.0)` -> `react-dom@18.2.0`). + */ +function parseLockfilePackages(text) { + const lines = text.split(/\r?\n/); + const result = new Set(); + let inPackages = false; + for (const line of lines) { + // Top-level section header (no leading whitespace, ends with ':'). + if (/^[A-Za-z][A-Za-z0-9_-]*:\s*$/.test(line)) { + inPackages = /^packages:\s*$/.test(line); + continue; + } + if (!inPackages) continue; + + // Direct child of `packages:` is indented exactly 2 spaces. + const m = /^ {2}('?)([^']+?)\1:\s*$/.exec(line); + if (!m) continue; + + let key = m[2]; + // Strip peer-dep suffix that pnpm v10 appends in parens. + const parenIdx = key.indexOf("("); + if (parenIdx >= 0) key = key.slice(0, parenIdx); + + // Split on the LAST '@' so scoped names like `@babel/core@7.26.0` work. + const lastAt = key.lastIndexOf("@"); + if (lastAt <= 0) continue; + const name = key.slice(0, lastAt); + const version = key.slice(lastAt + 1); + if (!name || !version) continue; + + result.add(`${name}@${version}`); + } + return result; +} + +// --------------------------------------------------------------------------- +// pnpm-workspace.yaml: extract trustPolicyExclude list +// --------------------------------------------------------------------------- + +/** + * Returns a Set of strings from the `trustPolicyExclude:` list in + * pnpm-workspace.yaml. Recognizes plain, single-quoted, and double-quoted + * entries. Returns an empty Set if the file is missing or the key is absent. + * + * Note: this only matches exact `name@version` entries against pinned versions. + * It does not evaluate range expressions like `webpack@4.47.0 || 5.102.1` — + * those would require a semver matcher; flag and skip with a warning. + */ +function parseTrustPolicyExclude(text) { + const exclude = new Set(); + if (!text) return exclude; + const lines = text.split(/\r?\n/); + let inExclude = false; + for (const raw of lines) { + if (/^trustPolicyExclude:\s*$/.test(raw)) { + inExclude = true; + continue; + } + if (!inExclude) continue; + + // Comment or blank line: keep going. + if (/^\s*(#.*)?$/.test(raw)) continue; + + // Indented list item: ` - 'pkg@1.2.3'` / ` - "pkg@1.2.3"` / ` - pkg@1.2.3`. + const m = /^\s+-\s*(['"]?)(.+?)\1\s*(#.*)?$/.exec(raw); + if (m) { + const value = m[2].trim(); + if (value.includes("||") || /[<>=^~*]/.test(value)) { + console.warn( + `Warning: trustPolicyExclude entry "${value}" uses a range/version expression; ` + + `this script only matches exact name@version and will skip it.`, + ); + continue; + } + exclude.add(value); + continue; + } + + // Anything else with non-list-item indentation ends the list. + if (/^\S/.test(raw)) { + inExclude = false; + } + } + return exclude; +} + +// --------------------------------------------------------------------------- +// Registry fetch with simple in-memory cache + concurrency limit +// --------------------------------------------------------------------------- + +const packumentCache = new Map(); // name -> Promise + +async function fetchPackument(name) { + if (packumentCache.has(name)) return packumentCache.get(name); + + // Scoped names need `/` percent-encoded for the URL. + const encoded = name.startsWith("@") + ? `@${encodeURIComponent(name.slice(1))}` + : encodeURIComponent(name); + const url = `${registry}/${encoded}`; + + const promise = (async () => { + for (let attempt = 0; attempt < 2; attempt++) { + try { + const res = await fetch(url, { + headers: { + // Full metadata is required: the abbreviated format + // (`application/vnd.npm.install-v1+json`) drops the per-version + // `time` map, which is needed to determine "earlier-published" + // versions for the no-downgrade comparison. + accept: "application/json", + }, + }); + if (res.status === 404) return null; + if (!res.ok) { + if (attempt === 0) continue; + console.warn(`Warning: ${name}: registry returned ${res.status}`); + return null; + } + return await res.json(); + } catch (err) { + if (attempt === 0) continue; + console.warn(`Warning: ${name}: registry fetch failed (${err.message ?? err})`); + return null; + } + } + return null; + })(); + + packumentCache.set(name, promise); + return promise; +} + +/** + * Limits concurrency on async tasks. `iter` is an iterable of factories that + * each return a Promise when called. Returns a Promise that resolves once all + * tasks are done. + */ +async function runWithConcurrency(items, n, worker) { + const queue = [...items]; + let active = 0; + let finished = 0; + const total = queue.length; + return new Promise((resolveAll, rejectAll) => { + const next = () => { + if (queue.length === 0 && active === 0) { + resolveAll(); + return; + } + while (active < n && queue.length > 0) { + const item = queue.shift(); + active++; + worker(item) + .catch(rejectAll) + .finally(() => { + active--; + finished++; + if (verbose && finished % 50 === 0) { + console.error(` progress: ${finished}/${total}`); + } + next(); + }); + } + }; + next(); + }); +} + +// --------------------------------------------------------------------------- +// Trust-evidence scoring +// --------------------------------------------------------------------------- + +/** + * Returns 1 if the version's metadata includes a provenance attestation, 0 + * otherwise. Treats trust as a single binary signal — see file header for the + * caveat about trusted-publisher tiers. + */ +function trustLevel(versionMeta) { + const att = versionMeta?.dist?.attestations; + if (att && typeof att === "object" && att.provenance) return 1; + return 0; +} + +function isPrerelease(version) { + // Cheap: any '-' after the major.minor.patch indicates a prerelease tag. + // Not bulletproof for build metadata-only versions ('+'), but those are rare + // in practice. + return /-/.test(version); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + if (verbose) { + console.error(`Reading lockfile: ${lockfilePath}`); + } + const lockfileText = readFileSync(lockfilePath, "utf-8"); + const pinnedSet = parseLockfilePackages(lockfileText); + if (verbose) { + console.error(`Found ${pinnedSet.size} unique name@version entries.`); + } + + let excludeSet = new Set(); + if (existsSync(workspacePath)) { + excludeSet = parseTrustPolicyExclude(readFileSync(workspacePath, "utf-8")); + if (verbose) { + console.error(`Loaded ${excludeSet.size} trustPolicyExclude entries.`); + } + } else if (verbose) { + console.error(`No pnpm-workspace.yaml at ${workspacePath}; no exclusions applied.`); + } + + // Group pinned versions by name so we fetch each packument once. + const byName = new Map(); + for (const entry of pinnedSet) { + const lastAt = entry.lastIndexOf("@"); + const name = entry.slice(0, lastAt); + const version = entry.slice(lastAt + 1); + if (!byName.has(name)) byName.set(name, new Set()); + byName.get(name).add(version); + } + + if (verbose) { + console.error( + `Querying registry for ${byName.size} unique packages (concurrency=${concurrency})...`, + ); + } + + const violations = []; + const skipped = []; + + await runWithConcurrency(byName.keys(), concurrency, async (name) => { + const packument = await fetchPackument(name); + if (packument === null) { + skipped.push({ name, reason: "registry-unreachable-or-404" }); + return; + } + const versions = packument.versions ?? {}; + const times = packument.time ?? {}; + + // Pre-compute trust level and publish time for every version of this + // package once. Strip non-version keys (`created`, `modified`). + const allVersions = Object.keys(versions); + const trustByVersion = new Map(); + const timeByVersion = new Map(); + for (const v of allVersions) { + trustByVersion.set(v, trustLevel(versions[v])); + const t = times[v]; + if (typeof t === "string") timeByVersion.set(v, Date.parse(t)); + } + + for (const pinnedVersion of byName.get(name)) { + const exclusionKey = `${name}@${pinnedVersion}`; + if (excludeSet.has(exclusionKey)) continue; + + const meta = versions[pinnedVersion]; + if (!meta) { + skipped.push({ name, version: pinnedVersion, reason: "version-not-in-registry" }); + continue; + } + const pinnedTrust = trustByVersion.get(pinnedVersion) ?? 0; + const pinnedTime = timeByVersion.get(pinnedVersion); + if (pinnedTime === undefined) { + skipped.push({ name, version: pinnedVersion, reason: "no-publish-time" }); + continue; + } + + const pinnedIsPrerelease = isPrerelease(pinnedVersion); + + let maxPriorTrust = 0; + let priorExample; + let priorExampleTime; + for (const [otherVersion, otherTime] of timeByVersion) { + if (otherTime >= pinnedTime) continue; + // pnpm v10.24+: prerelease versions are ignored when evaluating trust + // for a non-prerelease install. + if (!pinnedIsPrerelease && isPrerelease(otherVersion)) continue; + const otherTrust = trustByVersion.get(otherVersion) ?? 0; + if (otherTrust > maxPriorTrust) { + maxPriorTrust = otherTrust; + priorExample = otherVersion; + priorExampleTime = otherTime; + } + } + + if (maxPriorTrust > pinnedTrust) { + violations.push({ + name, + version: pinnedVersion, + pinnedTrust, + maxPriorTrust, + priorExample, + pinnedPublishedAt: new Date(pinnedTime).toISOString(), + priorPublishedAt: + priorExampleTime !== undefined + ? new Date(priorExampleTime).toISOString() + : undefined, + }); + } + } + }); + + // Stable sort for readable output. + violations.sort((a, b) => + a.name === b.name ? a.version.localeCompare(b.version) : a.name.localeCompare(b.name), + ); + + if (asJson) { + console.log( + JSON.stringify( + { + lockfile: lockfilePath, + workspace: existsSync(workspacePath) ? workspacePath : null, + totalUniquePackages: pinnedSet.size, + excludedCount: excludeSet.size, + violations, + skipped, + }, + null, + 2, + ), + ); + } else { + console.log(`Audited lockfile: ${lockfilePath}`); + console.log(` Unique pinned versions: ${pinnedSet.size}`); + console.log(` trustPolicyExclude entries: ${excludeSet.size}`); + if (skipped.length > 0) { + console.log(` Skipped (could not evaluate): ${skipped.length}`); + if (verbose) { + for (const s of skipped) { + console.log(` - ${s.name}${s.version ? `@${s.version}` : ""} (${s.reason})`); + } + } + } + if (violations.length === 0) { + console.log("\nNo trust-policy violations detected."); + } else { + console.log(`\n${violations.length} trust-policy violation(s):\n`); + const nameWidth = Math.max( + 4, + ...violations.map((v) => `${v.name}@${v.version}`.length), + ); + console.log( + `${"package@version".padEnd(nameWidth)} pinned prior prior-example`, + ); + console.log("-".repeat(nameWidth + 2 + 6 + 2 + 5 + 2 + 30)); + for (const v of violations) { + console.log( + `${`${v.name}@${v.version}`.padEnd(nameWidth)} ${String(v.pinnedTrust).padStart(6)} ${String(v.maxPriorTrust).padStart(5)} ${v.priorExample ?? ""}`, + ); + } + console.log( + `\nLegend: trust 0 = no provenance; 1 = has provenance attestation.`, + ); + console.log( + `These versions would likely fail pnpm's trustPolicy check on resolution.`, + ); + console.log( + `To suppress an entry, add an exact "name@version" string to`, + ); + console.log(`'trustPolicyExclude' in pnpm-workspace.yaml.`); + } + } + + process.exitCode = violations.length > 0 ? 1 : 0; +} + +main().catch((err) => { + console.error(err.stack ?? err); + process.exit(2); +}); From b1008e70979a74c52112bbbecbe23ebbb9ba4cbc Mon Sep 17 00:00:00 2001 From: Tommy Brosman Date: Fri, 1 May 2026 12:27:53 -0700 Subject: [PATCH 02/13] Trust policy as a flub command. --- .gitignore | 3 + build-tools/packages/build-cli/docs/check.md | 28 ++ .../src/commands/check/trustPolicy.ts | 369 ++++++++++++++++++ 3 files changed, 400 insertions(+) create mode 100644 build-tools/packages/build-cli/src/commands/check/trustPolicy.ts diff --git a/.gitignore b/.gitignore index c2781617762b..8f4cc0a21b09 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,9 @@ docs/resources/_gen/ # PNPM store (when mounting host file system in docker container) .pnpm-store/ +# Scratch dir created by `flub check trustPolicy`. +.trust-audit-temp/ + # TODO: This can be removed once the `flub add changeset` command no longer creates the UPCOMING file. UPCOMING.md diff --git a/build-tools/packages/build-cli/docs/check.md b/build-tools/packages/build-cli/docs/check.md index b28ae1a4d5ef..5bc056d84367 100644 --- a/build-tools/packages/build-cli/docs/check.md +++ b/build-tools/packages/build-cli/docs/check.md @@ -9,6 +9,7 @@ Check commands are used to verify repo state, apply policy, etc. * [`flub check layers`](#flub-check-layers) * [`flub check policy`](#flub-check-policy) * [`flub check prApproval`](#flub-check-prapproval) +* [`flub check trustPolicy`](#flub-check-trustpolicy) ## `flub check buildVersion` @@ -210,3 +211,30 @@ DESCRIPTION ``` _See code: [src/commands/check/prApproval.ts](https://github.com/microsoft/FluidFramework/blob/main/build-tools/packages/build-cli/src/commands/check/prApproval.ts)_ + +## `flub check trustPolicy` + +Audits the repo's lockfile against pnpm's `no-downgrade` trust policy. + +``` +USAGE + $ flub check trustPolicy [-v | --quiet] [--json] [--keep] [--tempDir ] + +FLAGS + --json Emit JSON instead of a text report. + --keep Do not delete the scratch workspace after running. + --tempDir= Scratch workspace directory (default: /.trust-audit-temp). + +LOGGING FLAGS + -v, --verbose Enable verbose logging. + --quiet Disable all logging. + +DESCRIPTION + Audits the repo's lockfile against pnpm's `no-downgrade` trust policy. + + Materializes a scratch workspace under `.trust-audit-temp/` containing one leaf project per pinned dependency, then + runs `pnpm install --trust-policy no-downgrade` and iteratively excludes each violation until pnpm either succeeds or + stops surfacing new violations. Reports the full list of trust-downgrade violations. +``` + +_See code: [src/commands/check/trustPolicy.ts](https://github.com/microsoft/FluidFramework/blob/main/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts)_ diff --git a/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts b/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts new file mode 100644 index 000000000000..ac2bb934aaac --- /dev/null +++ b/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts @@ -0,0 +1,369 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +import { Flags } from "@oclif/core"; +import { parse as parseYaml } from "yaml"; + +import { BaseCommand } from "../../library/commands/base.js"; + +/** + * The error code pnpm emits (both as the top-level `code` and inside `err`) + * when `trustPolicy: no-downgrade` rejects an install. + */ +const TRUST_DOWNGRADE_CODE = "ERR_PNPM_TRUST_DOWNGRADE"; + +interface PnpmRunResult { + code: number | null; + stdout: string; + stderr: string; +} + +/** + * Drives pnpm's own `trustPolicy: no-downgrade` check against every + * `name@version` referenced by the repo's pnpm-lock.yaml, and reports the + * full set of trust-downgrade violations. + * + * Strategy: + * 1. Read the lockfile via `@pnpm/lockfile.fs` and enumerate every key + * under `packages` (and `snapshots`, if present in newer lockfile + * versions). + * 2. Materialize a scratch workspace at `/.trust-audit-temp/` + * with one leaf project per `(name, version)`. Each leaf depends on + * the *real* registry name (no `npm:` aliases) because pnpm 10's + * `--trust-policy-exclude` only matches by registry name. + * 3. Run `pnpm install` against the scratch workspace with NDJSON + * reporting. pnpm aborts at the first violation; we add the + * offender to the exclude list and re-run, repeating until pnpm + * either succeeds or stops surfacing new violations. + */ +export default class CheckTrustPolicyCommand extends BaseCommand< + typeof CheckTrustPolicyCommand +> { + static readonly summary = + "Audits the repo's lockfile against pnpm's `no-downgrade` trust policy."; + + static readonly description = + "Materializes a scratch workspace under `.trust-audit-temp/` containing one leaf project per pinned dependency, then runs `pnpm install --trust-policy no-downgrade` and iteratively excludes each violation until pnpm either succeeds or stops surfacing new violations. Reports the full list of trust-downgrade violations."; + + static readonly flags = { + json: Flags.boolean({ + description: "Emit JSON instead of a text report.", + default: false, + }), + keep: Flags.boolean({ + description: "Do not delete the scratch workspace after running.", + default: false, + }), + tempDir: Flags.directory({ + description: + "Scratch workspace directory (default: /.trust-audit-temp).", + }), + ...BaseCommand.flags, + } as const; + + public async run(): Promise { + const context = await this.getContext(); + const repoRoot = context.root; + const tempDir = path.resolve(this.flags.tempDir ?? path.join(repoRoot, ".trust-audit-temp")); + + const lockfilePath = path.join(repoRoot, "pnpm-lock.yaml"); + this.verbose(`Reading lockfile: ${lockfilePath}`); + if (!existsSync(lockfilePath)) { + this.error(`No pnpm-lock.yaml found in ${repoRoot}`); + } + const lockfile = parseYaml(readFileSync(lockfilePath, "utf-8")) as { + packages?: Record; + snapshots?: Record; + }; + + const pinnedSet = collectPinnedVersions(lockfile); + this.verbose(`Found ${pinnedSet.size} unique name@version entries.`); + + this.verbose(`Materializing scratch workspace at ${tempDir}...`); + const projectCount = writeAuditWorkspace(tempDir, pinnedSet); + this.verbose(`Wrote ${projectCount} leaf projects.`); + + const violationSet = new Set(); + let lastResult: PnpmRunResult | undefined; + let iteration = 0; + const start = Date.now(); + // eslint-disable-next-line no-constant-condition + while (true) { + iteration++; + const excludeFlags: string[] = []; + for (const v of [...violationSet].sort()) { + excludeFlags.push("--trust-policy-exclude", v); + } + const installArgs = [ + "install", + "--recursive", + "--no-frozen-lockfile", + "--trust-policy", + "no-downgrade", + "--reporter", + "ndjson", + ...excludeFlags, + ]; + + this.verbose( + `Iteration ${iteration}: pnpm install (excluded so far: ${violationSet.size})`, + ); + + lastResult = await runPnpm(installArgs, tempDir, this.flags.verbose); + const found = extractTrustViolations(`${lastResult.stdout}\n${lastResult.stderr}`); + + if (lastResult.code === 0) { + this.verbose("pnpm install succeeded; audit complete."); + break; + } + + const newOnes = found.filter((v) => !violationSet.has(v)); + if (newOnes.length === 0) { + this.verbose( + `pnpm exited with code ${lastResult.code} but no new trust-policy violations were detected. Stopping.`, + ); + break; + } + for (const v of newOnes) violationSet.add(v); + for (const v of newOnes) this.verbose(` + ${v}`); + } + + const elapsedSec = Number(((Date.now() - start) / 1000).toFixed(1)); + const violations = [...violationSet].sort(); + const exitCode = lastResult?.code ?? 2; + + if (this.flags.json) { + this.log( + JSON.stringify( + { + tempDir, + exitCode, + iterations: iteration, + elapsedSec, + totalUniqueDependencies: pinnedSet.size, + violations, + }, + undefined, + 2, + ), + ); + } else { + this.log(`Audited via pnpm install in: ${tempDir}`); + this.log(` Final pnpm exit code: ${exitCode}`); + this.log(` Iterations: ${iteration}`); + this.log(` Unique pinned versions: ${pinnedSet.size}`); + this.log(` Elapsed: ${elapsedSec}s`); + if (violations.length === 0) { + this.log("\nNo trust-policy violations detected."); + if (exitCode !== 0 && !this.flags.verbose) { + this.log( + "\nNote: pnpm exited non-zero but no trust-related events were emitted. Re-run with --verbose to see pnpm's full output.", + ); + } + } else { + this.log(`\n${violations.length} trust-policy violation(s):\n`); + for (const v of violations) this.log(` ${v}`); + } + } + + if (!this.flags.keep) { + this.verbose(`Cleaning up temp dir: ${tempDir}`); + rmSync(tempDir, { recursive: true, force: true }); + } else { + this.verbose(`Leaving temp dir in place: ${tempDir}`); + } + + if (violations.length > 0) { + this.exit(1); + } + } +} + +/** + * Returns the set of unique `name@version` strings referenced by the + * lockfile's `packages` (and `snapshots`, when present) sections. + * + * Each key has the form `@[()]`. We strip the + * peer suffix and split on the last `@` so scoped names parse correctly. + * Entries whose "version" looks like a URL/tarball/git ref are skipped — + * pnpm's trust policy only applies to registry resolutions. + * + * The `packages:` section's structure (a top-level map keyed by + * `name@version[(peers)]`) has been stable since pnpm v6 (lockfile + * versions 5.x through 9.x). Newer lockfile versions also expose a + * `snapshots:` section with the same key shape, which we read when + * present. + */ +function collectPinnedVersions(lockfile: { + packages?: Record; + snapshots?: Record; +}): Set { + const result = new Set(); + for (const key of [ + ...Object.keys(lockfile.packages ?? {}), + ...Object.keys(lockfile.snapshots ?? {}), + ]) { + const parenIndex = key.indexOf("("); + const stripped = parenIndex >= 0 ? key.slice(0, parenIndex) : key; + const lastAt = stripped.lastIndexOf("@"); + if (lastAt <= 0) continue; + const name = stripped.slice(0, lastAt); + const version = stripped.slice(lastAt + 1); + if (!name || !version) continue; + if (/[/:]/.test(version)) continue; + result.add(`${name}@${version}`); + } + return result; +} + +/** + * Builds a filesystem-safe slug for use as a project directory name. + */ +function slugify(name: string, version: string): string { + const safeName = name.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + const safeVersion = version.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + return `${safeName}-${safeVersion}`.toLowerCase(); +} + +/** + * Creates `tempDir` containing: + * - `pnpm-workspace.yaml` declaring the leaf glob and `trustPolicy: no-downgrade`. + * - One leaf project per `(name, version)` under `projects//`, + * each pulling in exactly one real (non-aliased) dependency. + * + * Real dependency names matter: pnpm's `--trust-policy-exclude` matches + * against the *registry* name, so aliasing breaks the exclude path for + * any `(name, version)` combination not picked as the canonical one. + * + * We avoid putting `trustPolicyExclude` entries in the YAML because + * pnpm 10's YAML form silently drops double-quoted scalars and rejects + * bare scoped names; CLI flags are easier to control across iterations. + */ +function writeAuditWorkspace(tempDir: string, pinnedSet: Set): number { + if (existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + mkdirSync(tempDir, { recursive: true }); + + writeFileSync(path.resolve(tempDir, ".gitignore"), "*\n"); + writeFileSync( + path.resolve(tempDir, "pnpm-workspace.yaml"), + [ + "# Generated by `flub check trustPolicy` - do not edit.", + "packages:", + " - 'projects/*'", + "trustPolicy: no-downgrade", + "", + ].join("\n"), + ); + + const projectsDir = path.resolve(tempDir, "projects"); + mkdirSync(projectsDir, { recursive: true }); + const usedSlugs = new Map(); + let n = 0; + for (const token of pinnedSet) { + const lastAt = token.lastIndexOf("@"); + const name = token.slice(0, lastAt); + const version = token.slice(lastAt + 1); + let slug = slugify(name, version); + const collision = usedSlugs.get(slug) ?? 0; + usedSlugs.set(slug, collision + 1); + if (collision > 0) slug = `${slug}-${collision}`; + + const projectDir = path.resolve(projectsDir, slug); + mkdirSync(projectDir, { recursive: true }); + writeFileSync( + path.resolve(projectDir, "package.json"), + `${JSON.stringify( + { + name: `audit-${n++}`, + version: "0.0.0", + private: true, + dependencies: { [name]: version }, + }, + undefined, + 2, + )}\n`, + ); + } + return n; +} + +/** + * Runs `pnpm` with the given args from `cwd` and captures stdout, stderr, + * and the exit code. When `streamLive` is true, output is also forwarded + * to this process so progress is visible during long operations. + */ +function runPnpm(args: string[], cwd: string, streamLive: boolean): Promise { + return new Promise((resolveRun, rejectRun) => { + const child = spawn("pnpm", args, { + cwd, + shell: true, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, CI: "1" }, + }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + child.stdout?.on("data", (chunk: Buffer) => { + stdoutChunks.push(chunk); + if (streamLive) process.stdout.write(chunk); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderrChunks.push(chunk); + if (streamLive) process.stderr.write(chunk); + }); + child.on("error", rejectRun); + child.on("close", (code) => { + resolveRun({ + code, + stdout: Buffer.concat(stdoutChunks).toString("utf-8"), + stderr: Buffer.concat(stderrChunks).toString("utf-8"), + }); + }); + }); +} + +/** + * Scans pnpm's NDJSON-formatted output and returns a sorted, de-duplicated + * array of `name@version` strings that triggered the trust-downgrade error. + * + * Each event carries the offending package as a structured field: + * `package: { name, version, bareSpecifier }`. + * That field holds the *real* registry name even when the dependency was + * installed via an `npm:` alias, which is exactly what + * `--trust-policy-exclude` matches against. + */ +function extractTrustViolations(ndjson: string): string[] { + const found = new Set(); + for (const rawLine of ndjson.split(/\r?\n/)) { + if (!rawLine) continue; + // Cheap pre-filter — JSON.parse is comparatively expensive and + // most lines won't be trust-related. + if (!rawLine.includes(TRUST_DOWNGRADE_CODE)) continue; + let event: { + code?: string; + err?: { code?: string }; + package?: { name?: string; version?: string }; + }; + try { + event = JSON.parse(rawLine) as typeof event; + } catch { + continue; + } + if (event.code !== TRUST_DOWNGRADE_CODE && event.err?.code !== TRUST_DOWNGRADE_CODE) { + continue; + } + const name = event.package?.name; + const version = event.package?.version; + if (name && version) { + found.add(`${name}@${version}`); + } + } + return [...found].sort(); +} From 8bf8b0cebebb871ec21996da215bb2ad85dff009 Mon Sep 17 00:00:00 2001 From: Tommy Brosman Date: Fri, 1 May 2026 12:29:35 -0700 Subject: [PATCH 03/13] Removed old script. --- scripts/audit-trust-policy.mjs | 499 --------------------------------- 1 file changed, 499 deletions(-) delete mode 100644 scripts/audit-trust-policy.mjs diff --git a/scripts/audit-trust-policy.mjs b/scripts/audit-trust-policy.mjs deleted file mode 100644 index e373d87afb73..000000000000 --- a/scripts/audit-trust-policy.mjs +++ /dev/null @@ -1,499 +0,0 @@ -#!/usr/bin/env node -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -/** - * audit-trust-policy.mjs - * - * Best-effort approximation of pnpm's `trustPolicy: no-downgrade` rule applied - * to a pnpm-lock.yaml without forcing a re-resolution. - * - * What it does: - * 1. Reads `pnpm-lock.yaml` and extracts every unique `@` from - * the top-level `packages:` section. - * 2. Reads `pnpm-workspace.yaml` and parses `trustPolicyExclude`. - * 3. For each unique package name, fetches abbreviated metadata from the npm - * registry once. - * 4. For each pinned version, compares its trust evidence against every - * earlier-published, non-prerelease version of the same package. Flags any - * pinned version that is weaker than at least one earlier version. - * - * Trust evidence model: - * This script treats trust as a single binary signal: a version either has a - * provenance attestation in `dist.attestations.provenance` or it does not. - * pnpm's actual algorithm has finer tiers (trusted publisher > provenance > - * none); the registry's public abbreviated metadata does not reliably expose - * the trusted-publisher tier without parsing sigstore bundles, so this script - * may miss trusted-publisher -> provenance regressions. It will not produce - * false positives relative to the binary model. - * - * Usage: - * node scripts/audit-trust-policy.mjs [options] - * - * Options: - * --lockfile Path to pnpm-lock.yaml (default: ./pnpm-lock.yaml) - * --workspace Path to pnpm-workspace.yaml for trustPolicyExclude - * (default: ./pnpm-workspace.yaml) - * --concurrency Max concurrent registry requests (default: 8) - * --registry Registry base URL (default: https://registry.npmjs.org) - * --json Emit JSON instead of a text table - * --verbose Print progress and per-package diagnostics - * --help, -h Show this help and exit - * - * Exit codes: - * 0 if no violations were found - * 1 if at least one violation was found - * 2 if the script could not run (missing files, bad arguments, etc.) - */ - -import { existsSync, readFileSync } from "node:fs"; -import { resolve } from "node:path"; - -// --------------------------------------------------------------------------- -// Argument parsing -// --------------------------------------------------------------------------- - -function parseArgs(argv) { - const out = {}; - for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - switch (a) { - case "--help": - case "-h": - out.help = true; - break; - case "--json": - out.json = true; - break; - case "--verbose": - out.verbose = true; - break; - case "--lockfile": - out.lockfile = argv[++i]; - break; - case "--workspace": - out.workspace = argv[++i]; - break; - case "--concurrency": - out.concurrency = Number(argv[++i]); - break; - case "--registry": - out.registry = argv[++i]; - break; - default: - console.error(`Unknown argument: ${a}`); - process.exit(2); - } - } - return out; -} - -function printHelp() { - const banner = readFileSync(new URL(import.meta.url), "utf-8") - .split("\n") - .filter((line) => line.startsWith(" *") || line.startsWith("/*!")) - .map((line) => line.replace(/^\/\*!?| ?\*\/?| ?\* ?/g, "")) - .join("\n"); - console.log(banner); -} - -const args = parseArgs(process.argv.slice(2)); -if (args.help) { - printHelp(); - process.exit(0); -} - -const lockfilePath = resolve(args.lockfile ?? "pnpm-lock.yaml"); -const workspacePath = resolve(args.workspace ?? "pnpm-workspace.yaml"); -const concurrency = Number.isFinite(args.concurrency) && args.concurrency > 0 ? args.concurrency : 8; -const registry = (args.registry ?? "https://registry.npmjs.org").replace(/\/+$/, ""); -const asJson = args.json === true; -const verbose = args.verbose === true; - -if (!existsSync(lockfilePath)) { - console.error(`Lockfile not found: ${lockfilePath}`); - process.exit(2); -} - -// --------------------------------------------------------------------------- -// Lockfile parsing: extract unique @ from top-level `packages:` -// --------------------------------------------------------------------------- - -/** - * Parses pnpm-lock.yaml and returns the set of unique `@` keys - * from the top-level `packages:` section. Strips peer-dependency suffixes - * (e.g. `react-dom@18.2.0(react@18.2.0)` -> `react-dom@18.2.0`). - */ -function parseLockfilePackages(text) { - const lines = text.split(/\r?\n/); - const result = new Set(); - let inPackages = false; - for (const line of lines) { - // Top-level section header (no leading whitespace, ends with ':'). - if (/^[A-Za-z][A-Za-z0-9_-]*:\s*$/.test(line)) { - inPackages = /^packages:\s*$/.test(line); - continue; - } - if (!inPackages) continue; - - // Direct child of `packages:` is indented exactly 2 spaces. - const m = /^ {2}('?)([^']+?)\1:\s*$/.exec(line); - if (!m) continue; - - let key = m[2]; - // Strip peer-dep suffix that pnpm v10 appends in parens. - const parenIdx = key.indexOf("("); - if (parenIdx >= 0) key = key.slice(0, parenIdx); - - // Split on the LAST '@' so scoped names like `@babel/core@7.26.0` work. - const lastAt = key.lastIndexOf("@"); - if (lastAt <= 0) continue; - const name = key.slice(0, lastAt); - const version = key.slice(lastAt + 1); - if (!name || !version) continue; - - result.add(`${name}@${version}`); - } - return result; -} - -// --------------------------------------------------------------------------- -// pnpm-workspace.yaml: extract trustPolicyExclude list -// --------------------------------------------------------------------------- - -/** - * Returns a Set of strings from the `trustPolicyExclude:` list in - * pnpm-workspace.yaml. Recognizes plain, single-quoted, and double-quoted - * entries. Returns an empty Set if the file is missing or the key is absent. - * - * Note: this only matches exact `name@version` entries against pinned versions. - * It does not evaluate range expressions like `webpack@4.47.0 || 5.102.1` — - * those would require a semver matcher; flag and skip with a warning. - */ -function parseTrustPolicyExclude(text) { - const exclude = new Set(); - if (!text) return exclude; - const lines = text.split(/\r?\n/); - let inExclude = false; - for (const raw of lines) { - if (/^trustPolicyExclude:\s*$/.test(raw)) { - inExclude = true; - continue; - } - if (!inExclude) continue; - - // Comment or blank line: keep going. - if (/^\s*(#.*)?$/.test(raw)) continue; - - // Indented list item: ` - 'pkg@1.2.3'` / ` - "pkg@1.2.3"` / ` - pkg@1.2.3`. - const m = /^\s+-\s*(['"]?)(.+?)\1\s*(#.*)?$/.exec(raw); - if (m) { - const value = m[2].trim(); - if (value.includes("||") || /[<>=^~*]/.test(value)) { - console.warn( - `Warning: trustPolicyExclude entry "${value}" uses a range/version expression; ` + - `this script only matches exact name@version and will skip it.`, - ); - continue; - } - exclude.add(value); - continue; - } - - // Anything else with non-list-item indentation ends the list. - if (/^\S/.test(raw)) { - inExclude = false; - } - } - return exclude; -} - -// --------------------------------------------------------------------------- -// Registry fetch with simple in-memory cache + concurrency limit -// --------------------------------------------------------------------------- - -const packumentCache = new Map(); // name -> Promise - -async function fetchPackument(name) { - if (packumentCache.has(name)) return packumentCache.get(name); - - // Scoped names need `/` percent-encoded for the URL. - const encoded = name.startsWith("@") - ? `@${encodeURIComponent(name.slice(1))}` - : encodeURIComponent(name); - const url = `${registry}/${encoded}`; - - const promise = (async () => { - for (let attempt = 0; attempt < 2; attempt++) { - try { - const res = await fetch(url, { - headers: { - // Full metadata is required: the abbreviated format - // (`application/vnd.npm.install-v1+json`) drops the per-version - // `time` map, which is needed to determine "earlier-published" - // versions for the no-downgrade comparison. - accept: "application/json", - }, - }); - if (res.status === 404) return null; - if (!res.ok) { - if (attempt === 0) continue; - console.warn(`Warning: ${name}: registry returned ${res.status}`); - return null; - } - return await res.json(); - } catch (err) { - if (attempt === 0) continue; - console.warn(`Warning: ${name}: registry fetch failed (${err.message ?? err})`); - return null; - } - } - return null; - })(); - - packumentCache.set(name, promise); - return promise; -} - -/** - * Limits concurrency on async tasks. `iter` is an iterable of factories that - * each return a Promise when called. Returns a Promise that resolves once all - * tasks are done. - */ -async function runWithConcurrency(items, n, worker) { - const queue = [...items]; - let active = 0; - let finished = 0; - const total = queue.length; - return new Promise((resolveAll, rejectAll) => { - const next = () => { - if (queue.length === 0 && active === 0) { - resolveAll(); - return; - } - while (active < n && queue.length > 0) { - const item = queue.shift(); - active++; - worker(item) - .catch(rejectAll) - .finally(() => { - active--; - finished++; - if (verbose && finished % 50 === 0) { - console.error(` progress: ${finished}/${total}`); - } - next(); - }); - } - }; - next(); - }); -} - -// --------------------------------------------------------------------------- -// Trust-evidence scoring -// --------------------------------------------------------------------------- - -/** - * Returns 1 if the version's metadata includes a provenance attestation, 0 - * otherwise. Treats trust as a single binary signal — see file header for the - * caveat about trusted-publisher tiers. - */ -function trustLevel(versionMeta) { - const att = versionMeta?.dist?.attestations; - if (att && typeof att === "object" && att.provenance) return 1; - return 0; -} - -function isPrerelease(version) { - // Cheap: any '-' after the major.minor.patch indicates a prerelease tag. - // Not bulletproof for build metadata-only versions ('+'), but those are rare - // in practice. - return /-/.test(version); -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - -async function main() { - if (verbose) { - console.error(`Reading lockfile: ${lockfilePath}`); - } - const lockfileText = readFileSync(lockfilePath, "utf-8"); - const pinnedSet = parseLockfilePackages(lockfileText); - if (verbose) { - console.error(`Found ${pinnedSet.size} unique name@version entries.`); - } - - let excludeSet = new Set(); - if (existsSync(workspacePath)) { - excludeSet = parseTrustPolicyExclude(readFileSync(workspacePath, "utf-8")); - if (verbose) { - console.error(`Loaded ${excludeSet.size} trustPolicyExclude entries.`); - } - } else if (verbose) { - console.error(`No pnpm-workspace.yaml at ${workspacePath}; no exclusions applied.`); - } - - // Group pinned versions by name so we fetch each packument once. - const byName = new Map(); - for (const entry of pinnedSet) { - const lastAt = entry.lastIndexOf("@"); - const name = entry.slice(0, lastAt); - const version = entry.slice(lastAt + 1); - if (!byName.has(name)) byName.set(name, new Set()); - byName.get(name).add(version); - } - - if (verbose) { - console.error( - `Querying registry for ${byName.size} unique packages (concurrency=${concurrency})...`, - ); - } - - const violations = []; - const skipped = []; - - await runWithConcurrency(byName.keys(), concurrency, async (name) => { - const packument = await fetchPackument(name); - if (packument === null) { - skipped.push({ name, reason: "registry-unreachable-or-404" }); - return; - } - const versions = packument.versions ?? {}; - const times = packument.time ?? {}; - - // Pre-compute trust level and publish time for every version of this - // package once. Strip non-version keys (`created`, `modified`). - const allVersions = Object.keys(versions); - const trustByVersion = new Map(); - const timeByVersion = new Map(); - for (const v of allVersions) { - trustByVersion.set(v, trustLevel(versions[v])); - const t = times[v]; - if (typeof t === "string") timeByVersion.set(v, Date.parse(t)); - } - - for (const pinnedVersion of byName.get(name)) { - const exclusionKey = `${name}@${pinnedVersion}`; - if (excludeSet.has(exclusionKey)) continue; - - const meta = versions[pinnedVersion]; - if (!meta) { - skipped.push({ name, version: pinnedVersion, reason: "version-not-in-registry" }); - continue; - } - const pinnedTrust = trustByVersion.get(pinnedVersion) ?? 0; - const pinnedTime = timeByVersion.get(pinnedVersion); - if (pinnedTime === undefined) { - skipped.push({ name, version: pinnedVersion, reason: "no-publish-time" }); - continue; - } - - const pinnedIsPrerelease = isPrerelease(pinnedVersion); - - let maxPriorTrust = 0; - let priorExample; - let priorExampleTime; - for (const [otherVersion, otherTime] of timeByVersion) { - if (otherTime >= pinnedTime) continue; - // pnpm v10.24+: prerelease versions are ignored when evaluating trust - // for a non-prerelease install. - if (!pinnedIsPrerelease && isPrerelease(otherVersion)) continue; - const otherTrust = trustByVersion.get(otherVersion) ?? 0; - if (otherTrust > maxPriorTrust) { - maxPriorTrust = otherTrust; - priorExample = otherVersion; - priorExampleTime = otherTime; - } - } - - if (maxPriorTrust > pinnedTrust) { - violations.push({ - name, - version: pinnedVersion, - pinnedTrust, - maxPriorTrust, - priorExample, - pinnedPublishedAt: new Date(pinnedTime).toISOString(), - priorPublishedAt: - priorExampleTime !== undefined - ? new Date(priorExampleTime).toISOString() - : undefined, - }); - } - } - }); - - // Stable sort for readable output. - violations.sort((a, b) => - a.name === b.name ? a.version.localeCompare(b.version) : a.name.localeCompare(b.name), - ); - - if (asJson) { - console.log( - JSON.stringify( - { - lockfile: lockfilePath, - workspace: existsSync(workspacePath) ? workspacePath : null, - totalUniquePackages: pinnedSet.size, - excludedCount: excludeSet.size, - violations, - skipped, - }, - null, - 2, - ), - ); - } else { - console.log(`Audited lockfile: ${lockfilePath}`); - console.log(` Unique pinned versions: ${pinnedSet.size}`); - console.log(` trustPolicyExclude entries: ${excludeSet.size}`); - if (skipped.length > 0) { - console.log(` Skipped (could not evaluate): ${skipped.length}`); - if (verbose) { - for (const s of skipped) { - console.log(` - ${s.name}${s.version ? `@${s.version}` : ""} (${s.reason})`); - } - } - } - if (violations.length === 0) { - console.log("\nNo trust-policy violations detected."); - } else { - console.log(`\n${violations.length} trust-policy violation(s):\n`); - const nameWidth = Math.max( - 4, - ...violations.map((v) => `${v.name}@${v.version}`.length), - ); - console.log( - `${"package@version".padEnd(nameWidth)} pinned prior prior-example`, - ); - console.log("-".repeat(nameWidth + 2 + 6 + 2 + 5 + 2 + 30)); - for (const v of violations) { - console.log( - `${`${v.name}@${v.version}`.padEnd(nameWidth)} ${String(v.pinnedTrust).padStart(6)} ${String(v.maxPriorTrust).padStart(5)} ${v.priorExample ?? ""}`, - ); - } - console.log( - `\nLegend: trust 0 = no provenance; 1 = has provenance attestation.`, - ); - console.log( - `These versions would likely fail pnpm's trustPolicy check on resolution.`, - ); - console.log( - `To suppress an entry, add an exact "name@version" string to`, - ); - console.log(`'trustPolicyExclude' in pnpm-workspace.yaml.`); - } - } - - process.exitCode = violations.length > 0 ? 1 : 0; -} - -main().catch((err) => { - console.error(err.stack ?? err); - process.exit(2); -}); From 17437900e98a0ee95d5aea54b7aa7e323f3f8b28 Mon Sep 17 00:00:00 2001 From: Tommy Brosman Date: Fri, 1 May 2026 12:42:09 -0700 Subject: [PATCH 04/13] - Typing for pinned versions (no more string parsing in calling functions). - Added brackets around if statement bodies. --- .../src/commands/check/trustPolicy.ts | 78 +++++++++++++------ 1 file changed, 54 insertions(+), 24 deletions(-) diff --git a/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts b/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts index ac2bb934aaac..6e017e556772 100644 --- a/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts +++ b/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts @@ -18,6 +18,11 @@ import { BaseCommand } from "../../library/commands/base.js"; */ const TRUST_DOWNGRADE_CODE = "ERR_PNPM_TRUST_DOWNGRADE"; +interface PinnedVersion { + name: string; + version: string; +} + interface PnpmRunResult { code: number | null; stdout: string; @@ -82,11 +87,11 @@ export default class CheckTrustPolicyCommand extends BaseCommand< snapshots?: Record; }; - const pinnedSet = collectPinnedVersions(lockfile); - this.verbose(`Found ${pinnedSet.size} unique name@version entries.`); + const pinned = collectPinnedVersions(lockfile); + this.verbose(`Found ${pinned.length} unique name@version entries.`); this.verbose(`Materializing scratch workspace at ${tempDir}...`); - const projectCount = writeAuditWorkspace(tempDir, pinnedSet); + const projectCount = writeAuditWorkspace(tempDir, pinned); this.verbose(`Wrote ${projectCount} leaf projects.`); const violationSet = new Set(); @@ -130,8 +135,12 @@ export default class CheckTrustPolicyCommand extends BaseCommand< ); break; } - for (const v of newOnes) violationSet.add(v); - for (const v of newOnes) this.verbose(` + ${v}`); + for (const v of newOnes) { + violationSet.add(v); + } + for (const v of newOnes) { + this.verbose(` + ${v}`); + } } const elapsedSec = Number(((Date.now() - start) / 1000).toFixed(1)); @@ -146,7 +155,7 @@ export default class CheckTrustPolicyCommand extends BaseCommand< exitCode, iterations: iteration, elapsedSec, - totalUniqueDependencies: pinnedSet.size, + totalUniqueDependencies: pinned.length, violations, }, undefined, @@ -157,7 +166,7 @@ export default class CheckTrustPolicyCommand extends BaseCommand< this.log(`Audited via pnpm install in: ${tempDir}`); this.log(` Final pnpm exit code: ${exitCode}`); this.log(` Iterations: ${iteration}`); - this.log(` Unique pinned versions: ${pinnedSet.size}`); + this.log(` Unique pinned versions: ${pinned.length}`); this.log(` Elapsed: ${elapsedSec}s`); if (violations.length === 0) { this.log("\nNo trust-policy violations detected."); @@ -168,7 +177,9 @@ export default class CheckTrustPolicyCommand extends BaseCommand< } } else { this.log(`\n${violations.length} trust-policy violation(s):\n`); - for (const v of violations) this.log(` ${v}`); + for (const v of violations) { + this.log(` ${v}`); + } } } @@ -203,8 +214,9 @@ export default class CheckTrustPolicyCommand extends BaseCommand< function collectPinnedVersions(lockfile: { packages?: Record; snapshots?: Record; -}): Set { - const result = new Set(); +}): PinnedVersion[] { + const seen = new Set(); + const result: PinnedVersion[] = []; for (const key of [ ...Object.keys(lockfile.packages ?? {}), ...Object.keys(lockfile.snapshots ?? {}), @@ -212,12 +224,23 @@ function collectPinnedVersions(lockfile: { const parenIndex = key.indexOf("("); const stripped = parenIndex >= 0 ? key.slice(0, parenIndex) : key; const lastAt = stripped.lastIndexOf("@"); - if (lastAt <= 0) continue; + if (lastAt <= 0) { + continue; + } const name = stripped.slice(0, lastAt); const version = stripped.slice(lastAt + 1); - if (!name || !version) continue; - if (/[/:]/.test(version)) continue; - result.add(`${name}@${version}`); + if (!name || !version) { + continue; + } + if (/[/:]/.test(version)) { + continue; + } + const token = `${name}@${version}`; + if (seen.has(token)) { + continue; + } + seen.add(token); + result.push({ name, version }); } return result; } @@ -245,7 +268,7 @@ function slugify(name: string, version: string): string { * pnpm 10's YAML form silently drops double-quoted scalars and rejects * bare scoped names; CLI flags are easier to control across iterations. */ -function writeAuditWorkspace(tempDir: string, pinnedSet: Set): number { +function writeAuditWorkspace(tempDir: string, pinned: readonly PinnedVersion[]): number { if (existsSync(tempDir)) { rmSync(tempDir, { recursive: true, force: true }); } @@ -267,14 +290,13 @@ function writeAuditWorkspace(tempDir: string, pinnedSet: Set): number { mkdirSync(projectsDir, { recursive: true }); const usedSlugs = new Map(); let n = 0; - for (const token of pinnedSet) { - const lastAt = token.lastIndexOf("@"); - const name = token.slice(0, lastAt); - const version = token.slice(lastAt + 1); + for (const { name, version } of pinned) { let slug = slugify(name, version); const collision = usedSlugs.get(slug) ?? 0; usedSlugs.set(slug, collision + 1); - if (collision > 0) slug = `${slug}-${collision}`; + if (collision > 0) { + slug = `${slug}-${collision}`; + } const projectDir = path.resolve(projectsDir, slug); mkdirSync(projectDir, { recursive: true }); @@ -312,11 +334,15 @@ function runPnpm(args: string[], cwd: string, streamLive: boolean): Promise { stdoutChunks.push(chunk); - if (streamLive) process.stdout.write(chunk); + if (streamLive) { + process.stdout.write(chunk); + } }); child.stderr?.on("data", (chunk: Buffer) => { stderrChunks.push(chunk); - if (streamLive) process.stderr.write(chunk); + if (streamLive) { + process.stderr.write(chunk); + } }); child.on("error", rejectRun); child.on("close", (code) => { @@ -342,10 +368,14 @@ function runPnpm(args: string[], cwd: string, streamLive: boolean): Promise(); for (const rawLine of ndjson.split(/\r?\n/)) { - if (!rawLine) continue; + if (!rawLine) { + continue; + } // Cheap pre-filter — JSON.parse is comparatively expensive and // most lines won't be trust-related. - if (!rawLine.includes(TRUST_DOWNGRADE_CODE)) continue; + if (!rawLine.includes(TRUST_DOWNGRADE_CODE)) { + continue; + } let event: { code?: string; err?: { code?: string }; From d19888d6b86f96b9a3ec18a3549042c769c501b1 Mon Sep 17 00:00:00 2001 From: Tommy Brosman Date: Mon, 4 May 2026 11:38:51 -0700 Subject: [PATCH 05/13] Cleanup. Co-authored-by: Copilot --- .../src/commands/check/trustPolicy.ts | 113 +++++++++++------- 1 file changed, 69 insertions(+), 44 deletions(-) diff --git a/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts b/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts index 6e017e556772..b1216b670dc7 100644 --- a/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts +++ b/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts @@ -87,7 +87,14 @@ export default class CheckTrustPolicyCommand extends BaseCommand< snapshots?: Record; }; - const pinned = collectPinnedVersions(lockfile); + if (!lockfile.packages) { + this.error(`Invalid lockfile: no 'packages' section found in ${lockfilePath}`); + } + + const pinned = collectPinnedVersions(lockfile as { + packages: Record; + snapshots?: Record; + }); this.verbose(`Found ${pinned.length} unique name@version entries.`); this.verbose(`Materializing scratch workspace at ${tempDir}...`); @@ -121,25 +128,23 @@ export default class CheckTrustPolicyCommand extends BaseCommand< ); lastResult = await runPnpm(installArgs, tempDir, this.flags.verbose); - const found = extractTrustViolations(`${lastResult.stdout}\n${lastResult.stderr}`); + const found = extractTrustViolations(lastResult.stdout); if (lastResult.code === 0) { this.verbose("pnpm install succeeded; audit complete."); break; } - const newOnes = found.filter((v) => !violationSet.has(v)); - if (newOnes.length === 0) { + const newViolations = found.filter((v) => !violationSet.has(v)); + if (newViolations.length === 0) { this.verbose( `pnpm exited with code ${lastResult.code} but no new trust-policy violations were detected. Stopping.`, ); break; } - for (const v of newOnes) { - violationSet.add(v); - } - for (const v of newOnes) { - this.verbose(` + ${v}`); + for (const violation of newViolations) { + violationSet.add(violation); + this.verbose(` + ${violation}`); } } @@ -177,8 +182,8 @@ export default class CheckTrustPolicyCommand extends BaseCommand< } } else { this.log(`\n${violations.length} trust-policy violation(s):\n`); - for (const v of violations) { - this.log(` ${v}`); + for (const violation of violations) { + this.log(` ${violation}`); } } } @@ -198,27 +203,26 @@ export default class CheckTrustPolicyCommand extends BaseCommand< /** * Returns the set of unique `name@version` strings referenced by the - * lockfile's `packages` (and `snapshots`, when present) sections. + * lockfile's `packages` and `snapshots` sections. * * Each key has the form `@[()]`. We strip the * peer suffix and split on the last `@` so scoped names parse correctly. * Entries whose "version" looks like a URL/tarball/git ref are skipped — * pnpm's trust policy only applies to registry resolutions. * - * The `packages:` section's structure (a top-level map keyed by - * `name@version[(peers)]`) has been stable since pnpm v6 (lockfile - * versions 5.x through 9.x). Newer lockfile versions also expose a - * `snapshots:` section with the same key shape, which we read when - * present. + * Both sections use the same key format but may be present in different + * pnpm lockfile versions. To ensure a complete audit, we must read both + * to capture all pinned dependencies, since versions recorded only in + * `snapshots` would otherwise be skipped from the trust-policy check. */ function collectPinnedVersions(lockfile: { - packages?: Record; + packages: Record; snapshots?: Record; }): PinnedVersion[] { const seen = new Set(); const result: PinnedVersion[] = []; for (const key of [ - ...Object.keys(lockfile.packages ?? {}), + ...Object.keys(lockfile.packages), ...Object.keys(lockfile.snapshots ?? {}), ]) { const parenIndex = key.indexOf("("); @@ -236,6 +240,9 @@ function collectPinnedVersions(lockfile: { continue; } const token = `${name}@${version}`; + // Deduplicate: the same package@version can appear in both `packages` + // and `snapshots` sections, so we track what we've already collected + // to avoid auditing the same version multiple times. if (seen.has(token)) { continue; } @@ -247,6 +254,10 @@ function collectPinnedVersions(lockfile: { /** * Builds a filesystem-safe slug for use as a project directory name. + * + * @returns A lowercase string with runs of non-alphanumeric characters replaced + * by `-`, with leading/trailing hyphens trimmed. For example, + * `\@fluidframework/tree` + `1.0.0` → `fluidframework-tree-1-0-0`. */ function slugify(name: string, version: string): string { const safeName = name.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, ""); @@ -267,6 +278,8 @@ function slugify(name: string, version: string): string { * We avoid putting `trustPolicyExclude` entries in the YAML because * pnpm 10's YAML form silently drops double-quoted scalars and rejects * bare scoped names; CLI flags are easier to control across iterations. + * + * @returns The number of leaf projects written (one per entry in `pinned`). */ function writeAuditWorkspace(tempDir: string, pinned: readonly PinnedVersion[]): number { if (existsSync(tempDir)) { @@ -289,7 +302,7 @@ function writeAuditWorkspace(tempDir: string, pinned: readonly PinnedVersion[]): const projectsDir = path.resolve(tempDir, "projects"); mkdirSync(projectsDir, { recursive: true }); const usedSlugs = new Map(); - let n = 0; + let packageEntryCount = 0; for (const { name, version } of pinned) { let slug = slugify(name, version); const collision = usedSlugs.get(slug) ?? 0; @@ -304,7 +317,7 @@ function writeAuditWorkspace(tempDir: string, pinned: readonly PinnedVersion[]): path.resolve(projectDir, "package.json"), `${JSON.stringify( { - name: `audit-${n++}`, + name: `audit-${packageEntryCount}`, version: "0.0.0", private: true, dependencies: { [name]: version }, @@ -313,8 +326,9 @@ function writeAuditWorkspace(tempDir: string, pinned: readonly PinnedVersion[]): 2, )}\n`, ); + packageEntryCount++; } - return n; + return packageEntryCount; } /** @@ -356,44 +370,55 @@ function runPnpm(args: string[], cwd: string, streamLive: boolean): Promise(); - for (const rawLine of ndjson.split(/\r?\n/)) { - if (!rawLine) { - continue; - } - // Cheap pre-filter — JSON.parse is comparatively expensive and - // most lines won't be trust-related. - if (!rawLine.includes(TRUST_DOWNGRADE_CODE)) { + + for (const rawLine of ndjsonStdout.split(/\r?\n/)) { + // Skip blank lines and any line that can't possibly be a trust-downgrade + // event. The substring check is a cheap pre-filter so we only pay the + // JSON.parse cost on lines that are actually relevant. + if (rawLine === "" || !rawLine.includes(TRUST_DOWNGRADE_CODE)) { continue; } let event: { code?: string; - err?: { code?: string }; package?: { name?: string; version?: string }; }; try { event = JSON.parse(rawLine) as typeof event; - } catch { - continue; + } catch (err) { + throw new Error( + `Found stdout line containing "${TRUST_DOWNGRADE_CODE}" but failed to parse as JSON: ${rawLine}\n${err instanceof Error ? err.message : String(err)}`, + ); } - if (event.code !== TRUST_DOWNGRADE_CODE && event.err?.code !== TRUST_DOWNGRADE_CODE) { - continue; + if (event.code !== TRUST_DOWNGRADE_CODE) { + throw new Error( + `Found stdout line containing "${TRUST_DOWNGRADE_CODE}" but event.code did not match: ${rawLine}`, + ); } const name = event.package?.name; const version = event.package?.version; - if (name && version) { - found.add(`${name}@${version}`); + if (name === undefined || version === undefined) { + throw new Error( + `pnpm emitted a "${TRUST_DOWNGRADE_CODE}" event without a package name and version: ${rawLine}`, + ); } + found.add(`${name}@${version}`); } + return [...found].sort(); } From f8e184758c7854c48874fa8d9dec9d301d39d70b Mon Sep 17 00:00:00 2001 From: Tommy Brosman Date: Mon, 4 May 2026 11:46:35 -0700 Subject: [PATCH 06/13] Lint. Co-authored-by: Copilot --- .../src/commands/check/trustPolicy.ts | 51 ++++++++++--------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts b/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts index b1216b670dc7..4cec3c083ec6 100644 --- a/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts +++ b/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts @@ -35,17 +35,18 @@ interface PnpmRunResult { * full set of trust-downgrade violations. * * Strategy: - * 1. Read the lockfile via `@pnpm/lockfile.fs` and enumerate every key - * under `packages` (and `snapshots`, if present in newer lockfile - * versions). - * 2. Materialize a scratch workspace at `/.trust-audit-temp/` - * with one leaf project per `(name, version)`. Each leaf depends on - * the *real* registry name (no `npm:` aliases) because pnpm 10's - * `--trust-policy-exclude` only matches by registry name. - * 3. Run `pnpm install` against the scratch workspace with NDJSON - * reporting. pnpm aborts at the first violation; we add the - * offender to the exclude list and re-run, repeating until pnpm - * either succeeds or stops surfacing new violations. + * + * 1. Read the lockfile via `@pnpm/lockfile.fs` and enumerate every key + * under `packages` (and `snapshots`, if present in newer lockfile + * versions). + * 2. Materialize a scratch workspace at `/.trust-audit-temp/` + * with one leaf project per `(name, version)`. Each leaf depends on + * the *real* registry name (no `npm:` aliases) because pnpm 10's + * `--trust-policy-exclude` only matches by registry name. + * 3. Run `pnpm install` against the scratch workspace with NDJSON + * reporting. pnpm aborts at the first violation; we add the + * offender to the exclude list and re-run, repeating until pnpm + * either succeeds or stops surfacing new violations. */ export default class CheckTrustPolicyCommand extends BaseCommand< typeof CheckTrustPolicyCommand @@ -66,8 +67,7 @@ export default class CheckTrustPolicyCommand extends BaseCommand< default: false, }), tempDir: Flags.directory({ - description: - "Scratch workspace directory (default: /.trust-audit-temp).", + description: "Scratch workspace directory (default: /.trust-audit-temp).", }), ...BaseCommand.flags, } as const; @@ -75,7 +75,9 @@ export default class CheckTrustPolicyCommand extends BaseCommand< public async run(): Promise { const context = await this.getContext(); const repoRoot = context.root; - const tempDir = path.resolve(this.flags.tempDir ?? path.join(repoRoot, ".trust-audit-temp")); + const tempDir = path.resolve( + this.flags.tempDir ?? path.join(repoRoot, ".trust-audit-temp"), + ); const lockfilePath = path.join(repoRoot, "pnpm-lock.yaml"); this.verbose(`Reading lockfile: ${lockfilePath}`); @@ -91,10 +93,12 @@ export default class CheckTrustPolicyCommand extends BaseCommand< this.error(`Invalid lockfile: no 'packages' section found in ${lockfilePath}`); } - const pinned = collectPinnedVersions(lockfile as { - packages: Record; - snapshots?: Record; - }); + const pinned = collectPinnedVersions( + lockfile as { + packages: Record; + snapshots?: Record; + }, + ); this.verbose(`Found ${pinned.length} unique name@version entries.`); this.verbose(`Materializing scratch workspace at ${tempDir}...`); @@ -260,16 +264,17 @@ function collectPinnedVersions(lockfile: { * `\@fluidframework/tree` + `1.0.0` → `fluidframework-tree-1-0-0`. */ function slugify(name: string, version: string): string { - const safeName = name.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, ""); - const safeVersion = version.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + const safeName = name.replace(/[^\dA-Za-z]+/g, "-").replace(/^-+|-+$/g, ""); + const safeVersion = version.replace(/[^\dA-Za-z]+/g, "-").replace(/^-+|-+$/g, ""); return `${safeName}-${safeVersion}`.toLowerCase(); } /** * Creates `tempDir` containing: - * - `pnpm-workspace.yaml` declaring the leaf glob and `trustPolicy: no-downgrade`. - * - One leaf project per `(name, version)` under `projects//`, - * each pulling in exactly one real (non-aliased) dependency. + * + * - `pnpm-workspace.yaml` declaring the leaf glob and `trustPolicy: no-downgrade`. + * - One leaf project per `(name, version)` under `projects//`, + * each pulling in exactly one real (non-aliased) dependency. * * Real dependency names matter: pnpm's `--trust-policy-exclude` matches * against the *registry* name, so aliasing breaks the exclude path for From 261b157bae3b0c28bb4d7fb1edac04ee82078b93 Mon Sep 17 00:00:00 2001 From: Tommy Brosman Date: Mon, 4 May 2026 13:38:54 -0700 Subject: [PATCH 07/13] - No more parsing YAML. - --lockfile-only when installing (better performance). - Linked bug in pnpm repo. --- .../src/commands/check/trustPolicy.ts | 129 ++++++++++-------- 1 file changed, 71 insertions(+), 58 deletions(-) diff --git a/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts b/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts index 4cec3c083ec6..40e77cac71c9 100644 --- a/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts +++ b/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts @@ -4,11 +4,10 @@ */ import { spawn } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { Flags } from "@oclif/core"; -import { parse as parseYaml } from "yaml"; import { BaseCommand } from "../../library/commands/base.js"; @@ -36,9 +35,10 @@ interface PnpmRunResult { * * Strategy: * - * 1. Read the lockfile via `@pnpm/lockfile.fs` and enumerate every key - * under `packages` (and `snapshots`, if present in newer lockfile - * versions). + * 1. Run `pnpm list -r --json --depth Infinity` at the repo root and + * walk every `(name, version)` pair reachable through any project's + * `dependencies` / `devDependencies` / `peerDependencies` / + * `optionalDependencies` tree. * 2. Materialize a scratch workspace at `/.trust-audit-temp/` * with one leaf project per `(name, version)`. Each leaf depends on * the *real* registry name (no `npm:` aliases) because pnpm 10's @@ -47,6 +47,9 @@ interface PnpmRunResult { * reporting. pnpm aborts at the first violation; we add the * offender to the exclude list and re-run, repeating until pnpm * either succeeds or stops surfacing new violations. + * + * See https://github.com/pnpm/pnpm/issues/10622 for the bug that motivated + * this command. */ export default class CheckTrustPolicyCommand extends BaseCommand< typeof CheckTrustPolicyCommand @@ -80,25 +83,23 @@ export default class CheckTrustPolicyCommand extends BaseCommand< ); const lockfilePath = path.join(repoRoot, "pnpm-lock.yaml"); - this.verbose(`Reading lockfile: ${lockfilePath}`); if (!existsSync(lockfilePath)) { this.error(`No pnpm-lock.yaml found in ${repoRoot}`); } - const lockfile = parseYaml(readFileSync(lockfilePath, "utf-8")) as { - packages?: Record; - snapshots?: Record; - }; - if (!lockfile.packages) { - this.error(`Invalid lockfile: no 'packages' section found in ${lockfilePath}`); + this.verbose("Enumerating installed dependencies via `pnpm list -r --json`..."); + const listResult = await runPnpm( + ["list", "--recursive", "--json", "--depth", "Infinity"], + repoRoot, + false, + ); + if (listResult.code !== 0) { + this.error( + `pnpm list exited with code ${listResult.code}. stderr:\n${listResult.stderr}`, + ); } - const pinned = collectPinnedVersions( - lockfile as { - packages: Record; - snapshots?: Record; - }, - ); + const pinned = collectPinnedVersions(listResult.stdout); this.verbose(`Found ${pinned.length} unique name@version entries.`); this.verbose(`Materializing scratch workspace at ${tempDir}...`); @@ -120,6 +121,7 @@ export default class CheckTrustPolicyCommand extends BaseCommand< "install", "--recursive", "--no-frozen-lockfile", + "--lockfile-only", "--trust-policy", "no-downgrade", "--reporter", @@ -206,53 +208,64 @@ export default class CheckTrustPolicyCommand extends BaseCommand< } /** - * Returns the set of unique `name@version` strings referenced by the - * lockfile's `packages` and `snapshots` sections. + * Walks the JSON output of `pnpm list -r --json --depth Infinity` and returns + * the set of unique `name@version` strings for every dependency resolved + * from a registry. * - * Each key has the form `@[()]`. We strip the - * peer suffix and split on the last `@` so scoped names parse correctly. - * Entries whose "version" looks like a URL/tarball/git ref are skipped — - * pnpm's trust policy only applies to registry resolutions. - * - * Both sections use the same key format but may be present in different - * pnpm lockfile versions. To ensure a complete audit, we must read both - * to capture all pinned dependencies, since versions recorded only in - * `snapshots` would otherwise be skipped from the trust-policy check. + * pnpm's output is an array of workspace project nodes. Each node and each + * nested dependency entry can carry `dependencies`, `devDependencies`, + * `peerDependencies`, and `optionalDependencies` maps. Each map value has + * a `from` field (the *real* registry name, even when installed via an + * `npm:` alias) and a `version` field. Registry-resolved entries also + * carry a `resolved` URL — workspace, link, file, and git installs do not, + * so requiring `resolved` is what filters the audit down to the dependencies + * pnpm's trust policy actually applies to. */ -function collectPinnedVersions(lockfile: { - packages: Record; - snapshots?: Record; -}): PinnedVersion[] { +function collectPinnedVersions(listJsonStdout: string): PinnedVersion[] { + interface DependencyEntry { + from?: string; + version?: string; + resolved?: string; + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; + } + + const projects = JSON.parse(listJsonStdout) as DependencyEntry[]; const seen = new Set(); const result: PinnedVersion[] = []; - for (const key of [ - ...Object.keys(lockfile.packages), - ...Object.keys(lockfile.snapshots ?? {}), - ]) { - const parenIndex = key.indexOf("("); - const stripped = parenIndex >= 0 ? key.slice(0, parenIndex) : key; - const lastAt = stripped.lastIndexOf("@"); - if (lastAt <= 0) { - continue; - } - const name = stripped.slice(0, lastAt); - const version = stripped.slice(lastAt + 1); - if (!name || !version) { - continue; - } - if (/[/:]/.test(version)) { - continue; + + function visit(entry: DependencyEntry): void { + if ( + entry.from !== undefined && + entry.version !== undefined && + entry.resolved !== undefined && + /^https?:\/\//.test(entry.resolved) + ) { + const token = `${entry.from}@${entry.version}`; + if (!seen.has(token)) { + seen.add(token); + result.push({ name: entry.from, version: entry.version }); + } } - const token = `${name}@${version}`; - // Deduplicate: the same package@version can appear in both `packages` - // and `snapshots` sections, so we track what we've already collected - // to avoid auditing the same version multiple times. - if (seen.has(token)) { - continue; + for (const map of [ + entry.dependencies, + entry.devDependencies, + entry.peerDependencies, + entry.optionalDependencies, + ]) { + if (map === undefined) continue; + for (const child of Object.values(map)) { + visit(child); + } } - seen.add(token); - result.push({ name, version }); } + + for (const project of projects) { + visit(project); + } + return result; } From 94cae0976a0b8f151cb6c4d411d55cf68f02e3d9 Mon Sep 17 00:00:00 2001 From: Tommy Brosman Date: Mon, 4 May 2026 17:18:28 -0700 Subject: [PATCH 08/13] Fix: now handles multiple excluded versions. Co-authored-by: Copilot --- .../src/commands/check/trustPolicy.ts | 164 ++++++++++++------ 1 file changed, 113 insertions(+), 51 deletions(-) diff --git a/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts b/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts index 40e77cac71c9..c800140dbfed 100644 --- a/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts +++ b/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts @@ -42,7 +42,8 @@ interface PnpmRunResult { * 2. Materialize a scratch workspace at `/.trust-audit-temp/` * with one leaf project per `(name, version)`. Each leaf depends on * the *real* registry name (no `npm:` aliases) because pnpm 10's - * `--trust-policy-exclude` only matches by registry name. + * `--trust-policy-exclude` matches the registry name (and optional + * exact version), not the alias. * 3. Run `pnpm install` against the scratch workspace with NDJSON * reporting. pnpm aborts at the first violation; we add the * offender to the exclude list and re-run, repeating until pnpm @@ -109,48 +110,101 @@ export default class CheckTrustPolicyCommand extends BaseCommand< const violationSet = new Set(); let lastResult: PnpmRunResult | undefined; let iteration = 0; + let auditIncomplete = false; const start = Date.now(); - // eslint-disable-next-line no-constant-condition - while (true) { - iteration++; - const excludeFlags: string[] = []; - for (const v of [...violationSet].sort()) { - excludeFlags.push("--trust-policy-exclude", v); - } - const installArgs = [ - "install", - "--recursive", - "--no-frozen-lockfile", - "--lockfile-only", - "--trust-policy", - "no-downgrade", - "--reporter", - "ndjson", - ...excludeFlags, - ]; - - this.verbose( - `Iteration ${iteration}: pnpm install (excluded so far: ${violationSet.size})`, - ); - - lastResult = await runPnpm(installArgs, tempDir, this.flags.verbose); - const found = extractTrustViolations(lastResult.stdout); - - if (lastResult.code === 0) { - this.verbose("pnpm install succeeded; audit complete."); - break; - } + try { + // eslint-disable-next-line no-constant-condition + while (true) { + const excludeFlags: string[] = []; + // Group excluded versions by package name and pass each name + // once with versions joined by `||` (pnpm's "exact-versions + // union" syntax). For example: + // --trust-policy-exclude semver@5.7.2||6.3.1 + // + // This is required because pnpm's `evaluateVersionPolicy` only + // consults the FIRST rule matching a given package name (see + // `parseVersionPolicyRule`/`evaluateVersionPolicy` in pnpm.cjs). + // Passing multiple `--trust-policy-exclude semver@` flags + // silently drops all but the first. + // + // The `||` union form is documented under `trustPolicyExclude` + // (https://pnpm.io/settings#trustpolicyexclude), where the + // example `'webpack@4.47.0 || 5.102.1'` excludes both versions + // of webpack. + const versionsByName = new Map(); + for (const violation of violationSet) { + const atIndex = violation.lastIndexOf("@"); + const name = violation.slice(0, atIndex); + const version = violation.slice(atIndex + 1); + const existing = versionsByName.get(name); + if (existing === undefined) { + versionsByName.set(name, [version]); + } else { + existing.push(version); + } + } + for (const name of [...versionsByName.keys()].sort()) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const versions = versionsByName.get(name)!.sort(); + // Wrap in double quotes so that on Windows (where `runPnpm` + // uses `shell: true`) cmd.exe doesn't interpret `||` as a + // command separator before pnpm parses the arg. + excludeFlags.push( + "--trust-policy-exclude", + `"${name}@${versions.join("||")}"`, + ); + } + const installArgs = [ + "install", + "--recursive", + "--no-frozen-lockfile", + "--lockfile-only", + "--trust-policy", + "no-downgrade", + "--reporter", + "ndjson", + ...excludeFlags, + ]; - const newViolations = found.filter((v) => !violationSet.has(v)); - if (newViolations.length === 0) { this.verbose( - `pnpm exited with code ${lastResult.code} but no new trust-policy violations were detected. Stopping.`, + `Iteration ${iteration}: pnpm install (excluded so far: ${violationSet.size})`, ); - break; + iteration++; + + lastResult = await runPnpm(installArgs, tempDir, this.flags.verbose); + const found = extractTrustViolations(lastResult.stdout); + + if (lastResult.code === 0) { + this.verbose("pnpm install succeeded; audit complete."); + break; + } + + const newViolations = found.filter((violation) => !violationSet.has(violation)); + if (newViolations.length === 0) { + // pnpm exited non-zero without surfacing a new trust-policy + // violation. That means something else went wrong (network, + // auth, a pnpm behavior change, etc.) and the audit is no + // longer trustworthy: we have no way to know whether more + // violations exist beyond the ones already collected. Mark + // the audit incomplete so we exit non-zero below; otherwise + // CI would treat a failed audit as passing. + auditIncomplete = true; + this.warning( + `pnpm exited with code ${lastResult.code} but no new trust-policy violations were detected. Audit is incomplete; re-run with --verbose to see pnpm's full output.`, + ); + break; + } + for (const violation of newViolations) { + violationSet.add(violation); + this.verbose(` + ${violation}`); + } } - for (const violation of newViolations) { - violationSet.add(violation); - this.verbose(` + ${violation}`); + } finally { + if (this.flags.keep) { + this.verbose(`Leaving temp dir in place: ${tempDir}`); + } else { + this.verbose(`Cleaning up temp dir: ${tempDir}`); + rmSync(tempDir, { recursive: true, force: true }); } } @@ -167,6 +221,7 @@ export default class CheckTrustPolicyCommand extends BaseCommand< iterations: iteration, elapsedSec, totalUniqueDependencies: pinned.length, + auditIncomplete, violations, }, undefined, @@ -180,28 +235,27 @@ export default class CheckTrustPolicyCommand extends BaseCommand< this.log(` Unique pinned versions: ${pinned.length}`); this.log(` Elapsed: ${elapsedSec}s`); if (violations.length === 0) { - this.log("\nNo trust-policy violations detected."); - if (exitCode !== 0 && !this.flags.verbose) { + if (auditIncomplete) { this.log( - "\nNote: pnpm exited non-zero but no trust-related events were emitted. Re-run with --verbose to see pnpm's full output.", + "\nAudit incomplete: pnpm exited non-zero but no trust-policy events were emitted. Re-run with --verbose to see pnpm's full output.", ); + } else { + this.log("\nNo trust-policy violations detected."); } } else { this.log(`\n${violations.length} trust-policy violation(s):\n`); for (const violation of violations) { this.log(` ${violation}`); } + if (auditIncomplete) { + this.log( + "\nAudit incomplete: pnpm exited non-zero after the violations above without surfacing a new event. There may be more violations. Re-run with --verbose to see pnpm's full output.", + ); + } } } - if (!this.flags.keep) { - this.verbose(`Cleaning up temp dir: ${tempDir}`); - rmSync(tempDir, { recursive: true, force: true }); - } else { - this.verbose(`Leaving temp dir in place: ${tempDir}`); - } - - if (violations.length > 0) { + if (violations.length > 0 || auditIncomplete) { this.exit(1); } } @@ -290,8 +344,9 @@ function slugify(name: string, version: string): string { * each pulling in exactly one real (non-aliased) dependency. * * Real dependency names matter: pnpm's `--trust-policy-exclude` matches - * against the *registry* name, so aliasing breaks the exclude path for - * any `(name, version)` combination not picked as the canonical one. + * against the *registry* name (with optional exact version), so aliasing + * breaks the exclude path for any `(name, version)` combination not + * picked as the canonical one. * * We avoid putting `trustPolicyExclude` entries in the YAML because * pnpm 10's YAML form silently drops double-quoted scalars and rejects @@ -353,6 +408,13 @@ function writeAuditWorkspace(tempDir: string, pinned: readonly PinnedVersion[]): * Runs `pnpm` with the given args from `cwd` and captures stdout, stderr, * and the exit code. When `streamLive` is true, output is also forwarded * to this process so progress is visible during long operations. + * + * NOTE on `shell: true`: required on Windows so we can spawn `pnpm.cmd` + * (Node 20+ refuses to spawn .cmd/.bat files without a shell, per + * CVE-2024-27980). The downside is that on Windows cmd.exe interprets shell + * metacharacters in argv before pnpm sees them; callers that need to pass + * values containing `||`, `&`, `^`, etc. must wrap those values in their + * own double quotes (see the `--trust-policy-exclude` exclude builder). */ function runPnpm(args: string[], cwd: string, streamLive: boolean): Promise { return new Promise((resolveRun, rejectRun) => { From 3d0ab7cb61370c0fde0216125d8f6b89ab550b08 Mon Sep 17 00:00:00 2001 From: Tommy Brosman Date: Mon, 4 May 2026 17:40:59 -0700 Subject: [PATCH 09/13] =?UTF-8?q?-=20Switched=20base=20class=20=E2=80=94?= =?UTF-8?q?=20BaseCommand=20=E2=86=92=20BaseCommandWithBuildProject.=20Wor?= =?UTF-8?q?kspace=20root=20now=20comes=20from=20this.getBuildProject(this.?= =?UTF-8?q?flags.path).root=20instead=20of=20getContext().?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added path flag — Flags.directory({ exists: true }) so the command can target a build project outside the current working directory. - Adopted oclif's built-in JSON mode — static readonly enableJsonFlag = true;, removed the hand-rolled --json flag, and run() now returns a typed TrustPolicyAuditResult. Exit-on-failure gated on !this.jsonEnabled() so JSON consumers still get the structured payload. - Restructured violation tracking as Map> — replaced the flat Set of name@version tokens. The exclude-flag builder now reads versions directly from the map (no lastIndexOf("@") parsing); the || union construction itself was already in place from the previous commit. - Violations returned as objects, not strings — TrustPolicyAuditResult.violations and extractTrustViolations both now return PinnedVersion[] ({name, version}). Text output formats ${name}@${version} inline. - Removed the gross sort helper — extractTrustViolations returns events in pnpm's emission order (caller dedupes via the map). The single sort that matters (final output stability) is inlined where the result array is built. - Misc cleanup — removed backticks from verbose log strings; added countViolations helper. --- .../src/commands/check/trustPolicy.ts | 203 ++++++++++-------- 1 file changed, 116 insertions(+), 87 deletions(-) diff --git a/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts b/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts index c800140dbfed..27cf55c7639f 100644 --- a/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts +++ b/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts @@ -9,7 +9,7 @@ import path from "node:path"; import { Flags } from "@oclif/core"; -import { BaseCommand } from "../../library/commands/base.js"; +import { BaseCommandWithBuildProject } from "../../library/commands/base.js"; /** * The error code pnpm emits (both as the top-level `code` and inside `err`) @@ -52,7 +52,7 @@ interface PnpmRunResult { * See https://github.com/pnpm/pnpm/issues/10622 for the bug that motivated * this command. */ -export default class CheckTrustPolicyCommand extends BaseCommand< +export default class CheckTrustPolicyCommand extends BaseCommandWithBuildProject< typeof CheckTrustPolicyCommand > { static readonly summary = @@ -61,37 +61,40 @@ export default class CheckTrustPolicyCommand extends BaseCommand< static readonly description = "Materializes a scratch workspace under `.trust-audit-temp/` containing one leaf project per pinned dependency, then runs `pnpm install --trust-policy no-downgrade` and iteratively excludes each violation until pnpm either succeeds or stops surfacing new violations. Reports the full list of trust-downgrade violations."; + static readonly enableJsonFlag = true; + static readonly flags = { - json: Flags.boolean({ - description: "Emit JSON instead of a text report.", - default: false, - }), keep: Flags.boolean({ description: "Do not delete the scratch workspace after running.", default: false, }), + path: Flags.directory({ + description: + "Path used to locate the build project to audit. The closest build root containing this path is used. Defaults to the current working directory.", + exists: true, + }), tempDir: Flags.directory({ - description: "Scratch workspace directory (default: /.trust-audit-temp).", + description: "Scratch workspace directory (default: /.trust-audit-temp).", }), - ...BaseCommand.flags, + ...BaseCommandWithBuildProject.flags, } as const; - public async run(): Promise { - const context = await this.getContext(); - const repoRoot = context.root; + public async run(): Promise { + const buildProject = this.getBuildProject(this.flags.path); + const workspaceRoot = buildProject.root; const tempDir = path.resolve( - this.flags.tempDir ?? path.join(repoRoot, ".trust-audit-temp"), + this.flags.tempDir ?? path.join(workspaceRoot, ".trust-audit-temp"), ); - const lockfilePath = path.join(repoRoot, "pnpm-lock.yaml"); + const lockfilePath = path.join(workspaceRoot, "pnpm-lock.yaml"); if (!existsSync(lockfilePath)) { - this.error(`No pnpm-lock.yaml found in ${repoRoot}`); + this.error(`No pnpm-lock.yaml found in ${workspaceRoot}`); } - this.verbose("Enumerating installed dependencies via `pnpm list -r --json`..."); + this.verbose("Enumerating installed dependencies via pnpm list -r --json..."); const listResult = await runPnpm( ["list", "--recursive", "--json", "--depth", "Infinity"], - repoRoot, + workspaceRoot, false, ); if (listResult.code !== 0) { @@ -107,7 +110,10 @@ export default class CheckTrustPolicyCommand extends BaseCommand< const projectCount = writeAuditWorkspace(tempDir, pinned); this.verbose(`Wrote ${projectCount} leaf projects.`); - const violationSet = new Set(); + // Map of registry name → set of offending versions. We accumulate + // violations here across iterations so each pnpm invocation can be + // re-run with the union of all known offenders excluded. + const violationsByName = new Map>(); let lastResult: PnpmRunResult | undefined; let iteration = 0; let auditIncomplete = false; @@ -116,9 +122,8 @@ export default class CheckTrustPolicyCommand extends BaseCommand< // eslint-disable-next-line no-constant-condition while (true) { const excludeFlags: string[] = []; - // Group excluded versions by package name and pass each name - // once with versions joined by `||` (pnpm's "exact-versions - // union" syntax). For example: + // Pass each excluded package name once with its versions joined + // by `||` (pnpm's "exact-versions union" syntax). For example: // --trust-policy-exclude semver@5.7.2||6.3.1 // // This is required because pnpm's `evaluateVersionPolicy` only @@ -131,21 +136,9 @@ export default class CheckTrustPolicyCommand extends BaseCommand< // (https://pnpm.io/settings#trustpolicyexclude), where the // example `'webpack@4.47.0 || 5.102.1'` excludes both versions // of webpack. - const versionsByName = new Map(); - for (const violation of violationSet) { - const atIndex = violation.lastIndexOf("@"); - const name = violation.slice(0, atIndex); - const version = violation.slice(atIndex + 1); - const existing = versionsByName.get(name); - if (existing === undefined) { - versionsByName.set(name, [version]); - } else { - existing.push(version); - } - } - for (const name of [...versionsByName.keys()].sort()) { + for (const name of [...violationsByName.keys()].sort()) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const versions = versionsByName.get(name)!.sort(); + const versions = [...violationsByName.get(name)!].sort(); // Wrap in double quotes so that on Windows (where `runPnpm` // uses `shell: true`) cmd.exe doesn't interpret `||` as a // command separator before pnpm parses the arg. @@ -166,8 +159,9 @@ export default class CheckTrustPolicyCommand extends BaseCommand< ...excludeFlags, ]; + const excludedCount = countViolations(violationsByName); this.verbose( - `Iteration ${iteration}: pnpm install (excluded so far: ${violationSet.size})`, + `Iteration ${iteration}: pnpm install (excluded so far: ${excludedCount})`, ); iteration++; @@ -179,7 +173,9 @@ export default class CheckTrustPolicyCommand extends BaseCommand< break; } - const newViolations = found.filter((violation) => !violationSet.has(violation)); + const newViolations = found.filter( + ({ name, version }) => violationsByName.get(name)?.has(version) !== true, + ); if (newViolations.length === 0) { // pnpm exited non-zero without surfacing a new trust-policy // violation. That means something else went wrong (network, @@ -194,9 +190,14 @@ export default class CheckTrustPolicyCommand extends BaseCommand< ); break; } - for (const violation of newViolations) { - violationSet.add(violation); - this.verbose(` + ${violation}`); + for (const { name, version } of newViolations) { + let versions = violationsByName.get(name); + if (versions === undefined) { + versions = new Set(); + violationsByName.set(name, versions); + } + versions.add(version); + this.verbose(` + ${name}@${version}`); } } } finally { @@ -209,56 +210,84 @@ export default class CheckTrustPolicyCommand extends BaseCommand< } const elapsedSec = Number(((Date.now() - start) / 1000).toFixed(1)); - const violations = [...violationSet].sort(); + const violations: PinnedVersion[] = []; + for (const name of [...violationsByName.keys()].sort()) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + for (const version of [...violationsByName.get(name)!].sort()) { + violations.push({ name, version }); + } + } const exitCode = lastResult?.code ?? 2; - if (this.flags.json) { - this.log( - JSON.stringify( - { - tempDir, - exitCode, - iterations: iteration, - elapsedSec, - totalUniqueDependencies: pinned.length, - auditIncomplete, - violations, - }, - undefined, - 2, - ), - ); - } else { - this.log(`Audited via pnpm install in: ${tempDir}`); - this.log(` Final pnpm exit code: ${exitCode}`); - this.log(` Iterations: ${iteration}`); - this.log(` Unique pinned versions: ${pinned.length}`); - this.log(` Elapsed: ${elapsedSec}s`); - if (violations.length === 0) { - if (auditIncomplete) { - this.log( - "\nAudit incomplete: pnpm exited non-zero but no trust-policy events were emitted. Re-run with --verbose to see pnpm's full output.", - ); - } else { - this.log("\nNo trust-policy violations detected."); - } + this.log(`Audited via pnpm install in: ${tempDir}`); + this.log(` Final pnpm exit code: ${exitCode}`); + this.log(` Iterations: ${iteration}`); + this.log(` Unique pinned versions: ${pinned.length}`); + this.log(` Elapsed: ${elapsedSec}s`); + if (violations.length === 0) { + if (auditIncomplete) { + this.log( + "\nAudit incomplete: pnpm exited non-zero but no trust-policy events were emitted. Re-run with --verbose to see pnpm's full output.", + ); } else { - this.log(`\n${violations.length} trust-policy violation(s):\n`); - for (const violation of violations) { - this.log(` ${violation}`); - } - if (auditIncomplete) { - this.log( - "\nAudit incomplete: pnpm exited non-zero after the violations above without surfacing a new event. There may be more violations. Re-run with --verbose to see pnpm's full output.", - ); - } + this.log("\nNo trust-policy violations detected."); + } + } else { + this.log(`\n${violations.length} trust-policy violation(s):\n`); + for (const { name, version } of violations) { + this.log(` ${name}@${version}`); + } + if (auditIncomplete) { + this.log( + "\nAudit incomplete: pnpm exited non-zero after the violations above without surfacing a new event. There may be more violations. Re-run with --verbose to see pnpm's full output.", + ); } } - if (violations.length > 0 || auditIncomplete) { + const result: TrustPolicyAuditResult = { + tempDir, + exitCode, + iterations: iteration, + elapsedSec, + totalUniqueDependencies: pinned.length, + auditIncomplete, + violations, + }; + + // In text mode we exit non-zero on failure so CI fails. In JSON mode + // (handled by oclif via `enableJsonFlag`) we return the structured + // result instead, so downstream tooling can pipe the output without + // losing it to a non-zero exit. + if (!this.jsonEnabled() && (violations.length > 0 || auditIncomplete)) { this.exit(1); } + + return result; + } +} + +/** + * Structured result emitted by `flub check trustPolicy --json`. + */ +interface TrustPolicyAuditResult { + tempDir: string; + exitCode: number; + iterations: number; + elapsedSec: number; + totalUniqueDependencies: number; + auditIncomplete: boolean; + violations: PinnedVersion[]; +} + +/** + * Counts the total number of `(name, version)` pairs in a violations map. + */ +function countViolations(violationsByName: ReadonlyMap>): number { + let count = 0; + for (const versions of violationsByName.values()) { + count += versions.size; } + return count; } /** @@ -450,9 +479,9 @@ function runPnpm(args: string[], cwd: string, streamLive: boolean): Promise(); +function extractTrustViolations(ndjsonStdout: string): PinnedVersion[] { + const found: PinnedVersion[] = []; for (const rawLine of ndjsonStdout.split(/\r?\n/)) { // Skip blank lines and any line that can't possibly be a trust-downgrade @@ -497,8 +526,8 @@ function extractTrustViolations(ndjsonStdout: string): string[] { `pnpm emitted a "${TRUST_DOWNGRADE_CODE}" event without a package name and version: ${rawLine}`, ); } - found.add(`${name}@${version}`); + found.push({ name, version }); } - return [...found].sort(); + return found; } From 389fc3550413eea007daec49d9ad92ced745a121 Mon Sep 17 00:00:00 2001 From: Tommy Brosman Date: Mon, 4 May 2026 18:02:59 -0700 Subject: [PATCH 10/13] - Comment for the sorting. - .log -> .info --- .../packages/build-cli/docs/build-perf.md | 4 +-- .../src/commands/check/trustPolicy.ts | 26 ++++++++++++------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/build-tools/packages/build-cli/docs/build-perf.md b/build-tools/packages/build-cli/docs/build-perf.md index b1663f949174..67c59bc79ce3 100644 --- a/build-tools/packages/build-cli/docs/build-perf.md +++ b/build-tools/packages/build-cli/docs/build-perf.md @@ -75,7 +75,7 @@ DESCRIPTION EXAMPLES Collect public (PR) build data. - $ flub build-perf collect --mode public --project public --buildDefId 11 --outputDir ./output --adoApiToken \ + $ flub build-perf collect --mode public --project public --buildDefId 11 --outputDir ./output --adoApiToken ` $ADO_TOKEN ``` @@ -108,7 +108,7 @@ DESCRIPTION EXAMPLES Deploy dashboard for public mode. - $ flub build-perf deploy --mode public --aswaHostname myapp.azurestaticapps.net --dataDir ./data \ + $ flub build-perf deploy --mode public --aswaHostname myapp.azurestaticapps.net --dataDir ./data ` --deploymentToken $SWA_TOKEN ``` diff --git a/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts b/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts index 27cf55c7639f..5a3d7ef90205 100644 --- a/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts +++ b/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts @@ -210,6 +210,12 @@ export default class CheckTrustPolicyCommand extends BaseCommandWithBuildProject } const elapsedSec = Number(((Date.now() - start) / 1000).toFixed(1)); + + // Flatten `violationsByName` into a stable, deterministic array so the + // printed list and the JSON payload don't reorder across runs (Map and + // Set both preserve insertion order, which depends on pnpm's emission + // order across iterations). Sort by name, then by version within each + // name. This is the only consumer of `violationsByName` after the loop. const violations: PinnedVersion[] = []; for (const name of [...violationsByName.keys()].sort()) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -219,26 +225,26 @@ export default class CheckTrustPolicyCommand extends BaseCommandWithBuildProject } const exitCode = lastResult?.code ?? 2; - this.log(`Audited via pnpm install in: ${tempDir}`); - this.log(` Final pnpm exit code: ${exitCode}`); - this.log(` Iterations: ${iteration}`); - this.log(` Unique pinned versions: ${pinned.length}`); - this.log(` Elapsed: ${elapsedSec}s`); + this.info(`Audited via pnpm install in: ${tempDir}`); + this.info(` Final pnpm exit code: ${exitCode}`); + this.info(` Iterations: ${iteration}`); + this.info(` Unique pinned versions: ${pinned.length}`); + this.info(` Elapsed: ${elapsedSec}s`); if (violations.length === 0) { if (auditIncomplete) { - this.log( + this.info( "\nAudit incomplete: pnpm exited non-zero but no trust-policy events were emitted. Re-run with --verbose to see pnpm's full output.", ); } else { - this.log("\nNo trust-policy violations detected."); + this.info("\nNo trust-policy violations detected."); } } else { - this.log(`\n${violations.length} trust-policy violation(s):\n`); + this.info(`\n${violations.length} trust-policy violation(s):\n`); for (const { name, version } of violations) { - this.log(` ${name}@${version}`); + this.info(` ${name}@${version}`); } if (auditIncomplete) { - this.log( + this.info( "\nAudit incomplete: pnpm exited non-zero after the violations above without surfacing a new event. There may be more violations. Re-run with --verbose to see pnpm's full output.", ); } From f97259e562fd9e9202547722afa8a21d0d4dec6c Mon Sep 17 00:00:00 2001 From: Tommy Brosman Date: Mon, 4 May 2026 18:18:06 -0700 Subject: [PATCH 11/13] =?UTF-8?q?.trust-audit-temp/=20=E2=86=92=20**/.trus?= =?UTF-8?q?t-audit-temp/=20so=20the=20scratch=20dir=20is=20ignored=20under?= =?UTF-8?q?=20any=20workspace,=20not=20just=20the=20repo=20root=20(since?= =?UTF-8?q?=20the=20audit=20now=20picks=20the=20most=20specific=20workspac?= =?UTF-8?q?e=20and=20writes=20its=20temp=20dir=20there).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-workspace audit. --path now selects the most specific workspace (longest-prefix match) inside the build project containing the path, rather than always auditing the build-project root. This lets the command target a single release group (e.g. build-tools, routerlicious) instead of the whole repo. Updated --path and --tempDir flag descriptions accordingly. pnpm list -r and the scratch dir are now anchored to the chosen workspace, not the repo root. Removed the lockfile existence check. getBuildProject() already validates the workspace exists; the manual pnpm-lock.yaml check was redundant. info() instead of log() for the human-readable summary block, so --quiet suppresses it. --json mode already suppresses both, so the INFO: prefix doesn't leak into structured output. output-determinism doc comment added at the violations-flatten block, explaining that the sort exists so printed/JSON output is stable across runs (Map/Set preserve insertion order, which depends on pnpm's emission order). Best-effort post-run cleanup. The finally block's rmSync(tempDir, ...) is now wrapped in try/catch — failures (Windows file locks, AV holds, etc.) become a this.warning instead of masking the audit's exit code or violations list. Doc comment explains the rationale. Pre-run cleanup hardened. Removed the existsSync TOCTOU check in writeAuditWorkspace (since rmSync with force: true already no-ops on missing paths). Wrapped the rmSync itself in try/catch with a clear actionable error: "Cannot prepare scratch workspace: ... Delete it manually or pass a different --tempDir." Dropped the now-unused existsSync import. --- .gitignore | 2 +- .../src/commands/check/trustPolicy.ts | 60 ++++++++++++++----- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 8f4cc0a21b09..1e9c423d7526 100644 --- a/.gitignore +++ b/.gitignore @@ -48,7 +48,7 @@ docs/resources/_gen/ .pnpm-store/ # Scratch dir created by `flub check trustPolicy`. -.trust-audit-temp/ +**/.trust-audit-temp/ # TODO: This can be removed once the `flub add changeset` command no longer creates the UPCOMING file. UPCOMING.md diff --git a/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts b/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts index 5a3d7ef90205..3aeb6ae44595 100644 --- a/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts +++ b/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts @@ -4,7 +4,7 @@ */ import { spawn } from "node:child_process"; -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { Flags } from "@oclif/core"; @@ -70,31 +70,45 @@ export default class CheckTrustPolicyCommand extends BaseCommandWithBuildProject }), path: Flags.directory({ description: - "Path used to locate the build project to audit. The closest build root containing this path is used. Defaults to the current working directory.", + "Path inside the workspace to audit. The most specific workspace (e.g. a release group like `server/routerlicious` rather than the repo root) containing this path is used. Defaults to the current working directory.", exists: true, }), tempDir: Flags.directory({ - description: "Scratch workspace directory (default: /.trust-audit-temp).", + description: "Scratch workspace directory (default: /.trust-audit-temp).", }), ...BaseCommandWithBuildProject.flags, } as const; public async run(): Promise { - const buildProject = this.getBuildProject(this.flags.path); - const workspaceRoot = buildProject.root; + const searchPath = path.resolve(this.flags.path ?? process.cwd()); + const buildProject = this.getBuildProject(searchPath); + // Pick the most specific workspace containing `searchPath`. Workspaces + // in this repo nest (e.g. `server/routerlicious` lives inside the root + // workspace), so longest-prefix wins. + let workspaceDir: string | undefined; + for (const ws of buildProject.workspaces.values()) { + const dir = path.resolve(ws.directory); + if ( + (searchPath === dir || searchPath.startsWith(`${dir}${path.sep}`)) && + (workspaceDir === undefined || dir.length > workspaceDir.length) + ) { + workspaceDir = dir; + } + } + if (workspaceDir === undefined) { + this.error( + `No workspace in build project ${buildProject.root} contains ${searchPath}.`, + ); + } + this.verbose(`Auditing workspace: ${workspaceDir}`); const tempDir = path.resolve( - this.flags.tempDir ?? path.join(workspaceRoot, ".trust-audit-temp"), + this.flags.tempDir ?? path.join(workspaceDir, ".trust-audit-temp"), ); - const lockfilePath = path.join(workspaceRoot, "pnpm-lock.yaml"); - if (!existsSync(lockfilePath)) { - this.error(`No pnpm-lock.yaml found in ${workspaceRoot}`); - } - this.verbose("Enumerating installed dependencies via pnpm list -r --json..."); const listResult = await runPnpm( ["list", "--recursive", "--json", "--depth", "Infinity"], - workspaceRoot, + workspaceDir, false, ); if (listResult.code !== 0) { @@ -205,7 +219,19 @@ export default class CheckTrustPolicyCommand extends BaseCommandWithBuildProject this.verbose(`Leaving temp dir in place: ${tempDir}`); } else { this.verbose(`Cleaning up temp dir: ${tempDir}`); - rmSync(tempDir, { recursive: true, force: true }); + // Cleanup is best-effort: on Windows, pnpm's content-addressed + // store leaves hardlinks that AV scanners or lingering child + // processes can briefly hold open, and we don't want a stale + // lock to mask the audit's exit code or hide the violations + // the user actually came here for. Surface a warning so the + // user knows to clean up manually (or re-run with --keep). + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch (err) { + this.warning( + `Failed to remove temp dir ${tempDir}: ${err instanceof Error ? err.message : String(err)}. You may need to delete it manually.`, + ); + } } } @@ -390,8 +416,14 @@ function slugify(name: string, version: string): string { * @returns The number of leaf projects written (one per entry in `pinned`). */ function writeAuditWorkspace(tempDir: string, pinned: readonly PinnedVersion[]): number { - if (existsSync(tempDir)) { + // `rmSync` with `force: true` is a no-op if the path doesn't exist, so just + // try to remove it (avoids a race with `existsSync`). + try { rmSync(tempDir, { recursive: true, force: true }); + } catch (err) { + throw new Error( + `Cannot prepare scratch workspace: failed to remove existing temp dir ${tempDir}: ${err instanceof Error ? err.message : String(err)}. Delete it manually or pass a different --tempDir.`, + ); } mkdirSync(tempDir, { recursive: true }); From d0edabf1b2c84062da168cabca75b11c5aaf820c Mon Sep 17 00:00:00 2001 From: Tommy Brosman Date: Tue, 5 May 2026 10:03:19 -0700 Subject: [PATCH 12/13] - Use execa and github-slugger in `flub check trustPolicy` - Replace the hand-rolled `node:child_process` spawn helper with `execa`, matching the rest of build-cli. Because execa resolves `pnpm.cmd` natively without a shell, we no longer need `shell: true` and can drop the workaround that double-quoted `--trust-policy-exclude name@v1||v2` values to keep cmd.exe from interpreting `||`. - Replace the local `slugify(name, version)` helper plus `usedSlugs` collision map with a single `GithubSlugger` instance, which handles filesystem-safe slugging and duplicate suffixing internally. Both deps were already in the build-cli package. - No behavior change: `--path build-tools --json` produces the same 5 violations in 6 iterations as before. --- .../src/commands/check/trustPolicy.ts | 125 +++++++----------- 1 file changed, 45 insertions(+), 80 deletions(-) diff --git a/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts b/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts index 3aeb6ae44595..2bcb3a615117 100644 --- a/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts +++ b/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts @@ -3,11 +3,12 @@ * Licensed under the MIT License. */ -import { spawn } from "node:child_process"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { Flags } from "@oclif/core"; +import execa from "execa"; +import GithubSlugger from "github-slugger"; import { BaseCommandWithBuildProject } from "../../library/commands/base.js"; @@ -121,7 +122,7 @@ export default class CheckTrustPolicyCommand extends BaseCommandWithBuildProject this.verbose(`Found ${pinned.length} unique name@version entries.`); this.verbose(`Materializing scratch workspace at ${tempDir}...`); - const projectCount = writeAuditWorkspace(tempDir, pinned); + const projectCount = await writeAuditWorkspace(tempDir, pinned); this.verbose(`Wrote ${projectCount} leaf projects.`); // Map of registry name → set of offending versions. We accumulate @@ -153,13 +154,7 @@ export default class CheckTrustPolicyCommand extends BaseCommandWithBuildProject for (const name of [...violationsByName.keys()].sort()) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const versions = [...violationsByName.get(name)!].sort(); - // Wrap in double quotes so that on Windows (where `runPnpm` - // uses `shell: true`) cmd.exe doesn't interpret `||` as a - // command separator before pnpm parses the arg. - excludeFlags.push( - "--trust-policy-exclude", - `"${name}@${versions.join("||")}"`, - ); + excludeFlags.push("--trust-policy-exclude", `${name}@${versions.join("||")}`); } const installArgs = [ "install", @@ -226,7 +221,7 @@ export default class CheckTrustPolicyCommand extends BaseCommandWithBuildProject // the user actually came here for. Surface a warning so the // user knows to clean up manually (or re-run with --keep). try { - rmSync(tempDir, { recursive: true, force: true }); + await rm(tempDir, { recursive: true, force: true }); } catch (err) { this.warning( `Failed to remove temp dir ${tempDir}: ${err instanceof Error ? err.message : String(err)}. You may need to delete it manually.`, @@ -385,20 +380,7 @@ function collectPinnedVersions(listJsonStdout: string): PinnedVersion[] { } /** - * Builds a filesystem-safe slug for use as a project directory name. - * - * @returns A lowercase string with runs of non-alphanumeric characters replaced - * by `-`, with leading/trailing hyphens trimmed. For example, - * `\@fluidframework/tree` + `1.0.0` → `fluidframework-tree-1-0-0`. - */ -function slugify(name: string, version: string): string { - const safeName = name.replace(/[^\dA-Za-z]+/g, "-").replace(/^-+|-+$/g, ""); - const safeVersion = version.replace(/[^\dA-Za-z]+/g, "-").replace(/^-+|-+$/g, ""); - return `${safeName}-${safeVersion}`.toLowerCase(); -} - -/** - * Creates `tempDir` containing: + * Builds `tempDir` containing: * * - `pnpm-workspace.yaml` declaring the leaf glob and `trustPolicy: no-downgrade`. * - One leaf project per `(name, version)` under `projects//`, @@ -415,20 +397,23 @@ function slugify(name: string, version: string): string { * * @returns The number of leaf projects written (one per entry in `pinned`). */ -function writeAuditWorkspace(tempDir: string, pinned: readonly PinnedVersion[]): number { - // `rmSync` with `force: true` is a no-op if the path doesn't exist, so just - // try to remove it (avoids a race with `existsSync`). +async function writeAuditWorkspace( + tempDir: string, + pinned: readonly PinnedVersion[], +): Promise { + // `rm` with `force: true` is a no-op if the path doesn't exist, so just + // try to remove it (avoids a race with an existence check). try { - rmSync(tempDir, { recursive: true, force: true }); + await rm(tempDir, { recursive: true, force: true }); } catch (err) { throw new Error( `Cannot prepare scratch workspace: failed to remove existing temp dir ${tempDir}: ${err instanceof Error ? err.message : String(err)}. Delete it manually or pass a different --tempDir.`, ); } - mkdirSync(tempDir, { recursive: true }); + await mkdir(tempDir, { recursive: true }); - writeFileSync(path.resolve(tempDir, ".gitignore"), "*\n"); - writeFileSync( + await writeFile(path.resolve(tempDir, ".gitignore"), "*\n"); + await writeFile( path.resolve(tempDir, "pnpm-workspace.yaml"), [ "# Generated by `flub check trustPolicy` - do not edit.", @@ -440,20 +425,17 @@ function writeAuditWorkspace(tempDir: string, pinned: readonly PinnedVersion[]): ); const projectsDir = path.resolve(tempDir, "projects"); - mkdirSync(projectsDir, { recursive: true }); - const usedSlugs = new Map(); + await mkdir(projectsDir, { recursive: true }); + // `GithubSlugger` produces a filesystem-safe slug and auto-suffixes + // duplicates against its own internal seen-set, so we don't need to + // track collisions ourselves. + const slugger = new GithubSlugger(); let packageEntryCount = 0; for (const { name, version } of pinned) { - let slug = slugify(name, version); - const collision = usedSlugs.get(slug) ?? 0; - usedSlugs.set(slug, collision + 1); - if (collision > 0) { - slug = `${slug}-${collision}`; - } - + const slug = slugger.slug(`${name} ${version}`); const projectDir = path.resolve(projectsDir, slug); - mkdirSync(projectDir, { recursive: true }); - writeFileSync( + await mkdir(projectDir, { recursive: true }); + await writeFile( path.resolve(projectDir, "package.json"), `${JSON.stringify( { @@ -475,45 +457,28 @@ function writeAuditWorkspace(tempDir: string, pinned: readonly PinnedVersion[]): * Runs `pnpm` with the given args from `cwd` and captures stdout, stderr, * and the exit code. When `streamLive` is true, output is also forwarded * to this process so progress is visible during long operations. - * - * NOTE on `shell: true`: required on Windows so we can spawn `pnpm.cmd` - * (Node 20+ refuses to spawn .cmd/.bat files without a shell, per - * CVE-2024-27980). The downside is that on Windows cmd.exe interprets shell - * metacharacters in argv before pnpm sees them; callers that need to pass - * values containing `||`, `&`, `^`, etc. must wrap those values in their - * own double quotes (see the `--trust-policy-exclude` exclude builder). */ -function runPnpm(args: string[], cwd: string, streamLive: boolean): Promise { - return new Promise((resolveRun, rejectRun) => { - const child = spawn("pnpm", args, { - cwd, - shell: true, - stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env, CI: "1" }, - }); - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - child.stdout?.on("data", (chunk: Buffer) => { - stdoutChunks.push(chunk); - if (streamLive) { - process.stdout.write(chunk); - } - }); - child.stderr?.on("data", (chunk: Buffer) => { - stderrChunks.push(chunk); - if (streamLive) { - process.stderr.write(chunk); - } - }); - child.on("error", rejectRun); - child.on("close", (code) => { - resolveRun({ - code, - stdout: Buffer.concat(stdoutChunks).toString("utf-8"), - stderr: Buffer.concat(stderrChunks).toString("utf-8"), - }); - }); +async function runPnpm( + args: string[], + cwd: string, + streamLive: boolean, +): Promise { + const subprocess = execa("pnpm", args, { + cwd, + reject: false, + stdin: "ignore", + env: { ...process.env, CI: "1" }, }); + if (streamLive) { + subprocess.stdout?.on("data", (chunk: Buffer) => process.stdout.write(chunk)); + subprocess.stderr?.on("data", (chunk: Buffer) => process.stderr.write(chunk)); + } + const result = await subprocess; + return { + code: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }; } /** From 23a1346e8fb05aba1baf9039197874372302df09 Mon Sep 17 00:00:00 2001 From: Tommy Brosman Date: Tue, 5 May 2026 10:57:30 -0700 Subject: [PATCH 13/13] Biome. --- .../packages/build-cli/src/commands/check/trustPolicy.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts b/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts index 2bcb3a615117..3c157c864afa 100644 --- a/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts +++ b/build-tools/packages/build-cli/src/commands/check/trustPolicy.ts @@ -97,9 +97,7 @@ export default class CheckTrustPolicyCommand extends BaseCommandWithBuildProject } } if (workspaceDir === undefined) { - this.error( - `No workspace in build project ${buildProject.root} contains ${searchPath}.`, - ); + this.error(`No workspace in build project ${buildProject.root} contains ${searchPath}.`); } this.verbose(`Auditing workspace: ${workspaceDir}`); const tempDir = path.resolve(