diff --git a/.github/workflows/betterleaks.yml b/.github/workflows/betterleaks.yml
index 0938be9..3223d5a 100644
--- a/.github/workflows/betterleaks.yml
+++ b/.github/workflows/betterleaks.yml
@@ -18,6 +18,112 @@ env:
BETTERLEAKS_SHA256: f8b185a39ffcece2a1ca82bf3a4e7435cd81963ffd16b7a9128daf75f35f6de7
jobs:
+ # Betterleaks はバイナリのファイルを読み飛ばすので、証明書や鍵ストアは拡張子で検出する。
+ key-files:
+ if: true
+ name: Check for key files
+ runs-on: ubuntu-slim
+ timeout-minutes: 10
+ steps:
+ # 要るのはファイル名の一覧と除外ファイルだけなので、sparse checkout にしてファイルの中身を取得しない。
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }}
+ fetch-depth: 0
+ sparse-checkout: |
+ /.betterleaksignore
+ /.gitleaksignore
+ sparse-checkout-cone-mode: false
+ persist-credentials: false
+
+ - name: Check added key and certificate files
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }}
+ with:
+ script: |
+ // BEGIN key-file-check
+ const fs = require('node:fs');
+ const { execFileSync } = require('node:child_process');
+ const rule = 'binary-key-file';
+ const keyFile = /\.(?:p12|pfx|pkcs12|jks|keystore|bks|jceks)$/i;
+ const sha = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
+ const escape = (value) => String(value).replace(/[&<>"'`|\[\]\\*_~@]/g,
+ (char) => '' + char.charCodeAt(0) + ';');
+ // ファイルの中身が要る操作で、リモートへ取りに行かないようにする。
+ const git = (args) => execFileSync('git', args, {
+ encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
+ env: { ...process.env, GIT_NO_LAZY_FETCH: '1', GIT_TERMINAL_PROMPT: '0' },
+ });
+ const found = [];
+ const ignored = new Set();
+ try {
+ const base = process.env.BASE_SHA;
+ const head = process.env.HEAD_SHA;
+ if (!sha.test(base) || !sha.test(head)) throw new Error('Invalid base or head');
+ // PR 内の各コミットを見る。後のコミットで消しても、追加したコミットは GitHub に残るため。
+ for (const commit of git(['rev-list', '--no-merges', base + '..' + head]).split('\n').filter(Boolean)) {
+ if (!sha.test(commit)) throw new Error('Invalid commit');
+ const files = git(['diff-tree', '--root', '-r', '--no-commit-id', '--no-renames',
+ '--diff-filter=AM', '--name-only', '-z', commit]).split('\0').filter(Boolean);
+ for (const file of files) {
+ if (keyFile.test(file)) found.push({ commit, file });
+ }
+ }
+ // Betterleaks と同じ順で除外ファイルを探す。
+ const ignoreFile = ['.betterleaksignore', '.gitleaksignore'].find((name) => fs.existsSync(name));
+ if (ignoreFile) {
+ const stat = fs.lstatSync(ignoreFile);
+ if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error('Invalid ignore file');
+ for (const line of fs.readFileSync(ignoreFile, 'utf8').split(/\r?\n/)) {
+ const entry = line.trim();
+ if (entry && !entry.startsWith('#')) ignored.add(entry);
+ }
+ }
+ } catch (error) {
+ core.setFailed('鍵や証明書のファイルの検査を完了できませんでした。CI ログを確認して再実行してください。('
+ + String(error.message).slice(0, 500) + ')');
+ return;
+ }
+ // 除外はコミットごとに書く。同じパスのファイルを後で本物の鍵に差し替えても、検出されるようにするため。
+ const fingerprint = (item) => [item.commit, item.file, rule, 0].join(':');
+ const remaining = found.filter((item) => !ignored.has(fingerprint(item)));
+ if (remaining.length === 0) return;
+ core.setFailed('鍵や証明書のファイルが ' + remaining.length + ' 件見つかりました。対処方法は Summary を確認してください。');
+ const rows = [];
+ const fingerprints = [];
+ let bytes = 0;
+ for (const item of remaining) {
+ const row = '| ' + escape(item.file) + ' | ' + item.commit.slice(0, 7) + ' |';
+ const entry = escape(fingerprint(item));
+ bytes += Buffer.byteLength(row + entry);
+ if (rows.length >= 50 || bytes > 40000) break;
+ core.error('鍵や証明書のファイルが追加・変更されています: ' + item.file, { title: 'Key or certificate file', file: item.file });
+ rows.push(row);
+ fingerprints.push(entry);
+ }
+ let body = '### 鍵や証明書のファイルが ' + remaining.length + ' 件見つかりました\n\n'
+ + '証明書や鍵ストアのファイル(.p12、.pfx、.jks、.keystore など)は、中身を Betterleaks で検査できないため、拡張子で検出しています。\n\n'
+ + '| ファイル | コミット |\n| --- | --- |\n' + rows.join('\n') + '\n\n';
+ if (rows.length < remaining.length) {
+ body += '先頭 ' + rows.length + ' 件を表示しています。\n\n';
+ }
+ body += '#### 本物の鍵や証明書の場合\n\n'
+ + 'まず発行元で失効・再発行し、該当コミットから取り除いてください。コミットから消すだけでは足りません。push した時点で GitHub に届いているためです。\n\n'
+ + 'PR 内の過去コミットも検査します。後続コミットでファイルを削除しても、過去の検出は残ります。\n\n'
+ + '#### リポジトリに置いてよいファイルの場合\n\n'
+ + '開発用の署名鍵のように公開してよいファイルなら、リポジトリ直下の `.betterleaksignore` に、次の識別子を1行ずつ追記してください。'
+ + '除外理由を PR に記載し、レビューを受けてください。\n\n'
+ + '
' + fingerprints.join('\n') + '';
+ try {
+ await core.summary.addRaw(body).write();
+ } catch {
+ core.warning('Summary を書き込めませんでした。検出箇所はこのステップのエラー表示で確認してください。');
+ }
+ // END key-file-check
+
scan:
if: true
name: Scan for secrets
diff --git a/README.md b/README.md
index ab22ca9..b1cff01 100644
--- a/README.md
+++ b/README.md
@@ -63,3 +63,28 @@ Node.js で、workflow 内のコメント生成・投稿処理とスキャンの
```sh
node --test tests/betterleaks.test.cjs
```
+
+## 鍵や証明書のファイルの検査(`key-files` ジョブ)
+
+Betterleaks はバイナリのファイルを読み飛ばすので、証明書や鍵ストアの中身は検査できません。
+そこで、同じ workflow の `key-files` ジョブで、次の拡張子のファイルを PR のコミットで追加・変更していないかを検査します。
+
+`.p12`、`.pfx`、`.pkcs12`、`.jks`、`.keystore`、`.bks`、`.jceks`(大文字と小文字は区別しません)
+
+見つかるとジョブが失敗します。PR 内の各コミットを見るので、後続のコミットでファイルを削除しても検出は残ります。
+すでにリポジトリにあるファイルは、PR で変更しない限り検出しません。
+
+- 本物の鍵や証明書なら、まず発行元で失効・再発行してから、コミットから取り除きます。
+- 開発用の署名鍵のようにリポジトリに置いてよいファイルなら、`.betterleaksignore` に次の形式で1行を追記します。行は Actions の Summary からコピーできます。
+
+ ```text
+ <コミットのSHA>:<ファイルのパス>:binary-key-file:0
+ ```
+
+ ファイルのパスだけの除外(コミットの SHA を省いた形)は受け付けません。同じパスのファイルを、あとで本物の鍵に差し替えても検出するためです。
+
+このジョブはファイル名の一覧だけを使うので、checkout ではファイルの中身を取得しません(sparse checkout)。
+
+```sh
+node --test tests/key-files.test.cjs
+```
diff --git a/tests/key-files.test.cjs b/tests/key-files.test.cjs
new file mode 100644
index 0000000..20edbf3
--- /dev/null
+++ b/tests/key-files.test.cjs
@@ -0,0 +1,171 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { execFileSync } = require('node:child_process');
+const { runInNewContext } = require('node:vm');
+
+// workflow 内のスクリプトそのものを、一時的な git リポジトリに対して動かす。
+const workflow = fs.readFileSync(path.join(__dirname, '../.github/workflows/betterleaks.yml'), 'utf8');
+const script = workflow.split('// BEGIN key-file-check\n')[1].split('// END key-file-check')[0]
+ .replace(/^ {12}/gm, '');
+
+const gitEnv = {
+ ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1',
+ GIT_AUTHOR_NAME: 'test', GIT_AUTHOR_EMAIL: 'test@example.com',
+ GIT_COMMITTER_NAME: 'test', GIT_COMMITTER_EMAIL: 'test@example.com',
+};
+function git(cwd, ...args) {
+ return execFileSync('git', args, { cwd, env: gitEnv, encoding: 'utf8' }).trim();
+}
+function write(dir, file, content = 'x') {
+ fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
+ fs.writeFileSync(path.join(dir, file), content);
+}
+function commit(dir, message) {
+ git(dir, 'add', '-A');
+ git(dir, 'commit', '-q', '--allow-empty', '-m', message);
+ return git(dir, 'rev-parse', 'HEAD');
+}
+function repo() {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'key-files-'));
+ git(dir, 'init', '-q', '-b', 'main');
+ write(dir, 'README.md');
+ // 既存の鍵ファイルは、PR で触らない限り検出しない。
+ write(dir, 'app/development.keystore');
+ const base = commit(dir, 'base');
+ git(dir, 'switch', '-q', '-c', 'feature');
+ return { dir, base };
+}
+
+async function run(cwd, env) {
+ const result = { failed: [], errors: [], warnings: [], summary: undefined };
+ const core = {
+ setFailed: (message) => result.failed.push(message),
+ error: (message, properties) => result.errors.push({ message, properties }),
+ warning: (message) => result.warnings.push(message),
+ summary: { addRaw(value) { result.summary = value; return this; }, async write() {} },
+ };
+ const previous = process.cwd();
+ process.chdir(cwd);
+ try {
+ await runInNewContext('(async () => {\n' + script + '\n})()', {
+ require, Buffer, core, process: { env: { ...gitEnv, ...env } },
+ });
+ } finally {
+ process.chdir(previous);
+ }
+ return result;
+}
+
+test('no key files passes without output', async () => {
+ const { dir, base } = repo();
+ write(dir, 'src/app.rb');
+ const head = commit(dir, 'normal change');
+ const result = await run(dir, { BASE_SHA: base, HEAD_SHA: head });
+ assert.deepEqual(result.failed, []);
+ assert.equal(result.summary, undefined);
+});
+
+test('added, modified, renamed and later deleted key files fail', async () => {
+ const { dir, base } = repo();
+ write(dir, 'certs/push.p12');
+ write(dir, 'android/release.JKS');
+ write(dir, 'notes.txt');
+ const added = commit(dir, 'add');
+ write(dir, 'app/development.keystore', 'changed');
+ const modified = commit(dir, 'modify');
+ git(dir, 'mv', 'certs/push.p12', 'certs/apns.pfx');
+ const renamed = commit(dir, 'rename');
+ git(dir, 'rm', '-q', 'android/release.JKS');
+ const head = commit(dir, 'delete');
+ const result = await run(dir, { BASE_SHA: base, HEAD_SHA: head });
+ assert.equal(result.failed.length, 1);
+ assert.match(result.failed[0], /4 件/);
+ assert.deepEqual(result.errors.map((error) => error.properties.file).sort(),
+ ['android/release.JKS', 'app/development.keystore', 'certs/apns.pfx', 'certs/push.p12']);
+ for (const entry of [added + ':certs/push.p12', added + ':android/release.JKS',
+ modified + ':app/development.keystore', renamed + ':certs/apns.pfx']) {
+ assert.ok(result.summary.includes(entry + ':binary-key-file:0'), entry);
+ }
+ assert.ok(!result.summary.includes('notes.txt'));
+});
+
+test('only exact per-commit entries in the ignore file exclude a finding', async () => {
+ const { dir, base } = repo();
+ write(dir, 'a.p12');
+ write(dir, 'b.p12');
+ const added = commit(dir, 'add');
+ write(dir, '.betterleaksignore', [
+ '# 開発用',
+ added + ':a.p12:binary-key-file:0',
+ 'b.p12:binary-key-file:0',
+ ].join('\n'));
+ const head = commit(dir, 'ignore');
+ const result = await run(dir, { BASE_SHA: base, HEAD_SHA: head });
+ assert.deepEqual(result.errors.map((error) => error.properties.file), ['b.p12']);
+ // パスだけの除外は受け付けない。同じパスで本物の鍵に差し替えられても検出するため。
+ write(dir, '.betterleaksignore', added + ':a.p12:binary-key-file:0\n' + added + ':b.p12:binary-key-file:0\n');
+ write(dir, 'b.p12', 'replaced');
+ const replaced = commit(dir, 'replace');
+ const again = await run(dir, { BASE_SHA: base, HEAD_SHA: replaced });
+ assert.deepEqual(again.errors.map((error) => error.properties.file), ['b.p12']);
+ assert.ok(again.summary.includes(replaced + ':b.p12:binary-key-file:0'));
+});
+
+test('falls back to .gitleaksignore like Betterleaks', async () => {
+ const { dir, base } = repo();
+ write(dir, 'a.jks');
+ const added = commit(dir, 'add');
+ write(dir, '.gitleaksignore', added + ':a.jks:binary-key-file:0\n');
+ const head = commit(dir, 'ignore');
+ assert.deepEqual((await run(dir, { BASE_SHA: base, HEAD_SHA: head })).failed, []);
+});
+
+test('merging the base branch does not report key files that are already there', async () => {
+ const { dir, base } = repo();
+ write(dir, 'src/app.rb');
+ commit(dir, 'feature');
+ git(dir, 'switch', '-q', 'main');
+ write(dir, 'legacy.bks');
+ const newBase = commit(dir, 'base adds key');
+ git(dir, 'switch', '-q', 'feature');
+ git(dir, 'merge', '-q', '--no-edit', 'main');
+ const head = git(dir, 'rev-parse', 'HEAD');
+ assert.notEqual(base, newBase);
+ assert.deepEqual((await run(dir, { BASE_SHA: newBase, HEAD_SHA: head })).failed, []);
+});
+
+test('untrusted file names are escaped and invalid inputs fail closed', async () => {
+ const { dir, base } = repo();
+ write(dir, 'x/