ソースコードや設定ファイルに含まれるAPIトークンやパスワードなどの機密情報を見つけるSecretlintのv13.0.0をリリースしました。

このバージョンの主な変更点は次の3つです。

  • ファイル探索時に.gitignoreをデフォルトで尊重するように変更(Breaking Change)
  • グロブメタ文字を含むパスが実在する場合はリテラルとして扱うように変更
  • Tailscale/Stripeの検出ルールを新規追加、CloudflareをcanaryからrecommendへPromote

Breaking Change: .gitignoreをデフォルトで尊重

v13.0.0では、ファイル探索時に.gitignoreの内容をデフォルトで尊重するようになりました。 ripgrepと同じ挙動で、ネストされた.gitignoreファイルもサブディレクトリへカスケードして適用されます。深い階層のネガティブルール(!)で上位の判定を上書きできます。

.gitignoreに一致したファイルはスキャン対象から除外されます。 そのため、これまでdist/や生成物などを意図的にスキャンしていたプロジェクトでは、出力されるファイル数が減ります。

.secretlintignoreはこれまで通り併用できます。

v12までの挙動に戻したい場合は、--no-gitignoreオプションを指定します。

secretlint --no-gitignore "**/*"

.gitignoreに一致しているはずのファイルが結果に含まれている場合は、Issueで報告してください。

実装: @secretlint/walker

v13.0.0では、ファイル探索の実装をglobbyから、新しく追加した@secretlint/walkerへ置き換えました。

設計はplanドキュメントとしてリポジトリにコミットされています。

Rustにはripgrepignore crateがあり、oxcなどはこれを使うことで.gitignoreのカスケードを安価に再利用できます。 JavaScript側にも、ネストされた.gitignoreに対応するwalkerが全く無いわけではありません(tiny-readdir-glob-gitignoreignore-walkなど)。 ただし、ripgrepのignore crateほど枯れた実績や仕様の網羅度を持つものは見当たりませんでした。 また、後述する「グロブメタ文字を含むパスが実在する場合はリテラル扱いにする」のように、走査・マッチ側に独自の制御を入れたい要件もあります。 依存ライブラリもnode-ignorepicomatchまで分解すればfs.readdirの上に薄く書ける範囲だったため、Secretlint側でwalkerを実装することにしました。

@secretlint/walkerは、ネストされた.gitignoreのカスケードに対応するPromiseベースのファイルシステムwalkerです。 依存ライブラリはignore(node-ignore)とpicomatchの2つだけで、含めるパターン(include)と除外するパターン(ignore)でセマンティクスを分離しています。

  • 含めるパターン(include): picomatchを使ったグロブマッチ。**/*.{ts,js}のようなブレース展開やドットファイルのマッチに対応する
  • 除外するパターン(ignore): node-ignoreを使った.gitignoreセマンティクスのマッチ。.gitignoreの挙動に合わせるため、ブレース展開は意図的に対応しない

Node.js本体にもfs.globpath.matchesGlobが用意されていますが、これらにはドットファイルをマッチに含めるdotオプションが存在しません。 ドットファイル(.envなど)のスキャンが要件として外せないため、include側はpicomatchにしています。

走査自体はfs.readdir(dir, { withFileTypes: true })をベースにしたシンプルな再帰で、各ディレクトリのエントリをPromise.allで並列処理します。 ディレクトリ単位でignoreを判定し、無視対象に該当したディレクトリはサブツリーごと走査をやめます。 readdir自体を呼ばないため、大きなnode_modules配下などをまるごとスキップできます。

.gitignoreのカスケードはIgnoreStackという構造で扱います。 親ディレクトリのignoreインスタンスに対して、現在のディレクトリの.gitignoreを読み込んでextendIgnore()で重ねるという形でレイヤーを積みます。 .gitignoreがそのディレクトリに存在しない場合は親のインスタンスをそのまま返すため、不要なallocationが起きません。 深い階層のネガティブルール(!pattern)が浅い階層のルールを上書きする挙動も、このスタック上で自然に表現されます。 ネストされた.gitignore内のパターンはそのファイルの置かれたディレクトリにアンカーされます。 そのため、packages/foo/.gitignoresrc/**/*.tsはリポジトリルートではなくpackages/foo/src/配下にだけ適用されます。

