deepdebug-local-agent 1.1.0 → 1.2.0
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/.github/workflows/npm-publish.yml +16 -9
- package/CHANGELOG.md +7 -0
- package/docs/dlp.md +78 -0
- package/docs/releasing.md +37 -43
- package/env.example +4 -0
- package/package.json +1 -1
- package/scripts/next-version.mjs +75 -0
- package/src/dlp/engine.js +111 -0
- package/src/dlp/index.js +137 -0
- package/src/dlp/rules.js +97 -0
- package/src/dlp/validators.js +123 -0
- package/src/siblings/incident-forwarder.js +3 -1
- package/test/dlp/engine.test.js +173 -0
- package/test/dlp/fixtures.js +126 -0
- package/test/dlp/index.test.js +166 -0
- package/test/next-version.test.js +48 -0
package/src/dlp/rules.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DLP ruleset (versioned). Each rule: { id, class, re, group?, validate? }.
|
|
3
|
+
* - id: stable identifier, used as the metric label and in the placeholder.
|
|
4
|
+
* - class: coarse family, used by LOCAL_AGENT_DLP_BLOCK_CLASSES.
|
|
5
|
+
* - re: global regex (the engine resets lastIndex; never share state).
|
|
6
|
+
* - group: capture group to redact; default is the whole match.
|
|
7
|
+
* - validate: optional predicate on the redactable text; false = not a hit.
|
|
8
|
+
*
|
|
9
|
+
* Order matters: specific rules run before generic ones, so a Stripe key is
|
|
10
|
+
* reported as stripe_key and not as a generic secret assignment.
|
|
11
|
+
* Bump RULESET_VERSION on every change to the list or to a pattern.
|
|
12
|
+
*
|
|
13
|
+
* Reimplemented from public token formats; no third-party rule files copied.
|
|
14
|
+
*/
|
|
15
|
+
import {
|
|
16
|
+
looksLikeLiteralSecret, looksLikeOpaqueKey, realEmail, plausiblePhone,
|
|
17
|
+
plausibleCard, ibanValid, ptNifValid,
|
|
18
|
+
} from './validators.js';
|
|
19
|
+
|
|
20
|
+
export const RULESET_VERSION = '2026.10.28-1';
|
|
21
|
+
|
|
22
|
+
export const RULE_CLASSES = Object.freeze([
|
|
23
|
+
'private-key', 'cloud-credential', 'vcs-token', 'payment-key', 'saas-token', 'token',
|
|
24
|
+
'credential', 'connection-string', 'url-credential', 'pii-email', 'pii-phone', 'pii-id',
|
|
25
|
+
'pii-financial', 'high-entropy',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const r = (id, cls, re, extra = {}) => Object.freeze({ id, class: cls, re, ...extra });
|
|
29
|
+
|
|
30
|
+
export const RULES = Object.freeze([
|
|
31
|
+
// Private keys first: they span lines and may contain other-looking text.
|
|
32
|
+
r('pem_private_key', 'private-key',
|
|
33
|
+
/-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----|$)/g),
|
|
34
|
+
|
|
35
|
+
// Cloud
|
|
36
|
+
r('aws_access_key_id', 'cloud-credential', /\b(?:AKIA|ASIA|AGPA|AIDA|AROA|ANPA|ANVA|AIPA)[A-Z0-9]{16}\b/g),
|
|
37
|
+
r('aws_secret_access_key', 'cloud-credential',
|
|
38
|
+
/\baws[_-]?secret[_-]?(?:access[_-]?)?key["']?\s*[:=]\s*["']?([A-Za-z0-9/+=]{40})\b/gi, { group: 1 }),
|
|
39
|
+
r('gcp_api_key', 'cloud-credential', /\bAIza[0-9A-Za-z_-]{35}\b/g),
|
|
40
|
+
r('gcp_oauth_token', 'cloud-credential', /\bya29\.[0-9A-Za-z_-]{20,}/g),
|
|
41
|
+
r('gcp_oauth_client_secret', 'cloud-credential', /\bGOCSPX-[A-Za-z0-9_-]{20,}/g),
|
|
42
|
+
r('azure_storage_account_key', 'cloud-credential', /\bAccountKey=([A-Za-z0-9+/]{40,}={0,2})/g, { group: 1 }),
|
|
43
|
+
r('digitalocean_token', 'cloud-credential', /\bdo[opr]_v1_[a-f0-9]{64}\b/g),
|
|
44
|
+
|
|
45
|
+
// VCS / package registries
|
|
46
|
+
r('github_token', 'vcs-token', /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g),
|
|
47
|
+
r('github_fine_grained_pat', 'vcs-token', /\bgithub_pat_[A-Za-z0-9_]{22,}\b/g),
|
|
48
|
+
r('gitlab_pat', 'vcs-token', /\bglpat-[A-Za-z0-9_-]{20,}/g),
|
|
49
|
+
r('npm_token', 'vcs-token', /\bnpm_[A-Za-z0-9]{36}\b/g),
|
|
50
|
+
r('pypi_token', 'vcs-token', /\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{50,}/g),
|
|
51
|
+
|
|
52
|
+
// Payments / SaaS
|
|
53
|
+
r('stripe_key', 'payment-key', /\b[sr]k_(?:live|test)_[0-9A-Za-z]{16,}\b/g),
|
|
54
|
+
r('stripe_webhook_secret', 'payment-key', /\bwhsec_[A-Za-z0-9]{16,}\b/g),
|
|
55
|
+
r('shopify_token', 'saas-token', /\bshp(?:at|ca|pa|ss)_[a-f0-9]{32}\b/g),
|
|
56
|
+
r('slack_token', 'saas-token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/g),
|
|
57
|
+
r('slack_webhook', 'saas-token', /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/g),
|
|
58
|
+
r('discord_webhook', 'saas-token', /https:\/\/(?:discord|discordapp)\.com\/api\/webhooks\/\d+\/[\w-]+/g),
|
|
59
|
+
r('sendgrid_key', 'saas-token', /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g),
|
|
60
|
+
r('twilio_api_key', 'saas-token', /\bSK[0-9a-f]{32}\b/g),
|
|
61
|
+
r('mailgun_key', 'saas-token', /\bkey-[0-9a-f]{32}\b/g),
|
|
62
|
+
r('huggingface_token', 'saas-token', /\bhf_[A-Za-z0-9]{34,}\b/g),
|
|
63
|
+
r('telegram_bot_token', 'saas-token', /\b\d{8,10}:AA[A-Za-z0-9_-]{33}\b/g),
|
|
64
|
+
r('anthropic_key', 'saas-token', /\bsk-ant-[A-Za-z0-9_-]{20,}/g),
|
|
65
|
+
r('openai_key', 'saas-token', /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/g, { validate: (v) => /\d/.test(v) }),
|
|
66
|
+
|
|
67
|
+
// Generic tokens
|
|
68
|
+
r('jwt', 'token', /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g),
|
|
69
|
+
r('bearer_token', 'token', /\bBearer\s+([A-Za-z0-9._~+/-]{20,}=*)/g, { group: 1 }),
|
|
70
|
+
r('basic_auth_header', 'token', /\bAuthorization:\s*Basic\s+([A-Za-z0-9+/]{12,}={0,2})/gi, { group: 1 }),
|
|
71
|
+
|
|
72
|
+
// Connection strings and URL credentials (before the generic assignment rule)
|
|
73
|
+
r('db_connection_string', 'connection-string',
|
|
74
|
+
/\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|redis|rediss|amqps?|mssql|sqlserver|cockroachdb):\/\/[^\s"'`<>]+/gi,
|
|
75
|
+
{ validate: (v) => /:\/\/[^/\s]*@/.test(v) }),
|
|
76
|
+
r('jdbc_password', 'connection-string', /\bjdbc:[a-z0-9]+:[^\s"'`]*?[?&;](?:password|pwd)=([^\s&;"'`]+)/gi, { group: 1 }),
|
|
77
|
+
r('ado_connection_password', 'connection-string', /(?:^|;)\s*(?:Password|Pwd)\s*=\s*([^;\r\n"']{3,})/gim, { group: 1 }),
|
|
78
|
+
r('url_credentials', 'url-credential', /\b[a-z][a-z0-9+.-]{0,20}:\/\/([^\s:@/"'`<>]+:[^\s@/"'`<>]+)@/gi, { group: 1, validate: (v) => !/[${}<>]/.test(v) }),
|
|
79
|
+
|
|
80
|
+
// Generic "name = literal" assignments
|
|
81
|
+
r('generic_secret_assignment', 'credential',
|
|
82
|
+
/\b(?:[A-Za-z0-9_-]{0,40}?(?:password|passwd|pwd|secret|api[_-]?key|apikey|access[_-]?token|auth[_-]?token|client[_-]?secret|private[_-]?key|token))["']?\s*(?::|=|=>)\s*["']?([^\s"'`,;]{8,})/gi,
|
|
83
|
+
{ group: 1, validate: looksLikeLiteralSecret }),
|
|
84
|
+
|
|
85
|
+
// PII
|
|
86
|
+
r('email', 'pii-email', /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9-]{1,63}(?:\.[A-Za-z0-9-]{1,63}){0,6}\.[A-Za-z]{2,24}(?![A-Za-z0-9_~./-]|:[A-Za-z0-9_~./-])/g, { validate: realEmail }),
|
|
87
|
+
r('iban', 'pii-financial', /\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){2,7}(?: ?[A-Z0-9]{1,3})?\b/g, { validate: ibanValid }),
|
|
88
|
+
r('credit_card', 'pii-financial', /(?<![\d.-])\d{4}(?:[ -]?\d{4}){2}[ -]?\d{1,7}(?![\d-])/g, { validate: plausibleCard }),
|
|
89
|
+
r('pt_nif', 'pii-id',
|
|
90
|
+
/\b(?:NIF|NIPC|contribuinte|VAT)\b\s*(?:n[.ºo°]*\s*)?[:=#-]?\s*(?:PT)?(\d{9})\b/gi, { group: 1, validate: ptNifValid }),
|
|
91
|
+
r('phone_international', 'pii-phone', /(?<![\w.+])(?:\+|00)\d{1,3}(?:[ .-]?\(?\d{1,4}\)?){2,5}(?![\w])/g,
|
|
92
|
+
{ validate: plausiblePhone }),
|
|
93
|
+
|
|
94
|
+
// Last: opaque high-entropy blobs
|
|
95
|
+
r('high_entropy_blob', 'high-entropy', /(?<![A-Za-z0-9+/_=-])[A-Za-z0-9+/_=-]{40,}(?![A-Za-z0-9+/_=-])/g,
|
|
96
|
+
{ validate: looksLikeOpaqueKey }),
|
|
97
|
+
]);
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DLP validators: pure helpers that cut false positives after a regex matched.
|
|
3
|
+
* No I/O, no state.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Shannon entropy in bits per character. */
|
|
7
|
+
export function shannonEntropy(s) {
|
|
8
|
+
if (!s) return 0;
|
|
9
|
+
const freq = new Map();
|
|
10
|
+
for (const ch of s) freq.set(ch, (freq.get(ch) || 0) + 1);
|
|
11
|
+
let h = 0;
|
|
12
|
+
for (const n of freq.values()) {
|
|
13
|
+
const p = n / s.length;
|
|
14
|
+
h -= p * Math.log2(p);
|
|
15
|
+
}
|
|
16
|
+
return h;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Luhn checksum over a digit string. */
|
|
20
|
+
export function luhnValid(digits) {
|
|
21
|
+
if (!/^\d{13,19}$/.test(digits)) return false;
|
|
22
|
+
let sum = 0;
|
|
23
|
+
let alt = false;
|
|
24
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
25
|
+
let d = digits.charCodeAt(i) - 48;
|
|
26
|
+
if (alt) {
|
|
27
|
+
d *= 2;
|
|
28
|
+
if (d > 9) d -= 9;
|
|
29
|
+
}
|
|
30
|
+
sum += d;
|
|
31
|
+
alt = !alt;
|
|
32
|
+
}
|
|
33
|
+
return sum % 10 === 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** ISO 13616 mod-97 check for an IBAN (spaces allowed). */
|
|
37
|
+
export function ibanValid(raw) {
|
|
38
|
+
const s = raw.replace(/\s+/g, '').toUpperCase();
|
|
39
|
+
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(s)) return false;
|
|
40
|
+
const rearranged = s.slice(4) + s.slice(0, 4);
|
|
41
|
+
let rem = 0;
|
|
42
|
+
for (const ch of rearranged) {
|
|
43
|
+
const v = ch >= 'A' && ch <= 'Z' ? String(ch.charCodeAt(0) - 55) : ch;
|
|
44
|
+
for (const c of v) rem = (rem * 10 + (c.charCodeAt(0) - 48)) % 97;
|
|
45
|
+
}
|
|
46
|
+
return rem === 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Portuguese NIF/NIPC checksum (9 digits, mod 11). */
|
|
50
|
+
export function ptNifValid(digits) {
|
|
51
|
+
if (!/^\d{9}$/.test(digits)) return false;
|
|
52
|
+
const okPrefix = '1235689'.includes(digits[0]) || /^(?:45|70|71|72|74|75|77|79)/.test(digits);
|
|
53
|
+
if (!okPrefix) return false;
|
|
54
|
+
let sum = 0;
|
|
55
|
+
for (let i = 0; i < 8; i++) sum += (digits.charCodeAt(i) - 48) * (9 - i);
|
|
56
|
+
const check = 11 - (sum % 11);
|
|
57
|
+
const expected = check >= 10 ? 0 : check;
|
|
58
|
+
return expected === digits.charCodeAt(8) - 48;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const PLACEHOLDER_PREFIX = /^(?:\$|\{|<|%|\[|\(|process\.env|os\.getenv|env\[|getenv|config\.|settings\.|this\.|self\.|args\.|params\.|req\.|request\.|\.\.\.)/i;
|
|
62
|
+
const PLACEHOLDER_WORDS = /^(?:\*+|x{3,}|null|undefined|none|true|false|string|required|optional|redacted|changeme|change_me|your[-_ ].*|example.*|placeholder.*|dummy.*|secret|password|token|apikey|api_key)$/i;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Decide whether the value assigned to a secret-looking name is a literal
|
|
66
|
+
* credential or just code (identifier, call, template, placeholder).
|
|
67
|
+
*/
|
|
68
|
+
export function looksLikeLiteralSecret(value) {
|
|
69
|
+
const v = value.replace(/^["'`]+|["'`]+$/g, '');
|
|
70
|
+
if (v.length < 8) return false;
|
|
71
|
+
if (PLACEHOLDER_PREFIX.test(v) || PLACEHOLDER_WORDS.test(v)) return false;
|
|
72
|
+
if (v.includes('REDACTED') || v.includes('***')) return false;
|
|
73
|
+
if (/[()]/.test(v)) return false; // function call / expression
|
|
74
|
+
if (/^[A-Za-z_][A-Za-z0-9_.]*$/.test(v) && !/\d/.test(v)) {
|
|
75
|
+
// plain identifier or dotted path (getPassword, config.password): code, not a secret
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
if (/^[a-z]+(?:[-_][a-z]+)+$/.test(v)) return false; // kebab/snake names (secret ids, flags), not values
|
|
79
|
+
const hasDigit = /\d/.test(v);
|
|
80
|
+
const hasSymbol = /[!@#$%^&*+/=~\-]/.test(v);
|
|
81
|
+
if (hasDigit || hasSymbol) return true;
|
|
82
|
+
return v.length >= 16 && shannonEntropy(v) >= 3.5;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** High-entropy blob heuristic (opaque keys); excludes hashes, UUIDs and paths. */
|
|
86
|
+
export function looksLikeOpaqueKey(token) {
|
|
87
|
+
if (token.length < 40) return false;
|
|
88
|
+
if (/^sha\d+-/i.test(token)) return false; // npm/SRI integrity hashes
|
|
89
|
+
if (/^[0-9a-f]+$/i.test(token)) return false; // hex digests / git SHAs
|
|
90
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(token)) return false; // UUID
|
|
91
|
+
if (token.startsWith('/') || token.includes('//')) return false; // path / URL remnants
|
|
92
|
+
// Standard base64 never has - or _, URL-safe never has /: a mix means a path or URL fragment.
|
|
93
|
+
if (token.includes('/') && /[-_]/.test(token)) return false;
|
|
94
|
+
if (!/[a-z]/.test(token) || !/[A-Z]/.test(token) || !/\d/.test(token)) return false;
|
|
95
|
+
return shannonEntropy(token) >= 4.5;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const NON_MAIL_TLDS = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'css', 'js', 'ts', 'json', 'map', 'woff', 'woff2', 'ttf', 'eot', 'html', 'md']);
|
|
99
|
+
const DOC_DOMAINS = /(?:^|\.)(?:example\.(?:com|org|net)|test|invalid|localhost|local)$/i;
|
|
100
|
+
|
|
101
|
+
/** E-mail: reject asset names (logo@2x.png) and reserved documentation domains. */
|
|
102
|
+
export function realEmail(addr) {
|
|
103
|
+
const at = addr.lastIndexOf('@');
|
|
104
|
+
const domain = addr.slice(at + 1);
|
|
105
|
+
const tld = domain.slice(domain.lastIndexOf('.') + 1).toLowerCase();
|
|
106
|
+
if (NON_MAIL_TLDS.has(tld)) return false;
|
|
107
|
+
if (DOC_DOMAINS.test(domain)) return false;
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** International phone: leading +, 9 to 15 digits overall. */
|
|
112
|
+
export function plausiblePhone(raw) {
|
|
113
|
+
const digits = raw.replace(/\D/g, '');
|
|
114
|
+
return digits.length >= 9 && digits.length <= 15;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Card number candidates: Luhn + plausible leading digit, separators ignored. */
|
|
118
|
+
export function plausibleCard(raw) {
|
|
119
|
+
const digits = raw.replace(/[ -]/g, '');
|
|
120
|
+
if (!/^[2-6]/.test(digits)) return false;
|
|
121
|
+
if (/^(\d)\1+$/.test(digits)) return false;
|
|
122
|
+
return luhnValid(digits);
|
|
123
|
+
}
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import { logger as defaultLogger } from './logger.js';
|
|
27
27
|
import { BurstSuppressor } from './burst-suppressor.js';
|
|
28
|
+
import { protectEgress } from '../dlp/index.js';
|
|
28
29
|
|
|
29
30
|
const SIX_KEYS = ['adapter', 'errorCode', 'file', 'message', 'source', 'timestamp'];
|
|
30
31
|
|
|
@@ -293,7 +294,8 @@ export class IncidentForwarder {
|
|
|
293
294
|
return {
|
|
294
295
|
tenantId: this.tenantId || undefined,
|
|
295
296
|
service: evt.source,
|
|
296
|
-
|
|
297
|
+
// DLP (LOCAL_AGENT_DLP_MODE, default off = unchanged): log excerpts leave the customer network here.
|
|
298
|
+
message: protectEgress(evt.message, { source: 'incident-forwarder', log: (o, m) => this.logger.info(o, m) }).text,
|
|
297
299
|
severity: 'error',
|
|
298
300
|
environment: 'production',
|
|
299
301
|
detectedBy: 'local-agent-adapter',
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { test, describe } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { maskSensitive, maskDeep, normalizeMode, rulesetHash } from '../../src/dlp/engine.js';
|
|
4
|
+
import { RULES, RULESET_VERSION } from '../../src/dlp/rules.js';
|
|
5
|
+
import { POSITIVES, NEGATIVES, validPtNif } from './fixtures.js';
|
|
6
|
+
|
|
7
|
+
describe('ruleset', () => {
|
|
8
|
+
test('has at least 30 distinct rules with unique ids and valid regexes', () => {
|
|
9
|
+
const ids = RULES.map((r) => r.id);
|
|
10
|
+
assert.ok(ids.length >= 30, `only ${ids.length} rules`);
|
|
11
|
+
assert.equal(new Set(ids).size, ids.length);
|
|
12
|
+
for (const r of RULES) assert.ok(r.re.flags.includes('g'), r.id);
|
|
13
|
+
});
|
|
14
|
+
test('hash is stable and changes with the rules', () => {
|
|
15
|
+
assert.equal(rulesetHash(), rulesetHash());
|
|
16
|
+
assert.match(rulesetHash(), /^[0-9a-f]{64}$/);
|
|
17
|
+
assert.notEqual(rulesetHash(RULES.slice(1)), rulesetHash());
|
|
18
|
+
assert.match(RULESET_VERSION, /^\d{4}\.\d{2}\.\d{2}-\d+$/);
|
|
19
|
+
});
|
|
20
|
+
test('every rule is exercised by the positive corpus', () => {
|
|
21
|
+
const covered = new Set(POSITIVES.map(([id]) => id));
|
|
22
|
+
for (const r of RULES) assert.ok(covered.has(r.id), `no positive fixture for ${r.id}`);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('positives: zero leaks in enforce', () => {
|
|
27
|
+
for (const [ruleId, sample, secret] of POSITIVES) {
|
|
28
|
+
test(`${ruleId}: ${sample.slice(0, 24).replace(/\n/g, ' ')}`, () => {
|
|
29
|
+
const res = maskSensitive(sample, { mode: 'enforce' });
|
|
30
|
+
assert.ok(res.redactions[ruleId] >= 1, `expected ${ruleId}, got ${JSON.stringify(res.redactions)}`);
|
|
31
|
+
assert.match(res.text, new RegExp(`\\[REDACTED:${ruleId}\\]`));
|
|
32
|
+
if (secret) assert.ok(!res.text.includes(secret), 'secret still present');
|
|
33
|
+
assert.ok(!res.blocked);
|
|
34
|
+
// idempotent: masking masked text finds nothing new
|
|
35
|
+
assert.equal(maskSensitive(res.text, { mode: 'enforce' }).total, 0);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('false positives', () => {
|
|
41
|
+
for (const text of NEGATIVES) {
|
|
42
|
+
test(`unchanged: ${text.slice(0, 50)}`, () => {
|
|
43
|
+
const res = maskSensitive(text, { mode: 'enforce' });
|
|
44
|
+
assert.equal(res.text, text, JSON.stringify(res.redactions));
|
|
45
|
+
assert.equal(res.total, 0);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
test('false-positive rate on the negative corpus is 0 %', () => {
|
|
49
|
+
const hits = NEGATIVES.filter((t) => maskSensitive(t, { mode: 'enforce' }).total > 0);
|
|
50
|
+
assert.equal(hits.length, 0);
|
|
51
|
+
});
|
|
52
|
+
test('valid NIF only with context', () => {
|
|
53
|
+
const nif = validPtNif('12345678');
|
|
54
|
+
assert.equal(maskSensitive(`ref ${nif}`, { mode: 'enforce' }).text, `ref ${nif}`);
|
|
55
|
+
assert.match(maskSensitive(`NIF ${nif}`, { mode: 'enforce' }).text, /NIF \[REDACTED:pt_nif\]/);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('modes', () => {
|
|
60
|
+
const sample = POSITIVES.find(([id]) => id === 'github_token')[1];
|
|
61
|
+
test('off: untouched and nothing scanned', () => {
|
|
62
|
+
const res = maskSensitive(sample, { mode: 'off' });
|
|
63
|
+
assert.equal(res.text, sample);
|
|
64
|
+
assert.equal(res.total, 0);
|
|
65
|
+
});
|
|
66
|
+
test('unknown or missing mode behaves as off', () => {
|
|
67
|
+
assert.equal(normalizeMode('ENFORCE '), 'enforce');
|
|
68
|
+
assert.equal(normalizeMode('yes'), 'off');
|
|
69
|
+
assert.equal(normalizeMode(undefined), 'off');
|
|
70
|
+
assert.equal(maskSensitive(sample, {}).text, sample);
|
|
71
|
+
});
|
|
72
|
+
test('warn: text untouched, counts reported', () => {
|
|
73
|
+
const res = maskSensitive(sample, { mode: 'warn' });
|
|
74
|
+
assert.equal(res.text, sample);
|
|
75
|
+
assert.equal(res.redactions.github_token, 1);
|
|
76
|
+
});
|
|
77
|
+
test('enforce + blockClasses withholds the whole text', () => {
|
|
78
|
+
const res = maskSensitive(`before ${sample} after`, { mode: 'enforce', blockClasses: ['vcs-token'] });
|
|
79
|
+
assert.equal(res.text, '[BLOCKED:vcs-token]');
|
|
80
|
+
assert.equal(res.blocked, true);
|
|
81
|
+
const warn = maskSensitive(sample, { mode: 'warn', blockClasses: ['vcs-token'] });
|
|
82
|
+
assert.equal(warn.blocked, true);
|
|
83
|
+
assert.equal(warn.text, sample);
|
|
84
|
+
});
|
|
85
|
+
test('non-matching block class does not block', () => {
|
|
86
|
+
const res = maskSensitive(sample, { mode: 'enforce', blockClasses: ['private-key'] });
|
|
87
|
+
assert.equal(res.blocked, false);
|
|
88
|
+
assert.match(res.text, /REDACTED:github_token/);
|
|
89
|
+
});
|
|
90
|
+
test('enforce fails closed above maxBytes, warn does not alter', () => {
|
|
91
|
+
const big = 'a'.repeat(100);
|
|
92
|
+
assert.equal(maskSensitive(big, { mode: 'enforce', maxBytes: 50 }).text, '[REDACTED:dlp_oversize]');
|
|
93
|
+
assert.equal(maskSensitive(big, { mode: 'warn', maxBytes: 50 }).text, big);
|
|
94
|
+
});
|
|
95
|
+
test('non-string and empty input pass through', () => {
|
|
96
|
+
assert.equal(maskSensitive('', { mode: 'enforce' }).text, '');
|
|
97
|
+
assert.equal(maskSensitive(undefined, { mode: 'enforce' }).text, undefined);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('report never contains the value', () => {
|
|
102
|
+
test('JSON of the report has no secret material', () => {
|
|
103
|
+
const [, sample] = POSITIVES.find(([id]) => id === 'stripe_key');
|
|
104
|
+
const res = maskSensitive(sample, { mode: 'enforce' });
|
|
105
|
+
const { text, ...report } = res;
|
|
106
|
+
assert.ok(!JSON.stringify(report).includes('live_'));
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('multiple hits and context', () => {
|
|
111
|
+
test('masks several secrets and keeps surrounding text', () => {
|
|
112
|
+
const gh = POSITIVES.find(([id]) => id === 'github_token')[1];
|
|
113
|
+
const mail = 'maria@acme-corp.pt';
|
|
114
|
+
const res = maskSensitive(`a ${gh} b ${mail} c ${gh}`, { mode: 'enforce' });
|
|
115
|
+
assert.equal(res.text, 'a [REDACTED:github_token] b [REDACTED:email] c [REDACTED:github_token]');
|
|
116
|
+
assert.deepEqual(res.redactions, { github_token: 2, email: 1 });
|
|
117
|
+
});
|
|
118
|
+
test('assignment keeps the key name, masks only the value', () => {
|
|
119
|
+
const res = maskSensitive('DB_PASSWORD=Sup3rS3cretValue', { mode: 'enforce' });
|
|
120
|
+
assert.equal(res.text, 'DB_PASSWORD=[REDACTED:generic_secret_assignment]');
|
|
121
|
+
});
|
|
122
|
+
test('URL credentials keep scheme and host', () => {
|
|
123
|
+
const res = maskSensitive('https://deploy:hunter2hunter2@git.example.com/o/r.git', { mode: 'enforce' });
|
|
124
|
+
assert.equal(res.text, 'https://[REDACTED:url_credentials]@git.example.com/o/r.git');
|
|
125
|
+
});
|
|
126
|
+
test('multi-line stack trace with one secret', () => {
|
|
127
|
+
const gh = POSITIVES.find(([id]) => id === 'github_token')[1];
|
|
128
|
+
const trace = `Error: boom\n at a (x.js:1:1)\n token=${gh}\n at b (y.js:2:2)`;
|
|
129
|
+
const res = maskSensitive(trace, { mode: 'enforce' });
|
|
130
|
+
assert.ok(res.text.startsWith('Error: boom\n at a (x.js:1:1)\n'));
|
|
131
|
+
assert.ok(res.text.endsWith('\n at b (y.js:2:2)'));
|
|
132
|
+
assert.ok(!res.text.includes(gh));
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('performance guard', () => {
|
|
137
|
+
test('1 MB of pathological input is scanned in bounded time', () => {
|
|
138
|
+
for (const chunk of ['a'.repeat(1_000_000), 'a.'.repeat(500_000), '1 '.repeat(500_000), 'x@'.repeat(300_000)]) {
|
|
139
|
+
const t = Date.now();
|
|
140
|
+
maskSensitive(chunk, { mode: 'enforce' });
|
|
141
|
+
assert.ok(Date.now() - t < 5000, `slow: ${Date.now() - t} ms`);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('maskDeep', () => {
|
|
147
|
+
const gh = POSITIVES.find(([id]) => id === 'github_token')[1];
|
|
148
|
+
test('enforce masks nested strings, keeps keys and non-strings', () => {
|
|
149
|
+
const input = { a: gh, n: 5, ok: true, nested: [{ m: `x ${gh}` }, null], [gh]: 'k' };
|
|
150
|
+
const res = maskDeep(input, { mode: 'enforce' });
|
|
151
|
+
assert.equal(res.value.a, '[REDACTED:github_token]');
|
|
152
|
+
assert.equal(res.value.n, 5);
|
|
153
|
+
assert.equal(res.value.nested[0].m, 'x [REDACTED:github_token]');
|
|
154
|
+
assert.equal(res.value.nested[1], null);
|
|
155
|
+
assert.equal(res.total, 2);
|
|
156
|
+
assert.equal(input.a, gh, 'input not mutated');
|
|
157
|
+
});
|
|
158
|
+
test('warn returns the original value but reports counts; off returns it untouched', () => {
|
|
159
|
+
const input = { a: gh };
|
|
160
|
+
const warn = maskDeep(input, { mode: 'warn' });
|
|
161
|
+
assert.equal(warn.value, input);
|
|
162
|
+
assert.equal(warn.total, 1);
|
|
163
|
+
const off = maskDeep(input, { mode: 'off' });
|
|
164
|
+
assert.equal(off.value, input);
|
|
165
|
+
assert.equal(off.total, 0);
|
|
166
|
+
});
|
|
167
|
+
test('circular structures do not hang', () => {
|
|
168
|
+
const o = { s: gh };
|
|
169
|
+
o.self = o;
|
|
170
|
+
const res = maskDeep(o, { mode: 'enforce' });
|
|
171
|
+
assert.equal(res.value.self, '[circular]');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Synthetic secrets only. Prefixes are split so no scanner (GitHub push protection,
|
|
2
|
+
// trufflehog, our own DLP) flags this file; they are joined at runtime.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
// Deterministic pseudo-random text (sha256 stream), so fixtures are stable but look random.
|
|
5
|
+
const rnd = (alphabet, n, seed) => {
|
|
6
|
+
let out = '';
|
|
7
|
+
for (let i = 0; out.length < n; i++) {
|
|
8
|
+
const h = createHash('sha256').update(`${seed}:${i}`).digest();
|
|
9
|
+
for (const byte of h) { out += alphabet[byte % alphabet.length]; if (out.length === n) break; }
|
|
10
|
+
}
|
|
11
|
+
return out;
|
|
12
|
+
};
|
|
13
|
+
const AZ = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
14
|
+
const AZ09 = AZ + '0123456789';
|
|
15
|
+
const ALNUM = AZ09 + 'abcdefghijklmnopqrstuvwxyz';
|
|
16
|
+
const HEX = '0123456789abcdef';
|
|
17
|
+
const B64U = ALNUM + '-_';
|
|
18
|
+
const B64 = ALNUM + '+/';
|
|
19
|
+
|
|
20
|
+
/** [ruleId, sample text containing exactly one secret, the secret substring] */
|
|
21
|
+
export const POSITIVES = [
|
|
22
|
+
['pem_private_key', `key:\n${'-----BEGIN RSA PRIV' + 'ATE KEY-----'}\nMIIEowIBAAKCAQEAsynthetic${rnd(B64, 60, 1)}\n${'-----END RSA PRIV' + 'ATE KEY-----'}\nafter`, 'MIIEowIBAAKCAQEAsynthetic'],
|
|
23
|
+
['pem_private_key', `${'-----BEGIN OPENSSH PRIV' + 'ATE KEY-----'}\nb3BlbnNzaC1rZXktdjEAAAAA${rnd(B64, 40, 2)}\n${'-----END OPENSSH PRIV' + 'ATE KEY-----'}`, 'b3BlbnNzaC1rZXktdjEAAAAA'],
|
|
24
|
+
['pem_private_key', `${'-----BEGIN PRIV' + 'ATE KEY-----'}\ntruncated${rnd(B64, 30, 3)}`, 'truncated'],
|
|
25
|
+
['aws_access_key_id', `id=${'AKI' + 'A'}${rnd(AZ09, 16, 4)} region=eu`, 'AKIA'],
|
|
26
|
+
['aws_access_key_id', `temp ${'AS' + 'IA'}${rnd(AZ09, 16, 5)}`, 'ASIA'],
|
|
27
|
+
['aws_secret_access_key', `aws_secret_access_key = "${rnd(B64, 40, 6)}"`, null],
|
|
28
|
+
['gcp_api_key', `url?key=${'AI' + 'za'}${rnd(B64U, 35, 7)}&x=1`, 'AIza'],
|
|
29
|
+
['gcp_oauth_token', `tok ${'ya' + '29.'}${rnd(B64U, 40, 8)}`, 'ya29.'],
|
|
30
|
+
['gcp_oauth_client_secret', `secret ${'GOC' + 'SPX-'}${rnd(B64U, 28, 9)}`, 'GOCSPX-'],
|
|
31
|
+
['azure_storage_account_key', `DefaultEndpointsProtocol=https;AccountName=acme;AccountKey=${rnd(B64, 86, 10)}==;EndpointSuffix=core.windows.net`, null],
|
|
32
|
+
['digitalocean_token', `${'do' + 'p_v1_'}${rnd(HEX, 64, 11)}`, 'dop_v1_'],
|
|
33
|
+
['github_token', `${'gh' + 'p_'}${rnd(ALNUM, 36, 12)}`, 'ghp_'],
|
|
34
|
+
['github_fine_grained_pat', `${'github' + '_pat_'}${rnd(ALNUM, 40, 13)}`, 'github_pat_'],
|
|
35
|
+
['gitlab_pat', `${'gl' + 'pat-'}${rnd(B64U, 22, 14)}`, 'glpat-'],
|
|
36
|
+
['npm_token', `//registry.npmjs.org/:_authToken=${'np' + 'm_'}${rnd(ALNUM, 36, 15)}`, null],
|
|
37
|
+
['pypi_token', `${'py' + 'pi-AgEIcHlwaS5vcmc'}${rnd(B64U, 60, 16)}`, 'pypi-'],
|
|
38
|
+
['stripe_key', `${'sk_' + 'live_'}${rnd(ALNUM, 24, 17)}`, 'sk_live_'],
|
|
39
|
+
['stripe_key', `${'rk_' + 'test_'}${rnd(ALNUM, 24, 18)}`, 'rk_test_'],
|
|
40
|
+
['stripe_webhook_secret', `${'wh' + 'sec_'}${rnd(ALNUM, 32, 19)}`, 'whsec_'],
|
|
41
|
+
['shopify_token', `${'shp' + 'at_'}${rnd(HEX, 32, 20)}`, 'shpat_'],
|
|
42
|
+
['slack_token', `${'xo' + 'xb-'}1234567890-${rnd(ALNUM, 24, 21)}`, 'xoxb-'],
|
|
43
|
+
['slack_webhook', `${'https://hooks.' + 'slack.com/services/'}T${rnd(AZ09, 8, 22)}/B${rnd(AZ09, 8, 23)}/${rnd(ALNUM, 24, 24)}`, 'hooks.slack.com'],
|
|
44
|
+
['discord_webhook', `${'https://discord' + '.com/api/webhooks/'}123456789012345678/${rnd(B64U, 40, 25)}`, 'discord.com/api'],
|
|
45
|
+
['sendgrid_key', `${'S' + 'G.'}${rnd(B64U, 22, 26)}.${rnd(B64U, 43, 27)}`, 'SG.'],
|
|
46
|
+
['twilio_api_key', `${'S' + 'K'}${rnd(HEX, 32, 28)}`, null],
|
|
47
|
+
['mailgun_key', `${'ke' + 'y-'}${rnd(HEX, 32, 29)}`, 'key-'],
|
|
48
|
+
['huggingface_token', `${'h' + 'f_'}${rnd(ALNUM, 36, 30)}`, 'hf_'],
|
|
49
|
+
['telegram_bot_token', `bot 123456789:${'A' + 'A'}${rnd(B64U, 33, 31)}`, null],
|
|
50
|
+
['anthropic_key', `${'sk-' + 'ant-api03-'}${rnd(B64U, 40, 32)}`, 'sk-ant-'],
|
|
51
|
+
['openai_key', `${'sk' + '-proj-'}${rnd(ALNUM, 40, 33)}9`, 'sk-proj-'],
|
|
52
|
+
['jwt', `token ${'ey' + 'J'}hbGciOiJIUzI1NiJ9.${'ey' + 'J'}zdWIiOiIxMjM0NTY3ODkwIn0.${rnd(B64U, 43, 34)}`, null],
|
|
53
|
+
['bearer_token', `Authorization: Bearer ${rnd(B64U, 32, 35)}`, null],
|
|
54
|
+
['basic_auth_header', `Authorization: Basic ${Buffer.from('svc:' + rnd(ALNUM, 12, 36)).toString('base64')}`, null],
|
|
55
|
+
['db_connection_string', `DATABASE_URL=${'postgres' + 'ql'}://app:${rnd(ALNUM, 14, 37)}@db.internal:5432/app`, null],
|
|
56
|
+
['db_connection_string', `${'mongodb+' + 'srv'}://user:${rnd(ALNUM, 12, 38)}@cluster0.example.mongodb.net/db`, null],
|
|
57
|
+
['db_connection_string', `${'red' + 'is'}://:${rnd(ALNUM, 16, 39)}@cache.internal:6379`, null],
|
|
58
|
+
['jdbc_password', `jdbc:postgresql://db:5432/app?user=app&password=${rnd(ALNUM, 12, 40)}&ssl=true`, null],
|
|
59
|
+
['ado_connection_password', `Server=tcp:db,1433;Database=app;User Id=sa;Password=${rnd(ALNUM, 12, 41)}1!;Encrypt=True`, null],
|
|
60
|
+
['url_credentials', `git clone https://deploy:${rnd(ALNUM, 20, 42)}@git.example.com/org/repo.git`, null],
|
|
61
|
+
['generic_secret_assignment', `api_key = "${rnd(ALNUM, 24, 43)}"`, null],
|
|
62
|
+
['generic_secret_assignment', `DB_PASSWORD: ${rnd(ALNUM, 10, 44)}#9`, null],
|
|
63
|
+
['generic_secret_assignment', `"client_secret": "${rnd(B64U, 30, 45)}"`, null],
|
|
64
|
+
['email', 'contact maria.silva+ops@acme-corp.pt for access', 'maria.silva+ops@acme-corp.pt'],
|
|
65
|
+
['email', 'user JOAO.Costa@sub.empresa.co.uk failed login', 'JOAO.Costa@sub.empresa.co.uk'],
|
|
66
|
+
['iban', 'IBAN PT50 0002 0123 1234 5678 9015 4 transfer', 'PT50 0002 0123 1234 5678 9015 4'],
|
|
67
|
+
['iban', 'conta GB82WEST12345698765432 ok', 'GB82WEST12345698765432'],
|
|
68
|
+
['credit_card', 'card 4111 1111 1111 1111 declined', '4111 1111 1111 1111'],
|
|
69
|
+
['credit_card', 'pan=5555555555554444;', '5555555555554444'],
|
|
70
|
+
['pt_nif', 'NIF: 123456789 do cliente', '123456789'],
|
|
71
|
+
['pt_nif', 'contribuinte n.º 500000000', '500000000'],
|
|
72
|
+
['phone_international', 'ligar +351 912 345 678 urgente', '+351 912 345 678'],
|
|
73
|
+
['phone_international', 'tel 00351-213-456-789.', '00351-213-456-789'],
|
|
74
|
+
['high_entropy_blob', `blob=${rnd(B64, 48, 46)}Zq9`, null],
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
/** Text that must NOT be altered (false-positive corpus: ordinary code, hashes, ids). */
|
|
78
|
+
export const NEGATIVES = [
|
|
79
|
+
'const password = getPassword();',
|
|
80
|
+
'password: string;',
|
|
81
|
+
'token = process.env.API_TOKEN',
|
|
82
|
+
'const secret = config.secret',
|
|
83
|
+
'api_key = "${API_KEY}"',
|
|
84
|
+
'client_secret: <your-client-secret>',
|
|
85
|
+
'password = ""',
|
|
86
|
+
'if (!token) throw new Error("token required")',
|
|
87
|
+
'authToken: undefined',
|
|
88
|
+
'import { sanitizedChildEnv } from "../utils/child-env.js";',
|
|
89
|
+
'commit 283f6c2a1b9e4d7c5f0a8b6e3d2c1f9a8b7c6d5e merged',
|
|
90
|
+
'sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
|
91
|
+
'id 3f2504e0-4f89-41d3-9a0c-0305e82c3301 created',
|
|
92
|
+
'integrity "sha512-Q7DGnIuEtPSmGZ6JGVkLqUIxhEbuPDtCXXXgEngqNBhwxXrvTjTU0wsyxXfT3qlJl+ZRHU6h3cPRV0vg3s2uHQ=="',
|
|
93
|
+
'/Users/dev/projects/very-long-directory-name/another-long-segment/file-name-here.js',
|
|
94
|
+
'veryLongCamelCaseIdentifierNameThatKeepsGoingAndGoingForeverAndEver',
|
|
95
|
+
'see logo@2x.png and icon@3x.webp',
|
|
96
|
+
'user@host:~$ ls',
|
|
97
|
+
'import x from "@types/node"; npm i @scope/pkg',
|
|
98
|
+
'mail admin@example.com for docs',
|
|
99
|
+
'version +1.2.3 build 20261028 pid 123456789 ts=1698480000',
|
|
100
|
+
'timestamp 2026-10-28T09:00:00+01:00',
|
|
101
|
+
'order 4111 1111 1111 1112 failed luhn',
|
|
102
|
+
'number 123456789012345 is a plain id',
|
|
103
|
+
'NIF: 123456780 invalido',
|
|
104
|
+
'IBAN PT50 0002 0123 1234 5678 9015 5 wrong check digit',
|
|
105
|
+
'https://github.com/VISIONHAMMER/deepdebug-local-agent/pull/52',
|
|
106
|
+
'postgresql://localhost:5432/app',
|
|
107
|
+
'jdbc:postgresql://db:5432/app?ssl=true',
|
|
108
|
+
'Bearer token missing',
|
|
109
|
+
'sk-learn is a library; sk-short',
|
|
110
|
+
'The quick brown fox jumps over the lazy dog 1234.',
|
|
111
|
+
'phone format example: +1 (see docs)',
|
|
112
|
+
'java.lang.NullPointerException at com.acme.Foo.bar(Foo.java:42)',
|
|
113
|
+
'ERROR 2026-10-28 10:00:01 retry 3/5 host=db-01 latency=233ms',
|
|
114
|
+
'secret_manager.get_secret_version(name)',
|
|
115
|
+
'git clone git@github.com:VISIONHAMMER/repo.git',
|
|
116
|
+
'url = `https://${user}:${token}@github.com/o/r.git`',
|
|
117
|
+
'gcloud secrets versions access latest --secret=pg-password-qa',
|
|
118
|
+
'password_hash VARCHAR(255) NOT NULL',
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
export function validPtNif(prefix8) {
|
|
122
|
+
let sum = 0;
|
|
123
|
+
for (let i = 0; i < 8; i++) sum += Number(prefix8[i]) * (9 - i);
|
|
124
|
+
const c = 11 - (sum % 11);
|
|
125
|
+
return prefix8 + String(c >= 10 ? 0 : c);
|
|
126
|
+
}
|