promptkaizen-agent 0.6.3 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/dist/analyze/index.d.ts.map +1 -1
- package/dist/analyze/index.js +1 -1
- package/dist/analyze/index.js.map +1 -1
- package/dist/analyze/optimize-rules-fetcher.d.ts +1 -2
- package/dist/analyze/optimize-rules-fetcher.d.ts.map +1 -1
- package/dist/analyze/optimize-rules-fetcher.js +5 -2
- package/dist/analyze/optimize-rules-fetcher.js.map +1 -1
- package/dist/batcher.d.ts +18 -1
- package/dist/batcher.d.ts.map +1 -1
- package/dist/batcher.js +77 -36
- package/dist/batcher.js.map +1 -1
- package/dist/cli/disable-hooks.d.ts +2 -0
- package/dist/cli/disable-hooks.d.ts.map +1 -0
- package/dist/cli/disable-hooks.js +117 -0
- package/dist/cli/disable-hooks.js.map +1 -0
- package/dist/cli/hook.d.ts.map +1 -1
- package/dist/cli/hook.js +15 -5
- package/dist/cli/hook.js.map +1 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +4 -2
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/mcp.js +1 -1
- package/dist/cli/mcp.js.map +1 -1
- package/dist/cli/start.d.ts.map +1 -1
- package/dist/cli/start.js +11 -3
- package/dist/cli/start.js.map +1 -1
- package/dist/cli/status.d.ts.map +1 -1
- package/dist/cli/status.js +15 -1
- package/dist/cli/status.js.map +1 -1
- package/dist/config.d.ts +71 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +76 -3
- package/dist/config.js.map +1 -1
- package/dist/detection/engine.d.ts +149 -0
- package/dist/detection/engine.d.ts.map +1 -0
- package/dist/detection/engine.js +0 -0
- package/dist/detection/engine.js.map +1 -0
- package/dist/detection/index.d.ts +3 -0
- package/dist/detection/index.d.ts.map +1 -0
- package/dist/detection/index.js +24 -0
- package/dist/detection/index.js.map +1 -0
- package/dist/detection/rules.d.ts +103 -0
- package/dist/detection/rules.d.ts.map +1 -0
- package/dist/detection/rules.js +586 -0
- package/dist/detection/rules.js.map +1 -0
- package/dist/hooks/claude-hook.d.ts +31 -4
- package/dist/hooks/claude-hook.d.ts.map +1 -1
- package/dist/hooks/claude-hook.js +131 -16
- package/dist/hooks/claude-hook.js.map +1 -1
- package/dist/hooks/hook-pause.d.ts +34 -0
- package/dist/hooks/hook-pause.d.ts.map +1 -0
- package/dist/hooks/hook-pause.js +111 -0
- package/dist/hooks/hook-pause.js.map +1 -0
- package/dist/hooks/pretooluse-policy.d.ts +34 -0
- package/dist/hooks/pretooluse-policy.d.ts.map +1 -0
- package/dist/hooks/pretooluse-policy.js +97 -0
- package/dist/hooks/pretooluse-policy.js.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/mask-rules-fetcher.d.ts +5 -0
- package/dist/mask-rules-fetcher.d.ts.map +1 -1
- package/dist/mask-rules-fetcher.js +9 -2
- package/dist/mask-rules-fetcher.js.map +1 -1
- package/dist/pii-masker.d.ts.map +1 -1
- package/dist/pii-masker.js.map +1 -1
- package/dist/secret-detector.d.ts +51 -27
- package/dist/secret-detector.d.ts.map +1 -1
- package/dist/secret-detector.js +108 -439
- package/dist/secret-detector.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/suppress-sea-warning.d.ts +0 -2
- package/dist/suppress-sea-warning.d.ts.map +0 -1
- package/dist/suppress-sea-warning.js +0 -21
- package/dist/suppress-sea-warning.js.map +0 -1
package/dist/secret-detector.js
CHANGED
|
@@ -1,120 +1,48 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.SecretDetector = exports.MAX_DETECTIONS_PER_ENTRY = void 0;
|
|
4
|
-
exports.normalizeForDetection = normalizeForDetection;
|
|
5
|
-
exports.luhnValid = luhnValid;
|
|
6
|
-
exports.isValidMyNumber = isValidMyNumber;
|
|
7
|
-
exports.isValidCorporateNumber = isValidCorporateNumber;
|
|
8
|
-
exports.shannonEntropy = shannonEntropy;
|
|
3
|
+
exports.SecretDetector = exports.MAX_DETECTIONS_PER_ENTRY = exports.normalizeWithMap = exports.isLikelyCredential = exports.shannonEntropy = exports.isValidCorporateNumber = exports.isValidMyNumber = exports.luhnValid = exports.normalizeForDetection = void 0;
|
|
9
4
|
exports.maskMatch = maskMatch;
|
|
10
5
|
exports.hashMatch = hashMatch;
|
|
11
6
|
exports.hmacHash = hmacHash;
|
|
12
7
|
exports.collectStrings = collectStrings;
|
|
13
|
-
exports.isLikelyCredential = isLikelyCredential;
|
|
14
8
|
const crypto_1 = require("crypto");
|
|
9
|
+
// =============================================
|
|
10
|
+
// 流出検知 (Issue #42 / Phase S1) — 検出・記録レイヤ
|
|
11
|
+
// =============================================
|
|
12
|
+
//
|
|
13
|
+
// pii-masker.ts が「送信前に伏せる(マスキング)」なのに対し、本モジュールは
|
|
14
|
+
// 「何が貼られたかを統制側が把握する(検出・記録)」が目的。両者は別レイヤとして
|
|
15
|
+
// 同じ前処理パイプラインに差し込まれる。
|
|
16
|
+
//
|
|
17
|
+
// **ルール定義そのものは本ファイルに持たない (R1/T1)。**
|
|
18
|
+
// 正は shared/src/detection/rules.ts で、`npm run sync:detection` が
|
|
19
|
+
// agent/src/detection/ へコピーする。以前はここに独自のルール表があり、
|
|
20
|
+
// extension 側の表とドリフトして「high と判定したカード番号が伏字にならない」
|
|
21
|
+
// 事故を生んだ。本ファイルはハッシュ化・集計・キャップなど agent 固有の責務のみ扱う。
|
|
22
|
+
//
|
|
23
|
+
// 重要 (受け入れ条件): 検知結果は生機密本文を一切持たない。category・ruleId・
|
|
24
|
+
// severity・count・マスク済みプレビュー・マッチのハッシュのみを保持し、クラウドへ
|
|
25
|
+
// 送るのもこのメタデータだけ。
|
|
26
|
+
//
|
|
27
|
+
// 誤検知方針 / 閾値は agent/docs/leak-detection.md に文書化。
|
|
28
|
+
const detection_1 = require("./detection");
|
|
29
|
+
var detection_2 = require("./detection");
|
|
30
|
+
Object.defineProperty(exports, "normalizeForDetection", { enumerable: true, get: function () { return detection_2.normalizeForDetection; } });
|
|
31
|
+
Object.defineProperty(exports, "luhnValid", { enumerable: true, get: function () { return detection_2.luhnValid; } });
|
|
32
|
+
Object.defineProperty(exports, "isValidMyNumber", { enumerable: true, get: function () { return detection_2.isValidMyNumber; } });
|
|
33
|
+
Object.defineProperty(exports, "isValidCorporateNumber", { enumerable: true, get: function () { return detection_2.isValidCorporateNumber; } });
|
|
34
|
+
Object.defineProperty(exports, "shannonEntropy", { enumerable: true, get: function () { return detection_2.shannonEntropy; } });
|
|
35
|
+
Object.defineProperty(exports, "isLikelyCredential", { enumerable: true, get: function () { return detection_2.isLikelyCredential; } });
|
|
36
|
+
Object.defineProperty(exports, "normalizeWithMap", { enumerable: true, get: function () { return detection_2.normalizeWithMap; } });
|
|
15
37
|
/** 1 entry あたりの検知メタ上限。超過分は重大度順 (high>medium>low) で切り捨てる。 */
|
|
16
38
|
exports.MAX_DETECTIONS_PER_ENTRY = 50;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
// ---------------------------------------------
|
|
20
|
-
// NFKC で全角英数字・記号を半角化 (例: SK-… / 123 → SK-… / 123)。
|
|
21
|
-
// 漢数字は位取り (十/百/千) の誤変換を避け、単一桁文字のみ算用数字へ寄せる。
|
|
22
|
-
const KANJI_DIGITS = {
|
|
23
|
-
〇: '0',
|
|
24
|
-
零: '0',
|
|
25
|
-
一: '1',
|
|
26
|
-
二: '2',
|
|
27
|
-
三: '3',
|
|
28
|
-
四: '4',
|
|
29
|
-
五: '5',
|
|
30
|
-
六: '6',
|
|
31
|
-
七: '7',
|
|
32
|
-
八: '8',
|
|
33
|
-
九: '9',
|
|
34
|
-
};
|
|
35
|
-
function normalizeForDetection(text) {
|
|
36
|
-
let t = text.normalize('NFKC');
|
|
37
|
-
t = t.replace(/[〇零一二三四五六七八九]/g, (c) => KANJI_DIGITS[c] ?? c);
|
|
38
|
-
return t;
|
|
39
|
-
}
|
|
40
|
-
// ---------------------------------------------
|
|
41
|
-
// 検証ヘルパ (誤検知抑制)
|
|
42
|
-
// ---------------------------------------------
|
|
43
|
-
/** クレジットカード番号の Luhn チェック (13〜19 桁)。 */
|
|
44
|
-
function luhnValid(value) {
|
|
45
|
-
const digits = value.replace(/\D/g, '');
|
|
46
|
-
if (digits.length < 13 || digits.length > 19)
|
|
47
|
-
return false;
|
|
48
|
-
let sum = 0;
|
|
49
|
-
let alt = false;
|
|
50
|
-
for (let i = digits.length - 1; i >= 0; i--) {
|
|
51
|
-
let d = Number(digits[i]);
|
|
52
|
-
if (alt) {
|
|
53
|
-
d *= 2;
|
|
54
|
-
if (d > 9)
|
|
55
|
-
d -= 9;
|
|
56
|
-
}
|
|
57
|
-
sum += d;
|
|
58
|
-
alt = !alt;
|
|
59
|
-
}
|
|
60
|
-
return sum % 10 === 0;
|
|
61
|
-
}
|
|
62
|
-
/** マイナンバー (個人番号, 12 桁) のチェックデジット検証。 */
|
|
63
|
-
function isValidMyNumber(value) {
|
|
64
|
-
const d = value.replace(/\D/g, '');
|
|
65
|
-
if (!/^\d{12}$/.test(d))
|
|
66
|
-
return false;
|
|
67
|
-
const body = d.slice(0, 11);
|
|
68
|
-
const check = Number(d[11]);
|
|
69
|
-
let sum = 0;
|
|
70
|
-
for (let n = 1; n <= 11; n++) {
|
|
71
|
-
const pn = Number(body[11 - n]); // body 最下位を 1 桁目
|
|
72
|
-
const qn = n <= 6 ? n + 1 : n - 5;
|
|
73
|
-
sum += pn * qn;
|
|
74
|
-
}
|
|
75
|
-
const r = sum % 11;
|
|
76
|
-
const expected = r <= 1 ? 0 : 11 - r;
|
|
77
|
-
return expected === check;
|
|
78
|
-
}
|
|
79
|
-
/** 法人番号 (13 桁) のチェックデジット検証 (先頭 1 桁が検査数字)。 */
|
|
80
|
-
function isValidCorporateNumber(value) {
|
|
81
|
-
const d = value.replace(/\D/g, '');
|
|
82
|
-
if (!/^\d{13}$/.test(d))
|
|
83
|
-
return false;
|
|
84
|
-
const check = Number(d[0]);
|
|
85
|
-
const body = d.slice(1); // 12 桁
|
|
86
|
-
let sum = 0;
|
|
87
|
-
for (let n = 1; n <= 12; n++) {
|
|
88
|
-
const pn = Number(body[12 - n]);
|
|
89
|
-
const qn = n % 2 === 1 ? 1 : 2;
|
|
90
|
-
sum += pn * qn;
|
|
91
|
-
}
|
|
92
|
-
const r = sum % 9;
|
|
93
|
-
const expected = 9 - r;
|
|
94
|
-
return expected === check;
|
|
95
|
-
}
|
|
96
|
-
/** Shannon エントロピー (bits/char)。高エントロピー文字列の「シークレットらしさ」。 */
|
|
97
|
-
function shannonEntropy(s) {
|
|
98
|
-
if (s.length === 0)
|
|
99
|
-
return 0;
|
|
100
|
-
const freq = {};
|
|
101
|
-
for (const ch of s)
|
|
102
|
-
freq[ch] = (freq[ch] ?? 0) + 1;
|
|
103
|
-
let e = 0;
|
|
104
|
-
for (const k of Object.keys(freq)) {
|
|
105
|
-
const p = freq[k] / s.length;
|
|
106
|
-
e -= p * Math.log2(p);
|
|
107
|
-
}
|
|
108
|
-
return e;
|
|
109
|
-
}
|
|
110
|
-
// エントロピー検出の閾値 (誤検知抑制のため正規表現と併用)。詳細は docs 参照。
|
|
111
|
-
const ENTROPY_MIN_LENGTH = 20;
|
|
112
|
-
const ENTROPY_THRESHOLD = 4.0; // bits/char
|
|
39
|
+
/** ブロック理由に出す抜粋の最大長。 */
|
|
40
|
+
const SNIPPET_MAX_CHARS = 120;
|
|
113
41
|
/**
|
|
114
42
|
* 復元不能なマスク済みプレビュー。
|
|
115
|
-
* - pii / pii_jp: 末尾 2
|
|
43
|
+
* - pii / pii_jp: 末尾 2 文字のみ可視。マイナンバー等の低エントロピー値は
|
|
116
44
|
* 先頭を見せると桁の大半が漏れるため、先頭は一切露出しない。
|
|
117
|
-
* - secret / custom: 先頭 4 + 末尾 2
|
|
45
|
+
* - secret / custom: 先頭 4 + 末尾 2 文字のみ可視。prefix によるキー種別の
|
|
118
46
|
* 識別性を優先 (高エントロピー値のため 6 文字露出しても復元不能)。
|
|
119
47
|
*/
|
|
120
48
|
function maskMatch(value, category) {
|
|
@@ -137,12 +65,12 @@ function hashMatch(value) {
|
|
|
137
65
|
* マイナンバー (10^12 通り) のような低エントロピー値は無塩 sha256 だと総当たりで
|
|
138
66
|
* 復元できてしまうため、ローカル鍵 (クラウドへ送らない) で鍵付きハッシュにする。
|
|
139
67
|
*/
|
|
140
|
-
function hmacHash(value,
|
|
141
|
-
return (0, crypto_1.createHmac)('sha256',
|
|
68
|
+
function hmacHash(value, secretKey) {
|
|
69
|
+
return (0, crypto_1.createHmac)('sha256', secretKey).update(value).digest('hex').slice(0, 12);
|
|
142
70
|
}
|
|
143
71
|
/**
|
|
144
72
|
* 任意のパース済み値 (entry.raw) から全 string を再帰収集して連結する。
|
|
145
|
-
* JSON.stringify
|
|
73
|
+
* JSON.stringify だとエスケープでパターンが壊れるため、生の string を集める。
|
|
146
74
|
* maxChars で総量を上限化 (巨大 entry でのメモリ/CPU 暴発を防止)。
|
|
147
75
|
*/
|
|
148
76
|
function collectStrings(value, maxChars = 200_000) {
|
|
@@ -167,251 +95,42 @@ function collectStrings(value, maxChars = 200_000) {
|
|
|
167
95
|
walk(value);
|
|
168
96
|
return parts.join('\n');
|
|
169
97
|
}
|
|
170
|
-
// ---------------------------------------------
|
|
171
|
-
// マスキング用ラベル / 補助 (Issue #104 redact 用)
|
|
172
|
-
// ---------------------------------------------
|
|
173
|
-
//
|
|
174
|
-
// redact() が伏せ字に使う置換ラベル。pii-masker.ts が従来使っていた表記を保持し
|
|
175
|
-
// (ダッシュボード / 既存テストとの整合)、未定義の ruleId は `[MASKED_<RULEID>]` に倒す。
|
|
176
|
-
const REDACTION_LABELS = {
|
|
177
|
-
aws_access_key: '[MASKED_AWS_KEY]',
|
|
178
|
-
gcp_api_key: '[MASKED_GCP_KEY]',
|
|
179
|
-
bearer_token: 'Bearer [MASKED_TOKEN]',
|
|
180
|
-
private_key_block: '[MASKED_PRIVATE_KEY]',
|
|
181
|
-
high_entropy: '[MASKED_SECRET]',
|
|
182
|
-
credit_card: '[MASKED_CREDIT_CARD]',
|
|
183
|
-
jp_mynumber: '[MASKED_MYNUMBER]',
|
|
184
|
-
generic_credential: '[MASKED_CREDENTIAL]',
|
|
185
|
-
};
|
|
186
|
-
function redactionLabel(ruleId) {
|
|
187
|
-
return REDACTION_LABELS[ruleId] ?? `[MASKED_${ruleId.toUpperCase()}]`;
|
|
188
|
-
}
|
|
189
|
-
/** start 昇順 → 長い順に整列し、貪欲に非重複なスパンだけ残す (redact 用)。 */
|
|
190
|
-
function selectNonOverlapping(spans) {
|
|
191
|
-
spans.sort((a, b) => a.start - b.start || b.end - b.start - (a.end - a.start));
|
|
192
|
-
const out = [];
|
|
193
|
-
let lastEnd = -1;
|
|
194
|
-
for (const s of spans) {
|
|
195
|
-
if (s.start >= lastEnd) {
|
|
196
|
-
out.push(s);
|
|
197
|
-
lastEnd = s.end;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
return out;
|
|
201
|
-
}
|
|
202
|
-
// 秘密鍵 (PEM) は検出ルール (private_key_block) が BEGIN 行のみを拾うのに対し、
|
|
203
|
-
// マスキングでは鍵本体ごと伏せないと平文が残る (#104)。BEGIN〜END を一括で伏せる。
|
|
204
|
-
const PEM_BLOCK = /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g;
|
|
205
|
-
// 都道府県 (住所検出用)。
|
|
206
|
-
const PREFECTURES = '北海道|青森県|岩手県|宮城県|秋田県|山形県|福島県|茨城県|栃木県|群馬県|埼玉県|千葉県|東京都|神奈川県|新潟県|富山県|石川県|福井県|山梨県|長野県|岐阜県|静岡県|愛知県|三重県|滋賀県|京都府|大阪府|兵庫県|奈良県|和歌山県|鳥取県|島根県|岡山県|広島県|山口県|徳島県|香川県|愛媛県|高知県|福岡県|佐賀県|長崎県|熊本県|大分県|宮崎県|鹿児島県|沖縄県';
|
|
207
|
-
// 汎用クレデンシャル (#204): ラベル (password/secret/token/api_key 等) に続く値を拾う。
|
|
208
|
-
// lookbehind でラベル・区切り・開き引用符を消費し、**値のみ**を m[0] にする
|
|
209
|
-
// (= 検出 preview/hash も redact もラベルを残して値だけ伏せる)。redact は生テキストに
|
|
210
|
-
// 走るため全角コロン/イコール (:=) と全角空白 (\s に含まれる) にも対応する。
|
|
211
|
-
// 高エントロピーを有効化せずに弱いパスワード直書き (password: hunter2) を伏せられる。
|
|
212
|
-
const CREDENTIAL_LABEL = '(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|client[_-]?secret)';
|
|
213
|
-
const GENERIC_CREDENTIAL_RE = new RegExp(`(?<=\\b${CREDENTIAL_LABEL}\\s*[:=:=]\\s*["']?)[^\\s"']{6,}`, 'gi');
|
|
214
|
-
/** 汎用クレデンシャルの値が「本物っぽい」か (プレースホルダ/自然文を弾く)。 */
|
|
215
|
-
function isLikelyCredential(value) {
|
|
216
|
-
const v = value.trim();
|
|
217
|
-
if (v.length < 6)
|
|
218
|
-
return false;
|
|
219
|
-
if (/^<.*>$/.test(v))
|
|
220
|
-
return false; // <your-password> 等のプレースホルダ
|
|
221
|
-
if (/^[\W_]+$/.test(v))
|
|
222
|
-
return false; // ***, ----, ____ など記号のみ
|
|
223
|
-
if (/^(.)\1{5,}$/i.test(v))
|
|
224
|
-
return false; // xxxxxx, aaaaaa など同一文字反復
|
|
225
|
-
// 末尾/先頭の句読点を除いた語幹でプレースホルダ/自然文ワードを判定。
|
|
226
|
-
const core = v
|
|
227
|
-
.replace(/^[^\p{L}\p{N}]+/u, '')
|
|
228
|
-
.replace(/[^\p{L}\p{N}]+$/u, '')
|
|
229
|
-
.toLowerCase();
|
|
230
|
-
const DENY = new Set([
|
|
231
|
-
'redacted', 'none', 'null', 'nil', 'true', 'false', 'required', 'optional',
|
|
232
|
-
'example', 'changeme', 'placeholder', 'password', 'passwd', 'secret', 'token',
|
|
233
|
-
'your_password', 'yourpassword', 'test', 'enabled', 'disabled', 'undefined', 'empty',
|
|
234
|
-
]);
|
|
235
|
-
return !DENY.has(core);
|
|
236
|
-
}
|
|
237
|
-
// 全ルールは正規化済みテキスト (NFKC + 漢数字寄せ) に対して実行する。
|
|
238
|
-
const BUILTIN_RULES = [
|
|
239
|
-
// --- シークレット ---
|
|
240
|
-
// Anthropic を OpenAR より先に: OpenAI 側は sk-ant- を negative lookahead で除外。
|
|
241
|
-
{
|
|
242
|
-
ruleId: 'anthropic_key',
|
|
243
|
-
category: 'secret',
|
|
244
|
-
severity: 'high',
|
|
245
|
-
regex: /sk-ant-(?:api|oat|ort)\d+-[A-Za-z0-9_-]{40,}/g,
|
|
246
|
-
},
|
|
247
|
-
{
|
|
248
|
-
ruleId: 'openai_key',
|
|
249
|
-
category: 'secret',
|
|
250
|
-
severity: 'high',
|
|
251
|
-
regex: /sk-(?!ant-)(?:proj-)?[A-Za-z0-9]{40,}/g,
|
|
252
|
-
},
|
|
253
|
-
{
|
|
254
|
-
ruleId: 'github_token',
|
|
255
|
-
category: 'secret',
|
|
256
|
-
severity: 'high',
|
|
257
|
-
regex: /gh[pousr]_[A-Za-z0-9]{36}/g,
|
|
258
|
-
},
|
|
259
|
-
{
|
|
260
|
-
ruleId: 'slack_token',
|
|
261
|
-
category: 'secret',
|
|
262
|
-
severity: 'high',
|
|
263
|
-
regex: /xox[baprs]-[A-Za-z0-9-]{10,}/g,
|
|
264
|
-
},
|
|
265
|
-
{
|
|
266
|
-
ruleId: 'aws_access_key',
|
|
267
|
-
category: 'secret',
|
|
268
|
-
severity: 'high',
|
|
269
|
-
regex: /\bAKIA[0-9A-Z]{16}\b/g,
|
|
270
|
-
},
|
|
271
|
-
{
|
|
272
|
-
ruleId: 'gcp_api_key',
|
|
273
|
-
category: 'secret',
|
|
274
|
-
severity: 'high',
|
|
275
|
-
regex: /\bAIza[0-9A-Za-z_-]{35}\b/g,
|
|
276
|
-
},
|
|
277
|
-
{
|
|
278
|
-
ruleId: 'private_key_block',
|
|
279
|
-
category: 'secret',
|
|
280
|
-
severity: 'high',
|
|
281
|
-
regex: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g,
|
|
282
|
-
},
|
|
283
|
-
{
|
|
284
|
-
ruleId: 'jwt',
|
|
285
|
-
category: 'secret',
|
|
286
|
-
severity: 'medium',
|
|
287
|
-
regex: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
|
|
288
|
-
},
|
|
289
|
-
{
|
|
290
|
-
ruleId: 'bearer_token',
|
|
291
|
-
category: 'secret',
|
|
292
|
-
severity: 'medium',
|
|
293
|
-
regex: /Bearer\s+[A-Za-z0-9._-]{20,}/g,
|
|
294
|
-
},
|
|
295
|
-
{
|
|
296
|
-
// #204: password: hunter2 等のラベル付きクレデンシャル (弱パスワード含む)。値のみ伏せる。
|
|
297
|
-
ruleId: 'generic_credential',
|
|
298
|
-
category: 'secret',
|
|
299
|
-
severity: 'high',
|
|
300
|
-
regex: GENERIC_CREDENTIAL_RE,
|
|
301
|
-
validate: isLikelyCredential,
|
|
302
|
-
},
|
|
303
|
-
// --- PII (汎用) ---
|
|
304
|
-
{
|
|
305
|
-
ruleId: 'email',
|
|
306
|
-
category: 'pii',
|
|
307
|
-
severity: 'low',
|
|
308
|
-
regex: /[a-zA-Z0-9._%+-]{2,}@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
|
|
309
|
-
},
|
|
310
|
-
{
|
|
311
|
-
ruleId: 'credit_card',
|
|
312
|
-
category: 'pii',
|
|
313
|
-
severity: 'high',
|
|
314
|
-
// 13〜19 桁を区切り (空白/ハイフン) 込みで候補化し Luhn 検証。
|
|
315
|
-
regex: /\b(?:\d[ -]?){12,18}\d\b/g,
|
|
316
|
-
validate: luhnValid,
|
|
317
|
-
},
|
|
318
|
-
// --- 日本語 PII (APPI の個人情報・個人識別符号) ---
|
|
319
|
-
{
|
|
320
|
-
ruleId: 'jp_mynumber',
|
|
321
|
-
category: 'pii_jp',
|
|
322
|
-
severity: 'high',
|
|
323
|
-
regex: /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g,
|
|
324
|
-
validate: isValidMyNumber,
|
|
325
|
-
},
|
|
326
|
-
{
|
|
327
|
-
ruleId: 'jp_corporate_number',
|
|
328
|
-
category: 'pii_jp',
|
|
329
|
-
severity: 'medium',
|
|
330
|
-
regex: /\b\d{13}\b/g,
|
|
331
|
-
validate: isValidCorporateNumber,
|
|
332
|
-
},
|
|
333
|
-
{
|
|
334
|
-
ruleId: 'jp_phone',
|
|
335
|
-
category: 'pii_jp',
|
|
336
|
-
severity: 'medium',
|
|
337
|
-
// 携帯 (070/080/090) / フリーダイヤル (0120,0800) / 固定 (区切り必須)。
|
|
338
|
-
regex: /\b0[789]0-\d{4}-\d{4}\b|\b0120-\d{3}-\d{3}\b|\b0800-\d{3}-\d{4}\b|\b0\d{1,3}-\d{2,4}-\d{4}\b/g,
|
|
339
|
-
},
|
|
340
|
-
{
|
|
341
|
-
ruleId: 'jp_postal',
|
|
342
|
-
category: 'pii_jp',
|
|
343
|
-
severity: 'low',
|
|
344
|
-
// 電話番号の一部 (前後に数字/ハイフン) を除外。〒 付きは可視性が高い。
|
|
345
|
-
regex: /(?<![\d-])(?:〒\s?)?\d{3}-\d{4}(?![\d-])/g,
|
|
346
|
-
},
|
|
347
|
-
{
|
|
348
|
-
ruleId: 'jp_address',
|
|
349
|
-
category: 'pii_jp',
|
|
350
|
-
severity: 'medium',
|
|
351
|
-
regex: new RegExp(`(?:${PREFECTURES})[^\\s「」、。]{0,18}?[市区町村郡][^\\s「」、。]{0,20}?(?:\\d+|[十百千]+)(?:丁目|番地|番|-|−)`, 'g'),
|
|
352
|
-
},
|
|
353
|
-
{
|
|
354
|
-
ruleId: 'jp_wareki_date',
|
|
355
|
-
category: 'pii_jp',
|
|
356
|
-
severity: 'low',
|
|
357
|
-
regex: /(?:令和|平成|昭和|大正|明治)\s?(?:元|\d{1,2})\s?年(?:\s?\d{1,2}\s?月(?:\s?\d{1,2}\s?日)?)?/g,
|
|
358
|
-
},
|
|
359
|
-
];
|
|
360
98
|
const SEVERITY_RANK = { high: 0, medium: 1, low: 2 };
|
|
361
99
|
class SecretDetector {
|
|
362
100
|
rules;
|
|
363
101
|
enableEntropy;
|
|
364
|
-
|
|
102
|
+
hmacKey;
|
|
365
103
|
constructor(opts = {}) {
|
|
366
|
-
|
|
367
|
-
.map((e) => {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
regex: new RegExp(e.pattern, 'g'),
|
|
374
|
-
};
|
|
375
|
-
}
|
|
376
|
-
catch {
|
|
377
|
-
// 不正な正規表現は無視 (辞書のせいで検知全体を落とさない)。
|
|
378
|
-
return null;
|
|
379
|
-
}
|
|
380
|
-
})
|
|
381
|
-
.filter((r) => r !== null);
|
|
382
|
-
this.rules = [...BUILTIN_RULES, ...custom];
|
|
104
|
+
this.rules = (0, detection_1.compileRules)({
|
|
105
|
+
custom: (opts.customDictionary ?? []).map((e) => ({
|
|
106
|
+
ruleId: e.ruleId,
|
|
107
|
+
pattern: e.pattern,
|
|
108
|
+
severity: e.severity ?? 'medium',
|
|
109
|
+
})),
|
|
110
|
+
});
|
|
383
111
|
this.enableEntropy = opts.enableEntropy ?? true;
|
|
384
|
-
this.
|
|
112
|
+
this.hmacKey = opts.hmacSecret ?? (0, crypto_1.randomBytes)(32).toString('hex');
|
|
385
113
|
}
|
|
386
114
|
/** テキストから検知メタを返す。生機密本文は保持しない。 */
|
|
387
115
|
detect(text) {
|
|
116
|
+
return this.detectWithContext(text).detections;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* detect() に加えて、各検知の出現位置とマスク済みの行抜粋を返す。
|
|
120
|
+
*
|
|
121
|
+
* ブロック理由に「どの語が原因か」を出すために使う。マスク済みプレビューが
|
|
122
|
+
* 末尾 2 文字だけでは原因語を特定できず、利用者が二分探索で切り分ける羽目になる。
|
|
123
|
+
* contexts は端末内の表示専用で、クラウドへは送らない (DetectionContext 参照)。
|
|
124
|
+
*/
|
|
125
|
+
detectWithContext(text) {
|
|
388
126
|
if (!text)
|
|
389
|
-
return [];
|
|
390
|
-
const
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
rule.regex.lastIndex = 0;
|
|
394
|
-
let m;
|
|
395
|
-
while ((m = rule.regex.exec(norm)) !== null) {
|
|
396
|
-
const matched = m[0];
|
|
397
|
-
// ゼロ幅マッチの無限ループ回避。
|
|
398
|
-
if (m.index === rule.regex.lastIndex)
|
|
399
|
-
rule.regex.lastIndex++;
|
|
400
|
-
if (rule.validate && !rule.validate(matched))
|
|
401
|
-
continue;
|
|
402
|
-
rawHits.push({
|
|
403
|
-
ruleId: rule.ruleId,
|
|
404
|
-
category: rule.category,
|
|
405
|
-
severity: rule.severity,
|
|
406
|
-
value: matched,
|
|
407
|
-
start: m.index,
|
|
408
|
-
end: m.index + matched.length,
|
|
409
|
-
});
|
|
410
|
-
}
|
|
411
|
-
}
|
|
127
|
+
return { detections: [], contexts: [] };
|
|
128
|
+
const mapped = (0, detection_1.normalizeWithMap)(text);
|
|
129
|
+
const norm = mapped.text;
|
|
130
|
+
const rawHits = (0, detection_1.findHits)(norm, this.rules);
|
|
412
131
|
// スパン重複除外 (secret カテゴリのみ): 同一箇所を複数ルールが拾う二重計上を防ぐ。
|
|
413
|
-
// 例:
|
|
414
|
-
//
|
|
132
|
+
// 例: Bearer + JWT は両方にマッチする。ルール定義順 (特異的なルールが先) を
|
|
133
|
+
// 優先し、重複した後段ルールのヒットは破棄する。
|
|
415
134
|
// pii/pii_jp は別個の値が隣接し得る (住所+郵便番号等) ため対象外。
|
|
416
135
|
const hits = [];
|
|
417
136
|
for (const h of rawHits) {
|
|
@@ -420,29 +139,8 @@ class SecretDetector {
|
|
|
420
139
|
if (!dup)
|
|
421
140
|
hits.push(h);
|
|
422
141
|
}
|
|
423
|
-
const matchedValues = hits.map((h) => h.value);
|
|
424
142
|
if (this.enableEntropy) {
|
|
425
|
-
|
|
426
|
-
if (token.length < ENTROPY_MIN_LENGTH)
|
|
427
|
-
continue;
|
|
428
|
-
// 英字と数字が混在する文字列のみ (英文/数字列の誤検出を抑制)。
|
|
429
|
-
if (!(/[A-Za-z]/.test(token) && /\d/.test(token)))
|
|
430
|
-
continue;
|
|
431
|
-
if (shannonEntropy(token) < ENTROPY_THRESHOLD)
|
|
432
|
-
continue;
|
|
433
|
-
// 既存の正規表現ヒットと重複するトークンは二重計上しない。
|
|
434
|
-
if (matchedValues.some((v) => v.includes(token) || token.includes(v)))
|
|
435
|
-
continue;
|
|
436
|
-
// start/end は重複除外 (上で完了済み) では使わないため -1 で埋める。
|
|
437
|
-
hits.push({
|
|
438
|
-
ruleId: 'high_entropy',
|
|
439
|
-
category: 'secret',
|
|
440
|
-
severity: 'medium',
|
|
441
|
-
value: token,
|
|
442
|
-
start: -1,
|
|
443
|
-
end: -1,
|
|
444
|
-
});
|
|
445
|
-
}
|
|
143
|
+
hits.push(...(0, detection_1.findEntropyHits)(norm, hits.map((h) => h.value)));
|
|
446
144
|
}
|
|
447
145
|
// (category, ruleId) ごとに集約。生 value はここで preview/hash に変換して破棄。
|
|
448
146
|
const groups = new Map();
|
|
@@ -461,95 +159,66 @@ class SecretDetector {
|
|
|
461
159
|
count: values.length,
|
|
462
160
|
maskedPreview: maskMatch(values[0], hit.category),
|
|
463
161
|
matchHash: hit.category === 'pii' || hit.category === 'pii_jp'
|
|
464
|
-
? hmacHash(values[0], this.
|
|
162
|
+
? hmacHash(values[0], this.hmacKey)
|
|
465
163
|
: hashMatch(values[0]),
|
|
466
164
|
}));
|
|
467
165
|
// 重大度順 (high>medium>low、同重大度はルール定義順) に整列し、1 entry あたり
|
|
468
166
|
// MAX_DETECTIONS_PER_ENTRY 件へキャップ (cloud 側で batch ごと棄却されないため)。
|
|
469
167
|
metas.sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
|
|
470
|
-
|
|
168
|
+
const detections = metas.slice(0, exports.MAX_DETECTIONS_PER_ENTRY);
|
|
169
|
+
// ruleId ごとに最初の 1 件だけ位置を出す (理由文が長くなりすぎないため)。
|
|
170
|
+
const seen = new Set();
|
|
171
|
+
const contexts = [];
|
|
172
|
+
for (const h of hits) {
|
|
173
|
+
if (seen.has(h.ruleId))
|
|
174
|
+
continue;
|
|
175
|
+
seen.add(h.ruleId);
|
|
176
|
+
const ctx = this.buildContext(text, mapped, h);
|
|
177
|
+
if (ctx)
|
|
178
|
+
contexts.push(ctx);
|
|
179
|
+
}
|
|
180
|
+
return { detections, contexts };
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* ヒット位置を原文へ戻し、その行を **マスクしたまま** 抜粋する。
|
|
184
|
+
*
|
|
185
|
+
* 該当値そのものは出さない。理由文は端末にも Claude の文脈にも載るので、
|
|
186
|
+
* 「生機密を理由文に出さない」という既存の性質は崩さない。原因の特定には
|
|
187
|
+
* 行番号と、伏字を挟んだ前後の文脈で足りる。
|
|
188
|
+
*/
|
|
189
|
+
buildContext(raw, mapped, hit) {
|
|
190
|
+
const rawStart = mapped.starts[hit.start];
|
|
191
|
+
const rawEnd = mapped.ends[Math.max(hit.start, hit.end - 1)];
|
|
192
|
+
if (rawStart === undefined || rawEnd === undefined)
|
|
193
|
+
return null;
|
|
194
|
+
const line = raw.slice(0, rawStart).split('\n').length;
|
|
195
|
+
const lineStart = raw.lastIndexOf('\n', Math.max(0, rawStart - 1)) + 1;
|
|
196
|
+
const lineEndIdx = raw.indexOf('\n', rawEnd);
|
|
197
|
+
const lineEnd = lineEndIdx === -1 ? raw.length : lineEndIdx;
|
|
198
|
+
// 長い行は検知位置の周りだけ切り出す。
|
|
199
|
+
const from = Math.max(lineStart, rawStart - SNIPPET_MAX_CHARS / 2);
|
|
200
|
+
const to = Math.min(lineEnd, rawEnd + SNIPPET_MAX_CHARS / 2);
|
|
201
|
+
const head = from > lineStart ? '…' : '';
|
|
202
|
+
const tail = to < lineEnd ? '…' : '';
|
|
203
|
+
// 抜粋は必ずマスクを通す。同じ行に別の機密が居合わせていても漏らさない。
|
|
204
|
+
const snippet = this.redact(raw.slice(from, to)).text.replace(/\s+/g, ' ').trim();
|
|
205
|
+
return { ruleId: hit.ruleId, line, snippet: `${head}${snippet}${tail}` };
|
|
471
206
|
}
|
|
472
207
|
/**
|
|
473
|
-
*
|
|
208
|
+
* テキストから秘密情報を伏せ字へ置換して返す (Issue #104)。
|
|
474
209
|
*
|
|
475
210
|
* 検出 (detect) と同じルール集合を流用することで、**検出できるものは必ずマスクできる**
|
|
476
|
-
*
|
|
477
|
-
* -
|
|
478
|
-
* -
|
|
479
|
-
*
|
|
211
|
+
* を保証する。マスク範囲は shared の AGENT_REDACT_POLICY:
|
|
212
|
+
* - secret 全ルール (API キー / トークン / 秘密鍵 / 汎用クレデンシャル)
|
|
213
|
+
* - strong_pii (カード / マイナンバー / 口座 / 人名 — 検査数字やラベルで裏取り済み)
|
|
214
|
+
* 連絡先系 (電話 / 住所 / 郵便番号) とメールは対象外 — pii-masker のトグルが扱う。
|
|
480
215
|
*
|
|
481
|
-
*
|
|
482
|
-
* (maskEmails) や組織ルールが扱う。生テキストに対して動作する (置換位置を保つため
|
|
483
|
-
* 正規化はしない。全角の難読化対策は将来課題)。
|
|
216
|
+
* 生テキストに対して動作する (置換位置を保つため正規化はしない)。
|
|
484
217
|
*
|
|
485
218
|
* @returns 置換後テキストと ruleId ごとの置換件数 (MaskStat 連携用)。
|
|
486
219
|
*/
|
|
487
220
|
redact(text) {
|
|
488
|
-
|
|
489
|
-
return { text, counts: {} };
|
|
490
|
-
// --- 確定ルール (PEM 全ブロック + secret 全カテゴリ + high severity) ---
|
|
491
|
-
const specific = [];
|
|
492
|
-
PEM_BLOCK.lastIndex = 0;
|
|
493
|
-
let pm;
|
|
494
|
-
while ((pm = PEM_BLOCK.exec(text)) !== null) {
|
|
495
|
-
specific.push({ start: pm.index, end: pm.index + pm[0].length, ruleId: 'private_key_block' });
|
|
496
|
-
if (pm.index === PEM_BLOCK.lastIndex)
|
|
497
|
-
PEM_BLOCK.lastIndex++;
|
|
498
|
-
}
|
|
499
|
-
for (const rule of this.rules) {
|
|
500
|
-
if (!(rule.category === 'secret' || rule.severity === 'high'))
|
|
501
|
-
continue;
|
|
502
|
-
rule.regex.lastIndex = 0;
|
|
503
|
-
let m;
|
|
504
|
-
while ((m = rule.regex.exec(text)) !== null) {
|
|
505
|
-
const matched = m[0];
|
|
506
|
-
if (m.index === rule.regex.lastIndex)
|
|
507
|
-
rule.regex.lastIndex++;
|
|
508
|
-
if (rule.validate && !rule.validate(matched))
|
|
509
|
-
continue;
|
|
510
|
-
specific.push({ start: m.index, end: m.index + matched.length, ruleId: rule.ruleId });
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
// 確定ルールを貪欲に非重複選択 (start 昇順 → 長い順)。
|
|
514
|
-
const chosen = selectNonOverlapping(specific);
|
|
515
|
-
// --- 高エントロピー (パターン外のランダム秘密) ---
|
|
516
|
-
// 既定では無効 (マスカは enableEntropy=false で構築する)。理由: エントロピー基準は
|
|
517
|
-
// 正当な識別子 (job-id 等) や base64 断片まで潰し、保存本文を破壊し得るため、
|
|
518
|
-
// マスキングは確定ルールに限定する。検出 (detect) では従来どおり高エントロピーを
|
|
519
|
-
// フラグする。有効化時も確定ルールを優先し、空き領域のみ伏せる。
|
|
520
|
-
if (this.enableEntropy) {
|
|
521
|
-
const entropy = [];
|
|
522
|
-
const tokenRe = new RegExp(`[A-Za-z0-9+/=_-]{${ENTROPY_MIN_LENGTH},}`, 'g');
|
|
523
|
-
let tm;
|
|
524
|
-
while ((tm = tokenRe.exec(text)) !== null) {
|
|
525
|
-
const token = tm[0];
|
|
526
|
-
if (/[/\\]/.test(token))
|
|
527
|
-
continue;
|
|
528
|
-
if (!(/[A-Za-z]/.test(token) && /\d/.test(token)))
|
|
529
|
-
continue;
|
|
530
|
-
if (shannonEntropy(token) < ENTROPY_THRESHOLD)
|
|
531
|
-
continue;
|
|
532
|
-
const start = tm.index;
|
|
533
|
-
const end = start + token.length;
|
|
534
|
-
// 確定スパンと重なるものは捨てる (specific ラベルを優先)。
|
|
535
|
-
if (chosen.some((c) => start < c.end && c.start < end))
|
|
536
|
-
continue;
|
|
537
|
-
entropy.push({ start, end, ruleId: 'high_entropy' });
|
|
538
|
-
}
|
|
539
|
-
chosen.push(...selectNonOverlapping(entropy));
|
|
540
|
-
}
|
|
541
|
-
if (chosen.length === 0)
|
|
542
|
-
return { text, counts: {} };
|
|
543
|
-
chosen.sort((a, b) => a.start - b.start);
|
|
544
|
-
// 右端から置換してインデックスのずれを防ぐ。
|
|
545
|
-
const counts = {};
|
|
546
|
-
let out = text;
|
|
547
|
-
for (let i = chosen.length - 1; i >= 0; i--) {
|
|
548
|
-
const s = chosen[i];
|
|
549
|
-
out = out.slice(0, s.start) + redactionLabel(s.ruleId) + out.slice(s.end);
|
|
550
|
-
counts[s.ruleId] = (counts[s.ruleId] ?? 0) + 1;
|
|
551
|
-
}
|
|
552
|
-
return { text: out, counts };
|
|
221
|
+
return (0, detection_1.redactText)(text, this.rules, { ...detection_1.AGENT_REDACT_POLICY, entropy: this.enableEntropy });
|
|
553
222
|
}
|
|
554
223
|
}
|
|
555
224
|
exports.SecretDetector = SecretDetector;
|