Skip to content
This repository was archived by the owner on May 25, 2025. It is now read-only.

Commit ae87afa

Browse files
authored
feat: scrub substring values with their hash (#44)
* feat: scrub mac address from objects * chore: define the SQL scrubber * chore: make scrubber generic * chore: fix some sql scrubber thingies
1 parent c94b67d commit ae87afa

3 files changed

Lines changed: 165 additions & 0 deletions

File tree

src/scrubber.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,3 +430,53 @@ test('getScrubberSql', () => {
430430
expect(scrubber.getScrubberSql('pw')).toMatchInlineSnapshot(`"'notsecret'"`)
431431
expect(scrubber.getScrubberSql('name')).toMatchInlineSnapshot(`"'Jane Doe'"`)
432432
})
433+
434+
test('saltedHashSubstringScrubber should scrub substring values', () => {
435+
const data = {
436+
Data: [
437+
{ id: '01' },
438+
{ id: 'ab02cd' },
439+
{ id: 'ab03cd', foo: '03' },
440+
{ id: 'ab04cd', foo: 'ab04cd' },
441+
{ id: '01\n02\n03' },
442+
],
443+
}
444+
445+
const result = scrub(data, {
446+
fields: {
447+
'id,foo': {
448+
scrubber: 'saltedHashSubstringScrubber',
449+
params: {
450+
regex: ['\\d\\d'],
451+
initializationVector: 'initializationVector',
452+
},
453+
},
454+
},
455+
})
456+
457+
expect(result).toMatchInlineSnapshot(`
458+
{
459+
"Data": [
460+
{
461+
"id": "42bb960e91b4abf82bd6bdcc8e49cb405678ba5655a1cdc0210a4089cf2980f9",
462+
},
463+
{
464+
"id": "ab5365d6a9320a362fe52dbd54a20bc58eaa775d548e20dccf58d761882201381acd",
465+
},
466+
{
467+
"foo": "bb722ef61aa727e4a61aab72132badd39388204e9c6d8653c90a313a581bd622",
468+
"id": "abbb722ef61aa727e4a61aab72132badd39388204e9c6d8653c90a313a581bd622cd",
469+
},
470+
{
471+
"foo": "ab67fe825923d446fa7cd7711e66345232ab15a4bdc1cc9590b975353be70ad616cd",
472+
"id": "ab67fe825923d446fa7cd7711e66345232ab15a4bdc1cc9590b975353be70ad616cd",
473+
},
474+
{
475+
"id": "42bb960e91b4abf82bd6bdcc8e49cb405678ba5655a1cdc0210a4089cf2980f9
476+
5365d6a9320a362fe52dbd54a20bc58eaa775d548e20dccf58d761882201381a
477+
bb722ef61aa727e4a61aab72132badd39388204e9c6d8653c90a313a581bd622",
478+
},
479+
],
480+
}
481+
`)
482+
})

src/scrubbers.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { _stringMapEntries } from '@naturalcycles/js-lib'
12
import { nanoid } from '@naturalcycles/nodejs-lib'
23
import {
34
bcryptStringScrubber,
@@ -7,6 +8,7 @@ import {
78
charsFromRightScrubberSQL,
89
isoDateStringScrubber,
910
isoDateStringScrubberSQL,
11+
saltedHashSubstringScrubber,
1012
preserveOriginalScrubber,
1113
preserveOriginalScrubberSQL,
1214
randomEmailInContentScrubber,
@@ -25,6 +27,8 @@ import {
2527
undefinedScrubberSQL,
2628
unixTimestampScrubber,
2729
unixTimestampScrubberSQL,
30+
defaultScrubbers,
31+
defaultScrubbersSQL,
2832
} from './scrubbers'
2933

3034
const bryptStr1 = '$2a$12$HYNzBb8XYOZZeRwZDiVux.orKNqkSVAoXBDc9Gw7nSxr8rcZupbRK'
@@ -434,3 +438,76 @@ test('bcryptStringScrubberSQL', () => {
434438
bcryptStringScrubberSQL({ replacements: '$2a$10$:$2a$10$456,$2a$12$:$2a$12$123' }),
435439
).toMatchSnapshot()
436440
})
441+
442+
describe('saltedHashSubstringScrubber', () => {
443+
const initializationVector = nanoid()
444+
445+
test('should scrub the matching substring with a hash', () => {
446+
const result = saltedHashSubstringScrubber('foo|00:00:00:00:00:00|bar', {
447+
regex: '00:00:00:00:00:00',
448+
initializationVector,
449+
})
450+
451+
expect(result).toMatch(/foo\|.{64}\|bar/)
452+
expect(result).not.toContain('00:00:00:00:00:00')
453+
})
454+
455+
test('should scrub the same value with the same hash', () => {
456+
const result1 = saltedHashSubstringScrubber('foo|00:00:00:00:00:00|bar', {
457+
regex: '00:00:00:00:00:00',
458+
initializationVector,
459+
})
460+
461+
const result2 = saltedHashSubstringScrubber('bee|00:00:00:00:00:00|boo', {
462+
regex: '00:00:00:00:00:00',
463+
initializationVector,
464+
})
465+
466+
expect(result1?.substring(4, 64)).toBe(result2?.substring(4, 64))
467+
})
468+
469+
test('should scrub substring using regex', () => {
470+
const result = saltedHashSubstringScrubber('foo|00:00:00:00:00:00|bar', {
471+
regex:
472+
'[0-9a-zA-Z]{2}:[0-9a-zA-Z]{2}:[0-9a-zA-Z]{2}:[0-9a-zA-Z]{2}:[0-9a-zA-Z]{2}:[0-9a-zA-Z]{2}',
473+
initializationVector,
474+
})
475+
476+
expect(result).toMatch(/foo\|.{64}\|bar/)
477+
expect(result).not.toContain('00:00:00:00:00:00')
478+
})
479+
480+
test('should scrub multiple occurrences', () => {
481+
const result = saltedHashSubstringScrubber('foo|max|bar|max|boo', {
482+
regex: 'max',
483+
initializationVector,
484+
})
485+
486+
expect(result).not.toContain('max')
487+
})
488+
489+
test('should throw when the salt is missing', () => {
490+
expect(() => saltedHashSubstringScrubber('foo|max|bar', { regex: 'max' } as any)).toThrow(
491+
'Initialization vector is missing',
492+
)
493+
})
494+
495+
test('should throw when the regex or substring is missing', () => {
496+
expect(() =>
497+
saltedHashSubstringScrubber('foo|max|bar', {
498+
initializationVector,
499+
} as any),
500+
).toThrow('Substring or regex is missing')
501+
})
502+
})
503+
504+
const scrubberNames = _stringMapEntries(defaultScrubbers).map(([k]) => k)
505+
test.each(scrubberNames)('the %s should have its SQL scrubber counterpart', scrubberName => {
506+
console.log(scrubberName, defaultScrubbersSQL[scrubberName])
507+
expect(defaultScrubbersSQL[scrubberName]).toBeDefined()
508+
})
509+
510+
const sqlScrubberNames = _stringMapEntries(defaultScrubbersSQL).map(([k]) => k)
511+
test.each(sqlScrubberNames)('the %s should have its scrubber counterpart', scrubberName => {
512+
expect(defaultScrubbers[scrubberName]).toBeDefined()
513+
})

src/scrubbers.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,42 @@ export const bcryptStringScrubberSQL: BcryptStringScrubberSQLFn = params => {
462462
END`
463463
}
464464

465+
export type SaltedHashSubstringScrubberFn = ScrubberFn<
466+
string | undefined,
467+
SaltedHashSubstringScrubberParams
468+
>
469+
470+
export type SaltedHashSubstringScrubberSQLFn = ScrubberSQLFn<SaltedHashSubstringScrubberParams>
471+
472+
export interface SaltedHashSubstringScrubberParams {
473+
initializationVector: string
474+
regex: string
475+
}
476+
477+
export const saltedHashSubstringScrubber: SaltedHashSubstringScrubberFn = (value, params) => {
478+
_assert(params?.initializationVector, 'Initialization vector is missing')
479+
_assert(params?.regex, 'Substring or regex is missing')
480+
481+
if (!value) return value
482+
483+
const regex = new RegExp(params.regex, 'g')
484+
485+
return value.replace(regex, substring =>
486+
crypto.createHash('sha256').update(substring).update(params.initializationVector).digest('hex'),
487+
)
488+
}
489+
490+
export const saltedHashSubstringScrubberSQL: SaltedHashSubstringScrubberSQLFn = params => {
491+
_assert(params?.initializationVector, 'Initialization vector is missing')
492+
_assert(params?.regex, 'Substring or regex is missing')
493+
494+
const substringToReplace = `COALESCE(REGEXP_SUBSTR(${sqlValueToReplace}, '${params.regex}'), '')`
495+
const hashedValue = `SHA2(${substringToReplace} || '${params.initializationVector}', 256)`
496+
const replacedValue = `REGEXP_REPLACE(${sqlValueToReplace}, '${params.regex}', ${hashedValue})`
497+
498+
return replacedValue
499+
}
500+
465501
function nthChar(str: string, character: string, n: number): number | undefined {
466502
let count = 0
467503
let i = 0
@@ -489,6 +525,7 @@ export const defaultScrubbers: ScrubbersMap = {
489525
saltedHashScrubber,
490526
saltedHashEmailScrubber,
491527
bcryptStringScrubber,
528+
saltedHashSubstringScrubber,
492529
}
493530

494531
export const defaultScrubbersSQL: ScrubbersSQLMap = {
@@ -504,4 +541,5 @@ export const defaultScrubbersSQL: ScrubbersSQLMap = {
504541
saltedHashScrubber: saltedHashScrubberSQL,
505542
saltedHashEmailScrubber: saltedHashEmailScrubberSQL,
506543
bcryptStringScrubber: bcryptStringScrubberSQL,
544+
saltedHashSubstringScrubber: saltedHashSubstringScrubberSQL,
507545
}

0 commit comments

Comments
 (0)