クロスプラットフォーム対応として、node-ignoreへ渡すパスはWindowsでも/区切りに正規化(toPosix())した上でマッチングします。 返すパスはOSネイティブの区切り文字に戻します。 また、ENOENTEACCESは探索全体を止めずにスキップする方針で、ファイル削除・パーミッションエラーに対して頑健になっています。

入力パターンは静的なプレフィックス(walk root)と動的なサフィックス(マッチパターン)に分割し、同じrootを持つパターンはグループ化して1度のwalkで処理します。 グロブメタ文字を含む入力でも、それが実際のファイル/ディレクトリとして存在する場合はnoGlob相当の扱いにフォールバックさせるロジックもwalker側に入っています。

主なAPIはwalk(options)で、パッケージ単独でも利用できます。

オプション デフォルト 説明
cwd string 必須 探索の起点ディレクトリ
patterns string[] undefined インクルードのグロブパターン
ignoreFiles string[] [] カスケード対象のignoreファイル名(.gitignoreなど)
extraIgnorePatterns string[] [] コードから渡す追加のignoreパターン
noGlob boolean false patternsをリテラルとして扱う
followSymlinks boolean true シンボリックリンクを追従するか

CLI側のsecretlintでは、--no-gitignoreが指定された場合はignoreFilesから.gitignoreを外し、それ以外の場合はカスケード有効でwalkします。 .secretlintignoreは別経路のextraIgnorePatterns相当で従来通り適用されるため、.gitignoreとの共存に影響はありません。

パフォーマンス

walker単体の実行時間で見ると、globbyからの置き換えによるパフォーマンスの大きな劣化はありません。 一方で、.gitignoreのカスケードは「親のルールに子のルールを正しく重ねる」ことを満たさないと挙動が壊れる部分なので、ここはripgrepの実装を参考にしながら書いています。

実際のSecretlintの実行時間で見ると、.gitignoreを尊重することでnode_modules/dist/などをそもそもスキャンしなくなるため、Lint対象のファイル数が減ります。 スキャン+ルール評価のコストはファイル数に比例して効いてくるため、walker自体のコストよりもLint対象が減ることによる削減のほうが支配的になります。 結果として、v12より速く終わるケースが多くなる想定です。

グロブメタ文字を含むパスが実在する場合はリテラル扱いに

Secretlintはコマンドライン引数をデフォルトでグロブパターンとして解釈します。 v13.0.0では、グロブメタ文字(()[]{}?)を含むパスが実際にファイル/ディレクトリとして存在する場合は、リテラルとして扱うように変更しました。

これは、SvelteKitやNext.jsのRoute Groupなど、ディレクトリ名に()[]を含むプロジェクトで特に有効です。

パターン ディスク上 v12のデフォルト v13のデフォルト
src/(group)/page.tsx 存在する グロブとして解釈、マッチしない リテラルとしてマッチ
src/(missing)/page.tsx 存在しない グロブとして解釈 グロブとして解釈
src/[a-z]ormal.tsx normal.tsxが存在 グロブ経由でマッチ グロブ経由でマッチ

入力ごとに1度だけstatを実行し、存在すればリテラル、存在しなければ従来通りグロブとして扱います。 従来通り常にリテラルとして扱いたい場合は、--no-globオプションを指定します。

新しく追加された検出ルール

@secretlint/secretlint-rule-preset-recommendに、次の3つのルールが追加されました。

  • Tailscale - Tailscale APIキー(新規追加)
  • Stripe - Stripe APIキー(新規追加)
  • Cloudflare - Cloudflare APIトークン(canaryから昇格)

関連PRは次のとおりです。

@secretlint/secretlint-rule-preset-recommendを使っている場合は、v13.0.0にアップデートすると自動的にこれらのルールが有効になります。

まとめ

Secretlint v13.0.0では、ファイル探索が.gitignoreをデフォルトで尊重するようになりました。 そのため、dist/などをスキャンしていたプロジェクトでは、--no-gitignoreへの切り替えや.secretlintignoreの見直しが必要になります。

これに加えて、グロブメタ文字を含む実在パスをリテラルとして扱うよう調整し、Route Groupなどのディレクトリ構成でもオプションなしに動作します。 検出ルールにはTailscale/Stripe/Cloudflareを追加しています。

フィードバックがあればGitHubのIssueでお知らせください。