Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

npm v7におけるsudo run scriptがスーパーユーザーで実行されず、ハマった

f:id:ykob_yapp:20220104150002p:plain

はじめに

フロントエンドエンジニアの小林(baco16g)です。

2021年10月26日に、Node.js v16がアクティブLTS(Long Term Support)に移行しました。

目前でNode.js v14からマイグレーションする必要はありませんでしたが、npm v7以降への対応や Apple Siliconチップへの対応を鑑みると、早めに動作検証すべきだと考えて調査を始めました。

Yappli CMSのクライアントサイドは、CirlceCIジョブでNuxt.jsのStatic Site Generationを実行して、生成された静的ファイルをAmazon S3 バケットにアップロードして、Amazon CloudFrontで配信しています。さらに、BFF(backend for frontend)な領域にはGoを採用しています。

したがって、フロントエンド領域でNode.jsに依存している箇所はそれほど多くなく、調査自体は難なく終えました。終えたつもりでした。

SentryのReleaseが生成されない

Yappli CMSのクライアントサイドでは、エラートラッキングにSentryを使用しています。具体的には@nuxtjs/sentryを使用しており、Nuxt.jsのStatic Site Generation時にReleaseの生成を行なっています。

docs.sentry.io

しかし、Node.js v16へのマイグレーションコミット以降は、Releaseが突如として生成されなくなりました。SourceMapのデプロイ処理はRelease機能に依存しているため、このままでは検知したエラーの調査がしづらくなってしまいます。

ログを見たところ、CirlceCIジョブでエラーは発生しておらず、代わりに下記のWarningメッセージが表示されていました。

WARN Sentry release will not be published because "config.release" was not set nor it was possible to determine it automatically from the repository

@nuxtjs/sentryを読んでみる

どうやら、publishRelease.releaseが未設定の場合に出力されるメッセージのようです。ドキュメントによれば、config.releaseは自動解決されるため、本来ならば起こり得ないはずです。

export function webpackConfigHook (moduleContainer, webpackConfigs, options, logger) {
  ... // 省略

  if (options.config.release && !publishRelease.release) {
    publishRelease.release = options.config.release
  }

  if (!publishRelease.release) {
    // We've already tried to determine "release" manually using Sentry CLI so to avoid webpack
    // plugin crashing, we'll just bail here.
    logger.warn('Sentry release will not be published because "config.release" was not set nor it ' +
              'was possible to determine it automatically from the repository')
    return
  }

  ... // 省略
}

sentry-module/hooks.js at 607511a4e4c6000fbea78e811249839d4a1f7272 · nuxt-community/sentry-module · GitHub

さらに深ぼってみましょう。@nuxtjs/sentryでは、build:before と webpack:configという二つのNuxt Hooksを登録しています。今回のWarningは webpack:config hookで発生していますが、このhookではconfig.releaseを解決する処理が見当たりません。したがって、build:before hookで解決していると考えられます。

...ありました。@sentry/cliのインスタンスを作成して、cli.releases.proposeVersionを実行しています。

export async function buildHook (moduleContainer, options, logger) {
  if (!('release' in options.config)) {
    // Determine "config.release" automatically from local repo if not provided.
    try {
      // @ts-ignore
      const SentryCli = await (import('@sentry/cli').then(m => m.default || m))
      const cli = new SentryCli()
      options.config.release = (await cli.releases.proposeVersion()).trim()
    } catch {
      // Ignore
    }
  }

  ... // 省略
}

sentry-module/hooks.js at 607511a4e4c6000fbea78e811249839d4a1f7272 · nuxt-community/sentry-module · GitHub

catch節のIgnoreコメントから察するに、何かしらのエラーが発生し得るが、あえてハンドリングがされていないようです。どのようなエラーが発生しているかを可視化したいため、patch-packageを用いてエラーをthrowさせてみます。

その結果、下記のエラーメッセージを確認することができました。

error: Failed to load .sentryclirc file from the home folder. caused by: Permission denied (os error 13) thread 'unnamed' panicked at 'Config not bound yet': src/config.rs:85

home folder上の.sentryclircを開こうとしたが権限がなく、エラーが発生しているようです。

ということなので、@sentry/cliのコードも読む必要がありそうです。

@sentry/cliを読んでみる

