Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions .github/workflows/betterleaks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '| <code>' + escape(item.file) + '</code> | <code>' + item.commit.slice(0, 7) + '</code> |';
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'
+ '<pre>' + fingerprints.join('\n') + '</pre>';
try {
await core.summary.addRaw(body).write();
} catch {
core.warning('Summary を書き込めませんでした。検出箇所はこのステップのエラー表示で確認してください。');
}
// END key-file-check

scan:
if: true
name: Scan for secrets
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
171 changes: 171 additions & 0 deletions tests/key-files.test.cjs
Original file line number Diff line number Diff line change
@@ -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/<img src=a>`|@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('<img'));
assert.ok(!result.summary.includes('@everyone'));
for (const env of [{ BASE_SHA: 'main', HEAD_SHA: head }, { BASE_SHA: base, HEAD_SHA: '' },
{ BASE_SHA: base, HEAD_SHA: 'f'.repeat(40) }]) {
const invalid = await run(dir, env);
assert.equal(invalid.failed.length, 1);
assert.match(invalid.failed[0], /完了できませんでした/);
}
fs.symlinkSync('/etc/hosts', path.join(dir, '.betterleaksignore'));
assert.match((await run(dir, { BASE_SHA: base, HEAD_SHA: head })).failed[0], /完了できませんでした/);
});

test('works in a blobless clone without fetching file contents', async () => {
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']);
});