diff --git a/src/command/manifest/bazel/bazel-socket-json-trust.mts b/src/command/manifest/bazel/bazel-socket-json-trust.mts new file mode 100644 index 000000000..47663d67c --- /dev/null +++ b/src/command/manifest/bazel/bazel-socket-json-trust.mts @@ -0,0 +1,211 @@ +/** + * Socket.json settings that pick what `socket manifest bazel` runs, or where + * it writes outside the project, need `--trust-socket-json`. + */ +import path from 'node:path' + +import { SOCKET_JSON } from '../../../constants/socket.mts' +import { isPathWithinRoot } from '../../../util/trusted-executable.mts' +import { + canonicalizeForContainment, + readBuildToolBin, + TRUST_SOCKET_JSON_FLAG, +} from '../manifest-build-trust.mts' + +import type { CResult } from '../../../types.mts' +import type { SocketJson } from '../../../util/socket/json.mts' + +export type BazelSocketJsonConfig = NonNullable< + NonNullable['manifest']>['bazel'] +> + +export type BazelManifestCliSettings = { + bazel: unknown + bazelFlags: unknown + bazelOutputBase: unknown + bazelRc: unknown + out: unknown +} + +export type BazelManifestSettings = { + bazel: string | undefined + bazelFlags: string + bazelOutputBase: string | undefined + bazelRc: string | undefined + out: string + socketJsonDefaults: Array<{ flag: string; value: string }> +} + +export type BazelManifestSettingTally = { + refusedFields: string[] + socketJsonDefaults: BazelManifestSettings['socketJsonDefaults'] + trustSocketJson: boolean +} + +/** + * A command-line value always wins. A socket.json value is recorded as refused + * unless the trust flag is set. + */ +export function readBazelManifestSetting( + tally: BazelManifestSettingTally, + { + cliValue, + field, + flag, + socketJsonValue, + }: { + cliValue: unknown + field: string + flag: string + socketJsonValue: unknown + }, +): string | undefined { + const fromCli = readBuildToolBin(cliValue) + if (fromCli) { + return fromCli + } + const fromSocketJson = readBuildToolBin(socketJsonValue) + if (!fromSocketJson) { + return undefined + } + if (!tally.trustSocketJson) { + tally.refusedFields.push(field) + return undefined + } + tally.socketJsonDefaults.push({ flag, value: fromSocketJson }) + return fromSocketJson +} + +export function resolveBazelManifestOut({ + cliOut, + cwd, + socketJson, + trustSocketJson, +}: { + cliOut: unknown + cwd: string + socketJson: SocketJson | undefined + trustSocketJson: boolean +}): CResult<{ fromSocketJson: boolean; path: string }> { + const fromCli = readBuildToolBin(cliOut) + if (fromCli) { + return { ok: true, data: { fromSocketJson: false, path: fromCli } } + } + const socketJsonOut = readBuildToolBin( + socketJson?.defaults?.manifest?.bazel?.out, + ) + if (!socketJsonOut) { + return { + ok: true, + data: { + fromSocketJson: false, + path: path.join(cwd, '.socket', 'bazel-manifests'), + }, + } + } + const resolvedOut = path.resolve(cwd, socketJsonOut) + if ( + !trustSocketJson && + !isPathWithinRoot( + canonicalizeForContainment(cwd), + canonicalizeForContainment(resolvedOut), + ) + ) { + return { + ok: false, + message: `Refused a bazel output path chosen by ${SOCKET_JSON}`, + cause: [ + `${SOCKET_JSON} in ${cwd} sets defaults.manifest.bazel.out.`, + `Saw \`${socketJsonOut}\` (resolves to ${resolvedOut}), wanted a path inside ${cwd}.`, + 'The CLI writes generated manifests there, so a path outside the project would let a scanned repository choose where the CLI writes.', + `Fix: pass \`--out=${socketJsonOut}\` on the command line if you trust it, or re-run with ${TRUST_SOCKET_JSON_FLAG} to honor ${SOCKET_JSON} in this checkout.`, + ].join('\n'), + } + } + return { ok: true, data: { fromSocketJson: true, path: resolvedOut } } +} + +export function resolveBazelManifestSettings({ + cli, + cwd, + socketJson, + trustSocketJson, +}: { + cli: BazelManifestCliSettings + cwd: string + socketJson: SocketJson | undefined + trustSocketJson: boolean +}): CResult { + const bazelConfig: BazelSocketJsonConfig = + socketJson?.defaults?.manifest?.bazel ?? {} + const tally: BazelManifestSettingTally = { + refusedFields: [], + socketJsonDefaults: [], + trustSocketJson, + } + + const bazel = readBazelManifestSetting(tally, { + cliValue: cli.bazel, + field: bazelConfig.bazel + ? 'defaults.manifest.bazel.bazel' + : 'defaults.manifest.bazel.bin', + flag: '--bazel', + socketJsonValue: bazelConfig.bazel ?? bazelConfig.bin, + }) + const bazelFlags = readBazelManifestSetting(tally, { + cliValue: cli.bazelFlags, + field: 'defaults.manifest.bazel.bazelFlags', + flag: '--bazel-flags', + socketJsonValue: bazelConfig.bazelFlags, + }) + const bazelRc = readBazelManifestSetting(tally, { + cliValue: cli.bazelRc, + field: 'defaults.manifest.bazel.bazelRc', + flag: '--bazel-rc', + socketJsonValue: bazelConfig.bazelRc, + }) + const bazelOutputBase = readBazelManifestSetting(tally, { + cliValue: cli.bazelOutputBase, + field: 'defaults.manifest.bazel.bazelOutputBase', + flag: '--bazel-output-base', + socketJsonValue: bazelConfig.bazelOutputBase, + }) + + if (tally.refusedFields.length) { + return { + ok: false, + message: `Refused bazel settings chosen by ${SOCKET_JSON}`, + cause: [ + `${SOCKET_JSON} in ${cwd} sets ${tally.refusedFields.join(', ')}.`, + 'Saw repository-supplied bazel execution settings, wanted none.', + 'These values pick the bazel binary and the options it runs with, and a scanned repository controls its own socket.json.', + `Fix: pass --bazel, --bazel-flags, --bazel-rc, or --bazel-output-base on the command line, or re-run with ${TRUST_SOCKET_JSON_FLAG} to honor ${SOCKET_JSON} in this checkout.`, + ].join('\n'), + } + } + + const out = resolveBazelManifestOut({ + cliOut: cli.out, + cwd, + socketJson, + trustSocketJson, + }) + if (!out.ok) { + return out + } + if (out.data.fromSocketJson) { + tally.socketJsonDefaults.push({ flag: '--out', value: out.data.path }) + } + + return { + ok: true, + data: { + bazel, + bazelFlags: bazelFlags ?? '', + bazelOutputBase, + bazelRc, + out: out.data.path, + socketJsonDefaults: tally.socketJsonDefaults, + }, + } +} diff --git a/src/command/manifest/bazel/cmd-manifest-bazel.mts b/src/command/manifest/bazel/cmd-manifest-bazel.mts index 4ada4b1a6..76383df9a 100644 --- a/src/command/manifest/bazel/cmd-manifest-bazel.mts +++ b/src/command/manifest/bazel/cmd-manifest-bazel.mts @@ -9,8 +9,10 @@ import path from 'node:path' import { debugDirNs } from '@socketsecurity/lib-stable/debug/output' import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' +import { resolveBazelManifestSettings } from './bazel-socket-json-trust.mts' import { extractBazelToMaven } from './extract_bazel_to_maven.mts' import { extractBazelToPypi } from './extract_bazel_to_pypi.mts' +import { outputManifest } from '../output-manifest.mts' import { SOCKET_JSON } from '../../../constants/socket.mts' import { defineFlags } from '../../../meow.mts' import { commonFlags, outputFlags } from '../../../flags.mts' @@ -77,6 +79,11 @@ const config = { description: 'Per-hub bazel cquery timeout in milliseconds; default: 120000', }, + trustSocketJson: { + type: 'boolean', + default: false, + description: `Run the bazel binary and options declared in ${SOCKET_JSON}. Off by default because the scanned repository controls that file.`, + }, verbose: { type: 'boolean', description: @@ -99,6 +106,12 @@ const config = { --ecosystem may be repeated to select which ecosystems to extract. When omitted, Maven is generated by default. PyPI is explicit opt-in. + A ${SOCKET_JSON} that sets \`bazel\`/\`bin\`, \`bazelFlags\`, \`bazelRc\`, or + \`bazelOutputBase\`, or an \`out\` outside the project, is refused unless + you pass --trust-socket-json: those values choose what gets executed and + the repository being scanned owns that file. Pass the matching flags + yourself to override the defaults without trusting ${SOCKET_JSON}. + Note: this command generates dependency manifests for Bazel workspaces. It does not run reachability analysis. @@ -272,52 +285,28 @@ export async function run( [`${SOCKET_JSON} bazel defaults`]: sockJson.defaults?.manifest?.bazel, }) - let { bazel, bazelFlags, bazelOutputBase, bazelRc, out, verbose } = cli.flags + const settings = resolveBazelManifestSettings({ + cli: cli.flags, + cwd, + socketJson: sockJson, + trustSocketJson: cli.flags['trustSocketJson'], + }) + if (!settings.ok) { + await outputManifest(settings, outputKind, '-') + return + } + for (const { flag, value } of settings.data.socketJsonDefaults) { + logger.info(`Using default ${flag} from ${SOCKET_JSON}:`, value) + } + // An undefined bazel leaves the bazelisk/bazel PATH lookup to + // resolveBazelBinary. + const { bazel, bazelFlags, bazelOutputBase, bazelRc, out } = settings.data + let { verbose } = cli.flags let perRepoTimeout = cli.flags['perRepoTimeout'] as number | undefined const resolved = resolveBazelDefaults() function resolveBazelDefaults() { - // Set defaults for any flag/arg that is not given. Check socket.json first. - if (!bazel) { - const defaultBazel = defaults.bazel ?? defaults.bin - if (defaultBazel) { - bazel = defaultBazel - logger.info(`Using default --bazel from ${SOCKET_JSON}:`, bazel) - } - // Otherwise leave undefined; resolveBazelBinary performs the PATH - // lookup for bazelisk/bazel. - } - if (!bazelFlags) { - if (defaults.bazelFlags) { - bazelFlags = defaults.bazelFlags - logger.info( - `Using default --bazel-flags from ${SOCKET_JSON}:`, - bazelFlags, - ) - } else { - bazelFlags = '' - } - } - if (!bazelOutputBase && defaults.bazelOutputBase) { - bazelOutputBase = defaults.bazelOutputBase - logger.info( - `Using default --bazel-output-base from ${SOCKET_JSON}:`, - bazelOutputBase, - ) - } - if (!bazelRc && defaults.bazelRc) { - bazelRc = defaults.bazelRc - logger.info(`Using default --bazel-rc from ${SOCKET_JSON}:`, bazelRc) - } - if (!out) { - if (defaults.out) { - out = defaults.out - logger.info(`Using default --out from ${SOCKET_JSON}:`, out) - } else { - out = path.join(cwd, '.socket', 'bazel-manifests') - } - } verbose = resolveManifestDefault( verbose, defaults.verbose, diff --git a/test/unit/command/manifest/bazel/bazel-socket-json-trust.test.mts b/test/unit/command/manifest/bazel/bazel-socket-json-trust.test.mts new file mode 100644 index 000000000..767e61b1f --- /dev/null +++ b/test/unit/command/manifest/bazel/bazel-socket-json-trust.test.mts @@ -0,0 +1,152 @@ +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { resolveBazelManifestSettings } from '../../../../../src/command/manifest/bazel/bazel-socket-json-trust.mts' + +import type { BazelManifestCliSettings } from '../../../../../src/command/manifest/bazel/bazel-socket-json-trust.mts' +import type { SocketJson } from '../../../../../src/util/socket/json.mts' + +const CWD = path.join(path.sep, 'proj') + +const NO_CLI: BazelManifestCliSettings = { + bazel: undefined, + bazelFlags: undefined, + bazelOutputBase: undefined, + bazelRc: undefined, + out: undefined, +} + +function socketJsonWithBazel(bazel: Record): SocketJson { + return { defaults: { manifest: { bazel } } } as unknown as SocketJson +} + +describe('resolveBazelManifestSettings', () => { + it('uses PATH lookup and the default out dir with no socket.json', () => { + const result = resolveBazelManifestSettings({ + cli: NO_CLI, + cwd: CWD, + socketJson: undefined, + trustSocketJson: false, + }) + expect(result).toEqual({ + ok: true, + data: { + bazel: undefined, + bazelFlags: '', + bazelOutputBase: undefined, + bazelRc: undefined, + out: path.join(CWD, '.socket', 'bazel-manifests'), + socketJsonDefaults: [], + }, + }) + }) + + it.each([ + ['bin', 'defaults.manifest.bazel.bin', '/repo/payload.sh'], + ['bazel', 'defaults.manifest.bazel.bazel', '/repo/payload.sh'], + ['bazelFlags', 'defaults.manifest.bazel.bazelFlags', '--config=remote'], + ['bazelRc', 'defaults.manifest.bazel.bazelRc', 'tools/extra.bazelrc'], + ['bazelOutputBase', 'defaults.manifest.bazel.bazelOutputBase', '/cache'], + ])('refuses socket.json %s without the trust flag', (key, field, value) => { + const result = resolveBazelManifestSettings({ + cli: NO_CLI, + cwd: CWD, + socketJson: socketJsonWithBazel({ [key]: value }), + trustSocketJson: false, + }) + expect(result.ok).toBe(false) + expect(result.ok ? '' : result.cause).toContain(field) + }) + + it('honors socket.json execution settings under the trust flag', () => { + const result = resolveBazelManifestSettings({ + cli: NO_CLI, + cwd: CWD, + socketJson: socketJsonWithBazel({ + bazelFlags: '--config=remote', + bazelOutputBase: '/cache', + bazelRc: 'tools/extra.bazelrc', + bin: '/opt/bazelisk', + }), + trustSocketJson: true, + }) + expect(result.ok && result.data).toMatchObject({ + bazel: '/opt/bazelisk', + bazelFlags: '--config=remote', + bazelOutputBase: '/cache', + bazelRc: 'tools/extra.bazelrc', + }) + expect(result.ok && result.data.socketJsonDefaults).toHaveLength(4) + }) + + it('lets command-line flags override socket.json without the trust flag', () => { + const result = resolveBazelManifestSettings({ + cli: { + bazel: '/cli/bazelisk', + bazelFlags: '--keep_going', + bazelOutputBase: '/cli-cache', + bazelRc: 'cli.bazelrc', + out: '/cli-out', + }, + cwd: CWD, + socketJson: socketJsonWithBazel({ + bazelFlags: '--config=remote', + bazelOutputBase: '/cache', + bazelRc: 'tools/extra.bazelrc', + bin: '/repo/payload.sh', + out: '/elsewhere', + }), + trustSocketJson: false, + }) + expect(result).toEqual({ + ok: true, + data: { + bazel: '/cli/bazelisk', + bazelFlags: '--keep_going', + bazelOutputBase: '/cli-cache', + bazelRc: 'cli.bazelrc', + out: '/cli-out', + socketJsonDefaults: [], + }, + }) + }) + + it('honors a socket.json out inside the project untrusted', () => { + const result = resolveBazelManifestSettings({ + cli: NO_CLI, + cwd: CWD, + socketJson: socketJsonWithBazel({ out: 'build/manifests' }), + trustSocketJson: false, + }) + expect(result.ok && result.data.out).toBe( + path.join(CWD, 'build', 'manifests'), + ) + }) + + it.each(['../outside', '/etc/socket'])( + 'refuses a socket.json out of %s outside the project', + out => { + const result = resolveBazelManifestSettings({ + cli: NO_CLI, + cwd: CWD, + socketJson: socketJsonWithBazel({ out }), + trustSocketJson: false, + }) + expect(result.ok).toBe(false) + expect(result.ok ? '' : result.cause).toContain( + 'defaults.manifest.bazel.out', + ) + }, + ) + + it('honors a socket.json out outside the project under the trust flag', () => { + const result = resolveBazelManifestSettings({ + cli: NO_CLI, + cwd: CWD, + socketJson: socketJsonWithBazel({ out: '../outside' }), + trustSocketJson: true, + }) + expect(result.ok && result.data.out).toBe(path.resolve(CWD, '../outside')) + }) +}) diff --git a/test/unit/command/manifest/bazel/cmd-manifest-bazel.test.mts b/test/unit/command/manifest/bazel/cmd-manifest-bazel.test.mts index 44f9c5ac1..cdf6e1758 100644 --- a/test/unit/command/manifest/bazel/cmd-manifest-bazel.test.mts +++ b/test/unit/command/manifest/bazel/cmd-manifest-bazel.test.mts @@ -54,6 +54,25 @@ vi.mock( }), ) +const mockOutputManifest = vi.hoisted(() => vi.fn()) +const mockReadOrDefaultSocketJson = vi.hoisted(() => + vi.fn().mockReturnValue({}), +) + +vi.mock( + import('../../../../../src/command/manifest/output-manifest.mts'), + () => ({ + outputManifest: mockOutputManifest, + }), +) +vi.mock( + import('../../../../../src/util/socket/json.mts'), + async importOriginal => ({ + ...(await importOriginal()), + readOrDefaultSocketJson: mockReadOrDefaultSocketJson, + }), +) + const importMeta = { url: 'file:///cmd-manifest-bazel.test.mts', } as ImportMeta @@ -300,4 +319,47 @@ describe('cmd-manifest-bazel', () => { expect(extractBazelToMaven).not.toHaveBeenCalled() }) }) + + describe('socket.json trust', () => { + const BIN_SOCKET_JSON = { + defaults: { manifest: { bazel: { bin: '/socket-json/bazel' } } }, + } + + it('refuses a socket.json bin without --trust-socket-json', async () => { + mockReadOrDefaultSocketJson.mockReturnValueOnce(BIN_SOCKET_JSON) + await cmdManifestBazel.run(['.'], importMeta, context) + expect(extractBazelToMaven).not.toHaveBeenCalled() + expect(mockOutputManifest).toHaveBeenCalledWith( + expect.objectContaining({ ok: false }), + 'text', + '-', + ) + }) + + it('uses a socket.json bin under --trust-socket-json', async () => { + mockReadOrDefaultSocketJson.mockReturnValueOnce(BIN_SOCKET_JSON) + await cmdManifestBazel.run( + ['--trust-socket-json', '.'], + importMeta, + context, + ) + expect(mockOutputManifest).not.toHaveBeenCalled() + expect(extractBazelToMaven).toHaveBeenCalledWith( + expect.objectContaining({ bin: '/socket-json/bazel' }), + ) + }) + + it('lets --bazel override a socket.json bin without trust', async () => { + mockReadOrDefaultSocketJson.mockReturnValueOnce(BIN_SOCKET_JSON) + await cmdManifestBazel.run( + ['--bazel', '/cli/bazelisk', '.'], + importMeta, + context, + ) + expect(mockOutputManifest).not.toHaveBeenCalled() + expect(extractBazelToMaven).toHaveBeenCalledWith( + expect.objectContaining({ bin: '/cli/bazelisk' }), + ) + }) + }) })