npmで公開されている@sentry/cliは、JavaScriptをハブにして、Rustで生成したバイナリファイルを実行するというのが大まかな流れのようです。

バイナリファイルの実行には、child_process.execFileを使用しています。uidやgidを指定していないため、ここに問題はなさそうです。

function execute(args, live, silent, configFile, config = {}) {
  const env = { ...process.env };

  ... // 省略

  return new Promise((resolve, reject) => {
    ... // 省略
    childProcess.execFile(getPath(), args, { env }, (err, stdout) => {
      if (err) {
        reject(err);
      } else {
        resolve(stdout);
      }
    });
  });
}

sentry-cli/helper.js at 42a0e70abe582f2aaabc1e3d8d55186864f95dbf · getsentry/sentry-cli · GitHub

続いて、バイナリファイル側のコードを追いましょう。先ほどのエラーメッセージを出力している箇所を確認してみます。

dirs::home_dirで取得したパスに対してCONFIG_RC_FILE_NAMEをジョインして、そのファイルパスに対してfs::File::openを実行しています。

fn find_global_config_file() -> Result<PathBuf, Error> {
    dirs::home_dir()
        .ok_or_else(|| err_msg("Could not find home dir"))
        .map(|mut path| {
            path.push(CONFIG_RC_FILE_NAME);
            path
        })
}
fn load_global_config_file() -> Result<(PathBuf, Ini), Error> {
    let filename = find_global_config_file()?;
    match fs::File::open(&filename) {
        Ok(mut file) => match Ini::read_from(&mut file) {
            Ok(ini) => Ok((filename, ini)),
            Err(err) => Err(Error::from(err)),
        },
        Err(err) => {
            if err.kind() == io::ErrorKind::NotFound {
                Ok((filename, Ini::new()))
            } else {
                Err(Error::from(err)
                    .context("Failed to load .sentryclirc file from the home folder.")
                    .into())
            }
        }
    }
}

sentry-cli/config.rs at 42a0e70abe582f2aaabc1e3d8d55186864f95dbf · getsentry/sentry-cli · GitHub

docs.rs

ここまでコードリーディングした限り、Sentry側に不穏な箇所は一つもありません。

原因はNode.jsもしくはnpmの可能性が高い

whoami と echo $HOMEをしてみる

まず、CircleCIジョブでは、スーパーユーザーでnuxt generateを実行していました。

sudo bash -c "SENTRY_DSN=$SENTRY_DSN SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN npm run generate"

「なぜスーパーユーザーとして実行しているのか」という疑問は持ちつつも、スーパーユーザーであれば尚更に権限エラーが発生するのは不可解です。

そこで、対象のスクリプトでwhoami$HOMEを取得してみると、下記のような結果になりました。

const os = require('os');
console.log('USER', os.userInfo().username);
// circleci

console.log('HOME', process.env.HOME);
// /root

スーパーユーザーでnpm scriptを実行したにもかかわらず、circleciユーザーとして実行されています。また、$HOMEは/rootになっています。なるほど。権限エラーが発生すること自体は納得です。

問題の原因はNode.js?それともnpm?

簡易的なデモリポジトリを作成して、下記のような4つのコマンドを実行してみました。

その結果、sudo npm run whoamiのみが期待値とは異なる結果になりました。したがって、原因はnpmにあると断言できます。

$ circleci config process .circleci/config.yml > process.yml
$ circleci local execute -c process.yml --job my-job

====>> npm run whoami
[USER] circleci
[HOME] /home/circleci

====>> sudo npm run whoami
[USER] circleci
[HOME] /root

====>> node whoami.js
[USER] circleci
[HOME] /home/circleci

====>> sudo node whoami.js
[USER] root
[HOME] /root

GitHub - baco16g/demo-npm-userdo

npm/cliを読んでみる

npm/cliのコードリーディングをして具体的な原因を探りましょう。

package.jsonのmainにはcli/index.jsが指定されているので、まずはこのファイルから見てみます。このファイルでは、さらに./lib/cli.jsをrequireでロードしています。

./lib/cli.jsでは、コマンドライン引数を利用して、npm.execを実行しています。

