はじめに
フロントエンドエンジニアの小林(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の生成を行なっています。
しかし、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 } ... // 省略 }
さらに深ぼってみましょう。@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 } } ... // 省略 }
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
ここまでコードリーディングした限り、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.js
のnpm.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)
を実行して得られたuid
とgid
を子プロセスに流しています。
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.