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/`|@everyone\n.p12'); + const head = commit(dir, 'add'); + const result = await run(dir, { BASE_SHA: base, HEAD_SHA: head }); + assert.equal(result.failed.length, 1); + assert.ok(!result.summary.includes(' { + const { dir: source, base } = repo(); + write(source, 'a.keystore'); + write(source, 'big.txt', 'y'.repeat(100000)); + const head = commit(source, 'add'); + git(source, 'config', 'uploadpack.allowFilter', 'true'); + const clone = fs.mkdtempSync(path.join(os.tmpdir(), 'key-files-clone-')); + git(clone, 'clone', '-q', '--filter=blob:none', '--no-checkout', 'file://' + source, '.'); + git(clone, 'fetch', '-q', 'origin', 'feature'); + // リモートに取りに行けば失敗するようにしておく。 + git(clone, 'remote', 'set-url', 'origin', 'file:///nonexistent'); + const result = await run(clone, { BASE_SHA: base, HEAD_SHA: head }); + assert.deepEqual(result.errors.map((error) => error.properties.file), ['a.keystore']); +});