module.exports = async process => {
  ... // 省略
  const Npm = require('./npm.js')
  const npm = new Npm()
  ... // 省略
  cmd = npm.argv.shift()
  ... // 省略
  await npm.exec(cmd, npm.argv)

cli/cli.js at d8aac8448e983692cacb427e03f4688cd1b62e30 · npm/cli · GitHub

次に./npm.jsnpm.execを見てみましょう。./commands/${command}.jsで対象コマンドのファイルをrequireしていますね。

async cmd (cmd) {
  ... // 省略
  const command = this.deref(cmd)
  ... // 省略
  const Impl = require(`./commands/${command}.js`)
  const impl = new Impl(this)
  return impl
}
async exec (cmd, args) {
  const command = await this.cmd(cmd)
  ... // 省略
  command.exec(args)
}

cli/npm.js at d8aac8448e983692cacb427e03f4688cd1b62e30 · npm/cli · GitHub

今回は、runコマンドを追いたいので、./commands/run-script.jsを見てみましょう。最終的にrunScriptを実行しています。

const runScript = require('@npmcli/run-script')
async exec (args) {
  ... // 省略
  return this.run(args)
}
async run ([event, ...args], { path = this.npm.localPrefix, pkg } = {}) {
  ... // 省略
  for (const [event, args] of events) {
      await runScript({
        ...opts,
        event,
        args,
      })
    }
}

cli/run-script.js at d8aac8448e983692cacb427e03f4688cd1b62e30 · npm/cli · GitHub

@npmcli/run-scriptを使用・実行しているので、そちらを見てみます。

const runScriptPkg = require('./run-script-pkg.js')
... // 省略

const runScript = options => {
  validateOptions(options)
  const {pkg, path} = options
  return pkg ? runScriptPkg(options)
    : rpj(path + '/package.json').then(pkg => runScriptPkg({...options, pkg}))
}

cli/run-script.js at d8aac8448e983692cacb427e03f4688cd1b62e30 · npm/cli · GitHub

runScriptPkgでは、promiseSpawnでchild_procesを生成しています。spawnというキーワードから察するに、真相に近づいていそうです。

const promiseSpawn = require('@npmcli/promise-spawn')
  ... // 省略
const runScriptPkg = async options => {
  ... // 省略
  const p = promiseSpawn(...makeSpawnArgs({
    event,
    path,
    scriptShell,
    env: packageEnvs(env, pkg),
    stdio,
    cmd,
    stdioString,
  }), {
    event,
    script: cmd,
    pkgid: pkg._id,
    path,
  })
}
... // 省略

cli/run-script-pkg.js at d8aac8448e983692cacb427e03f4688cd1b62e30 · npm/cli · GitHub

ついに原因と思われるコードを発見しました。process.getuidの返り値が0、つまりスーパーユーザーであれば、inferOwner.sync(cwd)を実行して得られたuidgidを子プロセスに流しています。

const inferOwner = require('infer-owner')
const promiseSpawn = (cmd, args, opts, extra = {}) => {
  const cwd = opts.cwd || process.cwd()
  const isRoot = process.getuid && process.getuid() === 0
  const { uid, gid } = isRoot ? inferOwner.sync(cwd) : {}
  return promiseSpawnUid(cmd, args, {
    ...opts,
    cwd,
    uid,
    gid
  }, extra)
}

リリースノートを確認すると、v7.0.0-beta.0 (2020-08-04)に以下の記述がありました。

The user, group, uid, gid, and unsafe-perms configurations are no longer relevant. When npm is run as root, scripts are always run with the effective uid and gid of the working directory owner.

この修正に関するIssueやPull Requestなどを見つけることはできませんでしたが、理由は納得はできます。もしも、実行対象のパッケージに悪意のあるスクリプトが含まれていた場合、スーパーユーザーの権限で実行するのは危険です。

結論

npm run generateをスーパーユーザーとして実行する必要がないと判断して、circleciユーザーで実行するように変更を加え、解消させました。

SENTRY_DSN=$SENTRY_DSN SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN npm run generate

npmのドキュメントでは太古から以下の注意が記載されているため、npmのバージョンに依らず、sudoを利用したscriptの実行は避けるべきです。

Don't prefix your script commands with "sudo". If root permissions are required for some reason, then it'll fail with that error, and the user will sudo the npm command in question.

docs.npmjs.com