From 3637052954589a499044382a0737647b2fb585a5 Mon Sep 17 00:00:00 2001 From: svozza Date: Sun, 29 Mar 2026 11:49:01 +0100 Subject: [PATCH 01/17] feat(data-masking): add data masking utility Add new @aws-lambda-powertools/data-masking package with support for: - Irreversible field erasure with default or custom masking rules - Field-level and full-payload encryption/decryption via AWS Encryption SDK - Encryption context for integrity and authenticity - Dot notation and [*] wildcard field selection - Prototype pollution protection Includes unit tests with property-based testing (fast-check), e2e test scaffolding, and user-facing documentation. --- .gitignore | 9 +- .markdownlintignore | 5 +- docs/features/data-masking.md | 621 ++++++++++++++++++ mkdocs.yml | 1 + package-lock.json | 496 +++++++++++++- package.json | 3 +- packages/data-masking/package.json | 101 +++ packages/data-masking/src/DataMasking.ts | 252 +++++++ packages/data-masking/src/constants.ts | 1 + packages/data-masking/src/errors.ts | 23 + packages/data-masking/src/index.ts | 15 + .../src/provider/AWSEncryptionSDKProvider.ts | 108 +++ packages/data-masking/src/provider/index.ts | 4 + packages/data-masking/src/types.ts | 56 ++ packages/data-masking/tests/e2e/constants.ts | 1 + .../e2e/dataMasking.test.FunctionCode.ts | 42 ++ .../tests/e2e/dataMasking.test.ts | 120 ++++ .../unit/AWSEncryptionSDKProvider.test.ts | 55 ++ .../tests/unit/encrypt-decrypt.test.ts | 187 ++++++ .../data-masking/tests/unit/erase.test.ts | 239 +++++++ packages/data-masking/tsconfig.cjs.json | 11 + packages/data-masking/tsconfig.json | 11 + packages/data-masking/vitest.config.ts | 8 + 23 files changed, 2365 insertions(+), 4 deletions(-) create mode 100644 docs/features/data-masking.md create mode 100644 packages/data-masking/package.json create mode 100644 packages/data-masking/src/DataMasking.ts create mode 100644 packages/data-masking/src/constants.ts create mode 100644 packages/data-masking/src/errors.ts create mode 100644 packages/data-masking/src/index.ts create mode 100644 packages/data-masking/src/provider/AWSEncryptionSDKProvider.ts create mode 100644 packages/data-masking/src/provider/index.ts create mode 100644 packages/data-masking/src/types.ts create mode 100644 packages/data-masking/tests/e2e/constants.ts create mode 100644 packages/data-masking/tests/e2e/dataMasking.test.FunctionCode.ts create mode 100644 packages/data-masking/tests/e2e/dataMasking.test.ts create mode 100644 packages/data-masking/tests/unit/AWSEncryptionSDKProvider.test.ts create mode 100644 packages/data-masking/tests/unit/encrypt-decrypt.test.ts create mode 100644 packages/data-masking/tests/unit/erase.test.ts create mode 100644 packages/data-masking/tsconfig.cjs.json create mode 100644 packages/data-masking/tsconfig.json create mode 100644 packages/data-masking/vitest.config.ts diff --git a/.gitignore b/.gitignore index c8312c40ff..e6b19f5a49 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,11 @@ tsconfig.tsbuildinfo .amazonq .kiro .github/instructions -aidlc-docs \ No newline at end of file +aidlc-docsspecs/ +.specify/ +AGENTS.md +# generated agent artifacts +specs/ +.specify/ +AGENTS.md +aidlc-docs/ diff --git a/.markdownlintignore b/.markdownlintignore index df726bebbe..81cf6e78b1 100644 --- a/.markdownlintignore +++ b/.markdownlintignore @@ -10,4 +10,7 @@ layers/*/CHANGELOG.md LICENSE .github/** **node_modules/** */ -.venv \ No newline at end of file +.venv +# generated spec artifacts +specs/** +.specify/** \ No newline at end of file diff --git a/docs/features/data-masking.md b/docs/features/data-masking.md new file mode 100644 index 0000000000..06cd81344c --- /dev/null +++ b/docs/features/data-masking.md @@ -0,0 +1,621 @@ +--- +title: Data Masking +description: Utility +--- + +The data masking utility can encrypt, decrypt, or irreversibly erase sensitive information to protect data confidentiality. + +```mermaid +stateDiagram-v2 + direction LR + LambdaFn: Your Lambda function + DataMasking: DataMasking + Operation: Possible operations + Input: Sensitive value + Erase: Erase + Encrypt: Encrypt + Decrypt: Decrypt + Provider: AWS Encryption SDK provider + Result: Data transformed (erased, encrypted, or decrypted) + + LambdaFn --> DataMasking + DataMasking --> Operation + + state Operation { + [*] --> Input + Input --> Erase: Irreversible + Input --> Encrypt + Input --> Decrypt + Encrypt --> Provider + Decrypt --> Provider + } + + Operation --> Result +``` + +## Key features + +* Encrypt, decrypt, or irreversibly erase data with ease +* Erase sensitive information in one or more fields within nested data +* Seamless integration with [AWS Encryption SDK](https://docs.aws.amazon.com/encryption-sdk/latest/developer-guide/introduction.html){target="_blank"} for industry and AWS security best practices + +## Terminology + +**Erasing** replaces sensitive information **irreversibly** with a non-sensitive placeholder _(`*****`)_, or with a customised mask. This operation replaces data in-memory, making it a one-way action. + +**Encrypting** transforms plaintext into ciphertext using an encryption algorithm and a cryptographic key. It allows you to encrypt any sensitive data, so only allowed personnel to decrypt it. Learn more about [encryption and how AWS can help](https://aws.amazon.com/blogs/security/importance-of-encryption-and-how-aws-can-help/){target="_blank"}. + +**Decrypting** transforms ciphertext back into plaintext using a decryption algorithm and the correct decryption key. + +**Encryption context** is a non-secret `key=value` data used for authentication like `tenantId:`. This adds extra security and confirms encrypted data relationship with a context. + +**[Encrypted message](https://docs.aws.amazon.com/encryption-sdk/latest/developer-guide/message-format.html){target="_blank"}** is a portable data structure that includes encrypted data along with copies of the encrypted data key. It includes everything Encryption SDK needs to validate authenticity, integrity, and to decrypt with the right master key. + +**[Envelope encryption](https://docs.aws.amazon.com/encryption-sdk/latest/developer-guide/concepts.html#envelope-encryption){target="_blank"}** uses two different keys to encrypt data safely: master and data key. The data key encrypts the plaintext, and the master key encrypts the data key. It simplifies key management _(you own the master key)_, isolates compromises to data key, +and scales better with large data volumes. + +```mermaid +graph LR + M(Master key) --> |Encrypts| D(Data key) + D(Data key) --> |Encrypts| S(Sensitive data) +``` + +Envelope encryption visualized. + +## Getting started + +### Install + +```bash +npm install @aws-lambda-powertools/data-masking +``` + +For encryption and decryption, you also need the AWS Encryption SDK as a peer dependency: + +```bash +npm install @aws-crypto/client-node +``` + +### Required resources + +!!! info "By default, we use Amazon Key Management Service (KMS) for encryption and decryption operations." + +Before you start, you will need a KMS symmetric key to encrypt and decrypt your data. Your Lambda function will need read and write access to it. + +**NOTE**. We recommend setting a minimum of 1024MB of memory _(CPU intensive)_, and separate Lambda functions for encrypt and decrypt. + +### Erasing data + +Erasing will remove the original data and replace it with `*****`. This means you cannot recover erased data, and the data type will change to `string` for all erased values. + +```typescript title="getting_started_erase_data.ts" hl_lines="1 3 8" +import { DataMasking } from '@aws-lambda-powertools/data-masking'; + +const masker = new DataMasking(); + +export const handler = async (event: { body: Record }) => { + const data = event.body; + + return masker.erase(data, { fields: ['email', 'address.street', 'company_address'] }); // (1)! +}; +``` + +1. See [choosing parts of your data](#choosing-parts-of-your-data) to learn more about the `fields` option. + + If we omit `fields`, the entire object will be erased with `*****`. + +=== "Input" + + ```json + { + "name": "Jane Doe", + "age": 30, + "email": "jane@example.com", + "address": { + "street": "123 Main St", + "city": "Anytown" + }, + "company_address": { + "street": "456 ACME Ave", + "city": "Anytown" + } + } + ``` + +=== "Output" + + ```json + { + "name": "Jane Doe", + "age": 30, + "email": "*****", + "address": { + "street": "*****", + "city": "Anytown" + }, + "company_address": "*****" + } + ``` + +#### Custom masking + +The `erase` method also supports additional options for more advanced and flexible masking via `maskingRules`: + +```typescript title="custom_masking.ts" hl_lines="6-11" +import { DataMasking } from '@aws-lambda-powertools/data-masking'; + +const masker = new DataMasking(); + +const masked = masker.erase(data, { + maskingRules: { + email: { regexPattern: /(.)(.*)(@.*)/, maskFormat: '$1****$3' }, // j****@example.com + ssn: { dynamicMask: true }, // mask length matches original + zip: { customMask: 'XXXXX' }, // fixed replacement string + }, +}); +``` + +| Option | Description | +| --- | --- | +| `regexPattern` + `maskFormat` | Partial masking via regex replacement | +| `dynamicMask: true` | Replace with `*` repeated to match original value length | +| `customMask` | Replace with an exact string | + +### Encrypting data + +???+ note "About static typing and encryption" + Encrypting data may lead to a different data type, as it always transforms into a string _(``)_. + +To encrypt, you will need an [encryption provider](#providers). Here, we will use `AWSEncryptionSDKProvider`. + +Under the hood, we delegate a [number of operations](#encrypt-operation-with-encryption-sdk-kms) to AWS Encryption SDK to authenticate, create a portable encryption message, and actual data encryption. + +```typescript title="getting_started_encrypt_data.ts" hl_lines="1-2 4-6 13" +import { DataMasking } from '@aws-lambda-powertools/data-masking'; +import { AWSEncryptionSDKProvider } from '@aws-lambda-powertools/data-masking/provider'; + +const provider = new AWSEncryptionSDKProvider({ + keys: [process.env.KMS_KEY_ARN], // (1)! +}); +const masker = new DataMasking({ provider }); + +export const handler = async (event: { body: Record }) => { + const data = event.body; + + const encrypted = await masker.encrypt(data); + + return { body: encrypted }; +}; +``` + +1. You can use more than one KMS Key for higher availability but increased latency. + + Encryption SDK will ensure the data key is encrypted with both keys. + +### Decrypting data + +???+ note "About static typing and decryption" + Decrypting data may lead to a different data type, as encrypted data is always a string _(``)_. + +To decrypt, you will need an [encryption provider](#providers). Here, we will use `AWSEncryptionSDKProvider`. + +Under the hood, we delegate a [number of operations](#decrypt-operation-with-encryption-sdk-kms) to AWS Encryption SDK to verify authentication, integrity, and actual ciphertext decryption. + +```typescript title="getting_started_decrypt_data.ts" hl_lines="1-2 4-6 13" +import { DataMasking } from '@aws-lambda-powertools/data-masking'; +import { AWSEncryptionSDKProvider } from '@aws-lambda-powertools/data-masking/provider'; + +const provider = new AWSEncryptionSDKProvider({ + keys: [process.env.KMS_KEY_ARN], // (1)! +}); +const masker = new DataMasking({ provider }); + +export const handler = async (event: { body: Record }) => { + const data = event.body; + + const decrypted = await masker.decrypt(data); + + return decrypted; +}; +``` + +1. Note that KMS key alias or key ID won't work for decryption. You must use the full KMS Key ARN. + +### Encryption context for integrity and authenticity + +For a stronger security posture, you can add metadata to each encryption operation, and verify them during decryption. This is known as additional authenticated data (AAD). These are non-sensitive data that can help protect authenticity and integrity of your encrypted data, +and even help to prevent a [confused deputy](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html){target="_blank"} situation. + +???+ danger "Important considerations you should know" + 1. **Exact match verification on decrypt**. Be careful using random data like `timestamps` as encryption context if you can't provide them on decrypt. + 2. **Only `string` values are supported**. + 3. **Use non-sensitive data only**. When using KMS, encryption context is available as plaintext in AWS CloudTrail, unless you [intentionally disabled KMS events](https://docs.aws.amazon.com/kms/latest/developerguide/logging-using-cloudtrail.html#filtering-kms-events){target="_blank"}. + +=== "Encrypting with context" + + ```typescript hl_lines="3-6" + const encrypted = await masker.encrypt(data, { + fields: ['customer.ssn'], + context: { + tenantId: 'acme', + classification: 'confidential', + }, // (1)! + }); + ``` + + 1. They must match on `decrypt()` otherwise the operation will fail. + +=== "Decrypting with context" + + ```typescript hl_lines="3-6" + const decrypted = await masker.decrypt(encrypted, { + fields: ['customer.ssn'], + context: { + tenantId: 'acme', + classification: 'confidential', + }, // (1)! + }); + ``` + + 1. They must match otherwise the operation will fail. + +### Choosing parts of your data + +You can use the `fields` option with the dot notation `.` to choose one or more parts of your data to `erase`, `encrypt`, or `decrypt`. This is useful when you want to keep data structure intact except the confidential fields. + +When `fields` is present, operations behave differently: + +| Operation | Behaviour | Example | Result | +| --- | --- | --- | --- | +| `erase` | Replace data while keeping structure intact. | `{"cards": ["a", "b"]}` | `{"cards": ["*****", "*****"]}` | +| `encrypt` | Encrypt individual field values in place. | `{"ssn": "123"}` | `{"ssn": ""}` | +| `decrypt` | Decrypt individual field values in place. | `{"ssn": ""}` | `{"ssn": "123"}` | + +Here are common scenarios to best visualise how to use `fields`. + +=== "Top keys only" + + You want to erase data in the `card_number` field. + + > Expression: `masker.erase(data, { fields: ['card_number'] })` + + === "Data" + + ```json hl_lines="4" + { + "name": "Jane", + "operation": "non sensitive", + "card_number": "1111 2222 3333 4444" + } + ``` + + === "Result" + + ```json hl_lines="4" + { + "name": "Jane", + "operation": "non sensitive", + "card_number": "*****" + } + ``` + +=== "Nested key" + + You want to erase data in the `postcode` field. + + > Expression: `masker.erase(data, { fields: ['address.postcode'] })` + + === "Data" + + ```json hl_lines="6" + { + "name": "Jane", + "card_number": "1111 2222 3333 4444", + "address": { + "street": "123 Main St", + "postcode": 12345 + } + } + ``` + + === "Result" + + ```json hl_lines="6" + { + "name": "Jane", + "card_number": "1111 2222 3333 4444", + "address": { + "street": "123 Main St", + "postcode": "*****" + } + } + ``` + +=== "Multiple keys" + + You want to erase data in both `postcode` and `street` fields. + + > Expression: `masker.erase(data, { fields: ['address.postcode', 'address.street'] })` + + === "Data" + + ```json hl_lines="4-5" + { + "name": "Jane", + "address": { + "street": "123 Main St", + "postcode": 12345 + } + } + ``` + + === "Result" + + ```json hl_lines="4-5" + { + "name": "Jane", + "address": { + "street": "*****", + "postcode": "*****" + } + } + ``` + +=== "All fields in a list" + + You want to erase data under `street` field located at any index of the address list. + + > Expression: `masker.erase(data, { fields: ['address[*].street'] })` + + === "Data" + + ```json hl_lines="6 11" + { + "name": "Jane", + "address": [ + { + "postcode": 12345, + "street": "123 Any Drive" + }, + { + "postcode": 67890, + "street": "100 Main Street" + } + ] + } + ``` + + === "Result" + + ```json hl_lines="6 11" + { + "name": "Jane", + "address": [ + { + "postcode": 12345, + "street": "*****" + }, + { + "postcode": 67890, + "street": "*****" + } + ] + } + ``` + +## Advanced + +### Using multiple keys + +You can use multiple KMS keys from more than one AWS account for higher availability, when instantiating `AWSEncryptionSDKProvider`. + +```typescript title="using_multiple_keys.ts" hl_lines="4" +import { AWSEncryptionSDKProvider } from '@aws-lambda-powertools/data-masking/provider'; + +const provider = new AWSEncryptionSDKProvider({ + keys: [process.env.KMS_KEY_ARN_1, process.env.KMS_KEY_ARN_2], +}); +``` + +### Providers + +#### AWS Encryption SDK + +You can modify the following values when initialising the `AWSEncryptionSDKProvider` to best accommodate your security and performance thresholds. + +| Parameter | Default | Description | +| --- | --- | --- | +| `localCacheCapacity` | `100` | The maximum number of entries that can be retained in the local cryptographic materials cache | +| `maxCacheAgeSeconds` | `300` | The maximum time (in seconds) that a cache entry may be kept in the cache | +| `maxMessagesEncrypted` | `4294967296` | The maximum number of messages that may be encrypted under a cache entry | +| `maxBytesEncrypted` | `Number.MAX_SAFE_INTEGER` | The maximum number of bytes that may be encrypted under a cache entry | + +If required, you can customise the default values when initialising the `AWSEncryptionSDKProvider` class. + +```typescript title="aws_encryption_provider_example.ts" hl_lines="4-8" +import { AWSEncryptionSDKProvider } from '@aws-lambda-powertools/data-masking/provider'; + +const provider = new AWSEncryptionSDKProvider({ + keys: [process.env.KMS_KEY_ARN], + localCacheCapacity: 200, + maxCacheAgeSeconds: 400, + maxMessagesEncrypted: 200, + maxBytesEncrypted: 2000, +}); +``` + +### Data masking request flow + +The following sequence diagrams explain how `DataMasking` behaves under different scenarios. + +#### Erase operation + +Erasing operations occur in-memory and we cannot recover the original value. + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Lambda + participant DataMasking as Data Masking (in memory) + Client->>Lambda: Invoke (event) + Lambda->>DataMasking: erase(data) + DataMasking->>DataMasking: replaces data with ***** + Note over Lambda,DataMasking: No encryption providers involved. + DataMasking->>Lambda: data masked + Lambda-->>Client: Return response +``` + +Simple masking operation + +#### Encrypt operation with Encryption SDK (KMS) + +We call KMS to generate a unique data key that can be used for multiple `encrypt` operations in-memory. It improves performance, cost and prevents throttling. + +To make this operation simpler to visualise, we keep caching details in a [separate sequence diagram](#caching-encrypt-operations-with-encryption-sdk). Caching is enabled by default. + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Lambda + participant DataMasking as Data Masking + participant EncryptionProvider as Encryption Provider + Client->>Lambda: Invoke (event) + Lambda->>DataMasking: Init Encryption Provider with master key + Note over Lambda,DataMasking: AWSEncryptionSDKProvider({ keys: [KMS_KEY] }) + Lambda->>DataMasking: encrypt(data) + DataMasking->>EncryptionProvider: Create unique data key + Note over DataMasking,EncryptionProvider: KMS GenerateDataKey API + DataMasking->>DataMasking: Cache new unique data key + DataMasking->>DataMasking: DATA_KEY.encrypt(data) + DataMasking->>DataMasking: MASTER_KEY.encrypt(DATA_KEY) + DataMasking->>DataMasking: Create encrypted message + Note over DataMasking: Encrypted message includes encrypted data, data key encrypted, algorithm, and more. + DataMasking->>Lambda: Ciphertext from encrypted message + Lambda-->>Client: Return response +``` + +Encrypting operation using envelope encryption. + +#### Decrypt operation with Encryption SDK (KMS) + +We call KMS to decrypt the encrypted data key available in the encrypted message. If successful, we run authentication _(context)_ and integrity checks (_algorithm, data key length, etc_) to confirm its proceedings. + +Lastly, we decrypt the original encrypted data, throw away the decrypted data key for security reasons, and return the original plaintext data. + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Lambda + participant DataMasking as Data Masking + participant EncryptionProvider as Encryption Provider + Client->>Lambda: Invoke (event) + Lambda->>DataMasking: Init Encryption Provider with master key + Note over Lambda,DataMasking: AWSEncryptionSDKProvider({ keys: [KMS_KEY] }) + Lambda->>DataMasking: decrypt(data) + DataMasking->>EncryptionProvider: Decrypt encrypted data key + Note over DataMasking,EncryptionProvider: KMS Decrypt API + DataMasking->>DataMasking: Authentication and integrity checks + DataMasking->>DataMasking: DATA_KEY.decrypt(data) + DataMasking->>DataMasking: Discards decrypted data key + DataMasking->>Lambda: Plaintext + Lambda-->>Client: Return response +``` + +Decrypting operation using envelope encryption. + +#### Caching encrypt operations with Encryption SDK + +Without caching, every `encrypt()` operation would generate a new data key. It significantly increases latency and cost for ephemeral and short running environments like Lambda. + +With caching, we balance ephemeral Lambda environment performance characteristics with [adjustable thresholds](#aws-encryption-sdk) to meet your security needs. + +!!! info "Data key recycling" + We request a new data key when a cached data key exceeds any of the following security thresholds: + + 1. **Max age in seconds** + 2. **Max number of encrypted messages** + 3. **Max bytes encrypted** across all operations + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Lambda + participant DataMasking as Data Masking + participant EncryptionProvider as Encryption Provider + Client->>Lambda: Invoke (event) + Lambda->>DataMasking: Init Encryption Provider with master key + Note over Lambda,DataMasking: AWSEncryptionSDKProvider({ keys: [KMS_KEY] }) + Lambda->>DataMasking: encrypt(data) + DataMasking->>EncryptionProvider: Create unique data key + Note over DataMasking,EncryptionProvider: KMS GenerateDataKey API + DataMasking->>DataMasking: Cache new unique data key + DataMasking->>DataMasking: DATA_KEY.encrypt(data) + DataMasking->>DataMasking: MASTER_KEY.encrypt(DATA_KEY) + DataMasking->>DataMasking: Create encrypted message + DataMasking->>Lambda: Ciphertext from encrypted message + Lambda->>DataMasking: encrypt(another_data) + DataMasking->>DataMasking: Searches for data key in cache + alt Is Data key in cache? + DataMasking->>DataMasking: Reuses data key + else Is Data key evicted from cache? + DataMasking->>EncryptionProvider: Create unique data key + DataMasking->>DataMasking: MASTER_KEY.encrypt(DATA_KEY) + end + DataMasking->>DataMasking: DATA_KEY.encrypt(data) + DataMasking->>DataMasking: Create encrypted message + DataMasking->>Lambda: Ciphertext from encrypted message + Lambda-->>Client: Return response +``` + +Caching data keys during encrypt operation. + +## Testing your code + +### Testing erase operation + +Testing your code with a simple erase operation requires no mocking. + +```typescript title="test_data_masking.test.ts" +import { DataMasking } from '@aws-lambda-powertools/data-masking'; + +const masker = new DataMasking(); + +test('masks sensitive fields', () => { + const result = masker.erase( + { name: 'Jane', ssn: '123-45-6789' }, + { fields: ['ssn'] } + ); + + expect(result.ssn).toBe('*****'); + expect(result.name).toBe('Jane'); +}); +``` + +### Testing encrypt/decrypt operations + +For encrypt/decrypt, create a mock provider implementing the `EncryptionProvider` interface to avoid needing real KMS keys: + +```typescript title="test_data_masking_encrypt.test.ts" +import { DataMasking } from '@aws-lambda-powertools/data-masking'; +import type { EncryptionProvider } from '@aws-lambda-powertools/data-masking/types'; + +const mockProvider: EncryptionProvider = { + encrypt: async (data: string) => `ENC:${data}`, + decrypt: async (data: string) => data.replace('ENC:', ''), +}; + +const masker = new DataMasking({ provider: mockProvider }); + +test('encrypts and decrypts fields', async () => { + const data = { secret: 'value', public: 'visible' }; + + const encrypted = await masker.encrypt(data, { fields: ['secret'] }); + const decrypted = await masker.decrypt(encrypted, { fields: ['secret'] }); + + expect(decrypted).toEqual(data); +}); +``` diff --git a/mkdocs.yml b/mkdocs.yml index 79ccff71b1..b37c4e969b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -57,6 +57,7 @@ nav: - features/jmespath.md - features/parser.md - features/validation.md + - features/data-masking.md - features/kafka.md - features/metadata.md - Environment variables: environment-variables.md diff --git a/package-lock.json b/package-lock.json index 98c3bf3dab..2e65b3e66c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,8 @@ "examples/app", "packages/event-handler", "packages/validation", - "packages/kafka" + "packages/kafka", + "packages/data-masking" ], "devDependencies": { "@biomejs/biome": "^2.4.10", @@ -1517,6 +1518,80 @@ "node": ">= 6" } }, + "node_modules/@aws-crypto/branch-keystore-node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@aws-crypto/branch-keystore-node/-/branch-keystore-node-4.2.2.tgz", + "integrity": "sha512-3s1bX2ofExrG43EFFTtWOydXz7tI6JKUuax1MhZNwdSBhJw6y4FEtJVdPKquNesjxyXfKTHY0Mhz9RFF0K4T6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/kms-keyring": "^4.2.2", + "@aws-sdk/client-dynamodb": "^3.616.0", + "@aws-sdk/client-kms": "^3.616.0", + "@aws-sdk/util-dynamodb": "^3.616.0", + "tslib": "^2.2.0", + "uuid": "^9.0.0" + } + }, + "node_modules/@aws-crypto/branch-keystore-node/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@aws-crypto/cache-material": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@aws-crypto/cache-material/-/cache-material-4.2.2.tgz", + "integrity": "sha512-GF+aqEb2FRKfqHPuhSprRg2QhWeDrv0Vd6LNoCTDD7DFfpsvZorN9votg4EU9bnFubI2Q5OKlS5/KiCMpI1d9w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/material-management": "^4.2.2", + "@aws-crypto/serialize": "^4.2.2", + "@types/lru-cache": "^5.1.0", + "lru-cache": "^6.0.0", + "tslib": "^2.2.0" + } + }, + "node_modules/@aws-crypto/caching-materials-manager-node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@aws-crypto/caching-materials-manager-node/-/caching-materials-manager-node-4.2.2.tgz", + "integrity": "sha512-ehhpA8z9Iq0HWxHYup2HFTxmovnP5kz9m6J+kKx4zno0cFuwQHAXR3cVBmR+uywOxRI+UCh4HQdDH7T/HEVTlA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/cache-material": "^4.2.2", + "@aws-crypto/material-management-node": "^4.2.2", + "tslib": "^2.2.0" + } + }, + "node_modules/@aws-crypto/client-node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@aws-crypto/client-node/-/client-node-4.2.2.tgz", + "integrity": "sha512-sQZtyoI+sOmHKbRMq0scjjfOz5DThDA+NwTSSpqeuupmA8YWG/EMAkaRjZe0Xe7opCgHSdWpP7vYZVqDdkzc+Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/branch-keystore-node": "^4.2.2", + "@aws-crypto/caching-materials-manager-node": "^4.2.2", + "@aws-crypto/decrypt-node": "^4.2.2", + "@aws-crypto/encrypt-node": "^4.2.2", + "@aws-crypto/kms-keyring": "^4.2.2", + "@aws-crypto/kms-keyring-node": "^4.2.2", + "@aws-crypto/material-management-node": "^4.2.2", + "@aws-crypto/raw-aes-keyring-node": "^4.2.2", + "@aws-crypto/raw-rsa-keyring-node": "^4.2.2", + "tslib": "^2.2.0" + } + }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -1542,6 +1617,220 @@ "tslib": "^2.6.2" } }, + "node_modules/@aws-crypto/decrypt-node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@aws-crypto/decrypt-node/-/decrypt-node-4.2.2.tgz", + "integrity": "sha512-4gjApeqN1jknr8AzbhEJrHkpQea0aSiPzfwwnI7ryllc7yQuFkPMzjg/jkZCEGTm0aW/U5jeQYl319ehIyMISg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/material-management-node": "^4.2.2", + "@aws-crypto/serialize": "^4.2.2", + "@types/duplexify": "^3.6.0", + "duplexify": "^4.1.1", + "readable-stream": "^3.6.0", + "tslib": "^2.2.0" + } + }, + "node_modules/@aws-crypto/decrypt-node/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@aws-crypto/encrypt-node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@aws-crypto/encrypt-node/-/encrypt-node-4.2.2.tgz", + "integrity": "sha512-Yc7DOkwM2rOXibgSrAXI6cZn9EqOzynP6iiTmhjgRMdk8GdYm4OFCuW8JVOQKKaob80MUtcNrvVbrBdoy1WCRg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/material-management-node": "^4.2.2", + "@aws-crypto/serialize": "^4.2.2", + "@types/duplexify": "^3.6.0", + "duplexify": "^4.1.3", + "readable-stream": "^3.6.0", + "tslib": "^2.2.0" + } + }, + "node_modules/@aws-crypto/encrypt-node/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@aws-crypto/hkdf-node": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/hkdf-node/-/hkdf-node-4.0.0.tgz", + "integrity": "sha512-FytH3TF9c0OP+vnicc4YJoxoFoLajdRzzuRchDHmh4yXk32lj/HzgXGPfj+kSyy0chkh4XVONh2/zMRmqsA/hQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.2.0" + } + }, + "node_modules/@aws-crypto/kdf-ctr-mode-node": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/kdf-ctr-mode-node/-/kdf-ctr-mode-node-4.1.0.tgz", + "integrity": "sha512-63A5mgEN3NWdVpHF4Y+4TLl2F62UP6KaFZ6cF5MT7hZ3rDaiyh6u45Tjcxe6/mB5U3FiMBOba/2AXrSX0hNkCw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.2.0" + } + }, + "node_modules/@aws-crypto/kms-keyring": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@aws-crypto/kms-keyring/-/kms-keyring-4.2.2.tgz", + "integrity": "sha512-VqEEgltMochgGlwYBah4cevOPthGOG17O/DlQjxJGwLYdoiE8wRJUj2G1VhGyEr5KAdVxwhDf1qceS0/V0xJNg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/material-management": "^4.2.2", + "tslib": "^2.2.0" + } + }, + "node_modules/@aws-crypto/kms-keyring-node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@aws-crypto/kms-keyring-node/-/kms-keyring-node-4.2.2.tgz", + "integrity": "sha512-rfYGbNVUIt2nXcv7TroO1dvYgMDz7hiNhC0SSMFgtX82ytqbAdpucvgQU7eM8j8bscT7zCuisiJuvOEHNA9HMg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/branch-keystore-node": "^4.2.2", + "@aws-crypto/cache-material": "^4.2.2", + "@aws-crypto/kdf-ctr-mode-node": "^4.1.0", + "@aws-crypto/kms-keyring": "^4.2.2", + "@aws-crypto/material-management-node": "^4.2.2", + "@aws-crypto/serialize": "^4.2.2", + "@aws-sdk/client-dynamodb": "^3.621.0", + "@aws-sdk/client-kms": "^3.362.0", + "tslib": "^2.2.0" + } + }, + "node_modules/@aws-crypto/material-management": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@aws-crypto/material-management/-/material-management-4.2.2.tgz", + "integrity": "sha512-p4OOs7qNp8NUbm9J0OLkvfPnLNaCSAcgD0EpNCovEHjFDtPG851RZMD0IJ0sCqGgDnJCotCZa3HM0Lb1DA2jww==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "bn.js": "^5.2.3", + "tslib": "^2.2.0", + "uuid": "^10.0.0" + } + }, + "node_modules/@aws-crypto/material-management-node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@aws-crypto/material-management-node/-/material-management-node-4.2.2.tgz", + "integrity": "sha512-f18PvCWhFLUmxvWFuVrdazc0R4AK8MOSYFpCo4Htd90Eeju1pCuUAh2zBO8LKFAm12GTmMqvPSmRbubB19g3Ng==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/hkdf-node": "^4.0.0", + "@aws-crypto/material-management": "^4.2.2", + "@aws-crypto/serialize": "^4.2.2", + "tslib": "^2.2.0" + } + }, + "node_modules/@aws-crypto/material-management/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@aws-crypto/raw-aes-keyring-node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@aws-crypto/raw-aes-keyring-node/-/raw-aes-keyring-node-4.2.2.tgz", + "integrity": "sha512-e2ouSZq6m3c5cPYSifx7efA000gy83/CHNOkMUIKt9uDvXz89Sqo0SwGXHOppfcEAvsqeBhoBOYqkHCSoqfnhg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/material-management-node": "^4.2.2", + "@aws-crypto/raw-keyring": "^4.2.2", + "@aws-crypto/serialize": "^4.2.2", + "tslib": "^2.2.0" + } + }, + "node_modules/@aws-crypto/raw-keyring": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@aws-crypto/raw-keyring/-/raw-keyring-4.2.2.tgz", + "integrity": "sha512-F3mwKq4LDMOvKJYZknqyAzkdFR149EXwtVKzNPCuzpwTYLcPs9U3dDn2i3EOtzZS7K4va45BOq63xXS7QYaunQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/material-management": "^4.2.2", + "@aws-crypto/serialize": "^4.2.2", + "tslib": "^2.2.0" + } + }, + "node_modules/@aws-crypto/raw-rsa-keyring-node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@aws-crypto/raw-rsa-keyring-node/-/raw-rsa-keyring-node-4.2.2.tgz", + "integrity": "sha512-EjkWCnVUW7xzWxiAN8xZ58PQZIEhe9CyhKoL4+JYT4s9wZhx83bQsOAnzJ5e1fYOOXbd1bCbMNt/PpcibRYqqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/material-management-node": "^4.2.2", + "@aws-crypto/raw-keyring": "^4.2.2", + "tslib": "^2.2.0" + } + }, + "node_modules/@aws-crypto/serialize": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@aws-crypto/serialize/-/serialize-4.2.2.tgz", + "integrity": "sha512-9CXxsaHgq0Bmv9jad0Kpwflk0mT8gHgNfjJ0dAbrCELsQfJeA3t6uaH3cmSMFgLEfBhbXYlDT2bRcyyWZ0I14A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/material-management": "^4.2.2", + "asn1.js": "^5.3.0", + "bn.js": "^5.2.3", + "tslib": "^2.2.0", + "uuid": "^10.0.0" + } + }, + "node_modules/@aws-crypto/serialize/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@aws-crypto/sha1-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", @@ -1727,6 +2016,10 @@ "resolved": "packages/commons", "link": true }, + "node_modules/@aws-lambda-powertools/data-masking": { + "resolved": "packages/data-masking", + "link": true + }, "node_modules/@aws-lambda-powertools/event-handler": { "resolved": "packages/event-handler", "link": true @@ -5596,6 +5889,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/duplexify": { + "version": "3.6.5", + "resolved": "https://registry.npmjs.org/@types/duplexify/-/duplexify-3.6.5.tgz", + "integrity": "sha512-fB56ACzlW91UdZ5F3VXplVMDngO8QaX5Y2mjvADtN01TT2TMy4WjF0Lg+tFDvt4uMBeTe4SgaD+qCrA7dL5/tA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -5620,6 +5923,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-ssE3Vlrys7sdIzs5LOxCzTVMsU7i9oa/IaW92wF32JFb3CVczqOkru2xspuKczHEbG3nvmPY7IFqVmGGHdNbYw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -6192,6 +6502,26 @@ "arkregex": "0.0.5" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/asn1.js/node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -6796,6 +7126,13 @@ ], "license": "MIT" }, + "node_modules/bn.js": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", + "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", + "dev": true, + "license": "MIT" + }, "node_modules/bowser": { "version": "2.13.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz", @@ -7222,6 +7559,34 @@ "node": ">=0.3.1" } }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/duplexify/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -7244,6 +7609,16 @@ "dev": true, "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -7337,6 +7712,29 @@ "node": ">=12.0.0" } }, + "node_modules/fast-check": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.6.0.tgz", + "integrity": "sha512-h7H6Dm0Fy+H4ciQYFxFjXnXkzR2kr9Fb22c0UBpHnm59K2zpr2t13aPTHlltFiNT6zuxp6HMPAVVvgur4BLdpA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -8280,6 +8678,19 @@ "devOptional": true, "license": "Apache-2.0" }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/lunr": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", @@ -9070,6 +9481,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC" + }, "node_modules/minimatch": { "version": "9.0.8", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.8.tgz", @@ -9200,6 +9618,16 @@ ], "license": "MIT" }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -9414,6 +9842,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -9613,6 +10058,13 @@ ], "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -9827,6 +10279,13 @@ "node": ">= 6" } }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/streamx": { "version": "2.23.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", @@ -10706,6 +11165,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, "node_modules/yaml": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", @@ -10785,6 +11258,27 @@ "@aws-lambda-powertools/testing-utils": "file:../testing" } }, + "packages/data-masking": { + "name": "@aws-lambda-powertools/data-masking", + "version": "2.32.0", + "license": "MIT-0", + "dependencies": { + "@aws-lambda-powertools/commons": "2.32.0", + "@aws-lambda-powertools/jmespath": "2.32.0" + }, + "devDependencies": { + "@aws-crypto/client-node": "^4.2.2", + "fast-check": "^4.6.0" + }, + "peerDependencies": { + "@aws-crypto/client-node": ">=4.x" + }, + "peerDependenciesMeta": { + "@aws-crypto/client-node": { + "optional": true + } + } + }, "packages/event-handler": { "name": "@aws-lambda-powertools/event-handler", "version": "2.32.0", diff --git a/package.json b/package.json index 862e30481d..9a28576925 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "examples/app", "packages/event-handler", "packages/validation", - "packages/kafka" + "packages/kafka", + "packages/data-masking" ], "type": "module", "scripts": { diff --git a/packages/data-masking/package.json b/packages/data-masking/package.json new file mode 100644 index 0000000000..e15a55f88f --- /dev/null +++ b/packages/data-masking/package.json @@ -0,0 +1,101 @@ +{ + "name": "@aws-lambda-powertools/data-masking", + "version": "2.32.0", + "description": "A utility for masking and encrypting sensitive data in AWS Lambda functions", + "author": { + "name": "Amazon Web Services", + "url": "https://aws.amazon.com" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "test": "vitest --run tests/unit", + "test:unit": "vitest --run tests/unit", + "test:unit:coverage": "vitest --run tests/unit --coverage.enabled --coverage.thresholds.100 --coverage.include='src/**'", + "test:unit:types": "echo 'Not Implemented'", + "test:e2e:nodejs20x": "echo \"Not implemented\"", + "test:e2e:nodejs22x": "echo \"Not implemented\"", + "test:e2e:nodejs24x": "echo \"Not implemented\"", + "test:e2e": "echo \"Not implemented\"", + "build:cjs": "tsc --build tsconfig.cjs.json && echo '{ \"type\": \"commonjs\" }' > lib/cjs/package.json", + "build:esm": "tsc --build tsconfig.json && echo '{ \"type\": \"module\" }' > lib/esm/package.json", + "build": "npm run build:esm & npm run build:cjs", + "lint": "biome lint .", + "lint:fix": "biome check --write .", + "lint:ci": "biome ci .", + "prepack": "node ../../.github/scripts/release_patch_package_json.js ." + }, + "homepage": "https://github.com/aws-powertools/powertools-lambda-typescript/tree/main/packages/data-masking#readme", + "license": "MIT-0", + "type": "module", + "exports": { + ".": { + "require": { + "types": "./lib/cjs/index.d.ts", + "default": "./lib/cjs/index.js" + }, + "import": { + "types": "./lib/esm/index.d.ts", + "default": "./lib/esm/index.js" + } + }, + "./providers": { + "require": { + "types": "./lib/cjs/provider/index.d.ts", + "default": "./lib/cjs/provider/index.js" + }, + "import": { + "types": "./lib/esm/provider/index.d.ts", + "default": "./lib/esm/provider/index.js" + } + } + }, + "typesVersions": { + "*": { + "providers": [ + "lib/cjs/provider/index.d.ts", + "lib/esm/provider/index.d.ts" + ] + } + }, + "types": "./lib/cjs/index.d.ts", + "main": "./lib/cjs/index.js", + "files": [ + "lib" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/aws-powertools/powertools-lambda-typescript.git" + }, + "bugs": { + "url": "https://github.com/aws-powertools/powertools-lambda-typescript/issues" + }, + "dependencies": { + "@aws-lambda-powertools/commons": "2.32.0", + "@aws-lambda-powertools/jmespath": "2.32.0" + }, + "peerDependencies": { + "@aws-crypto/client-node": ">=4.x" + }, + "peerDependenciesMeta": { + "@aws-crypto/client-node": { + "optional": true + } + }, + "keywords": [ + "aws", + "lambda", + "powertools", + "data-masking", + "encryption", + "pii", + "sensitive-data", + "nodejs", + "serverless" + ], + "devDependencies": { + "@aws-crypto/client-node": "^4.2.2", + "fast-check": "^4.6.0" + } +} diff --git a/packages/data-masking/src/DataMasking.ts b/packages/data-masking/src/DataMasking.ts new file mode 100644 index 0000000000..47c39d0f61 --- /dev/null +++ b/packages/data-masking/src/DataMasking.ts @@ -0,0 +1,252 @@ +import { search } from '@aws-lambda-powertools/jmespath'; +import { DEFAULT_MASK_VALUE } from './constants.js'; +import { + DataMaskingEncryptionError, + DataMaskingFieldNotFoundError, + DataMaskingUnsupportedTypeError, +} from './errors.js'; +import type { + DataMaskingConstructorOptions, + DecryptOptions, + EncryptOptions, + EncryptionProvider, + EraseOptions, + MaskingRule, +} from './types.js'; + +/** + * Orchestrates erasing, encrypting, and decrypting sensitive data. + * + * @example + * ```typescript + * import { DataMasking } from '@aws-lambda-powertools/data-masking'; + * + * const masker = new DataMasking(); + * const masked = masker.erase(data, { fields: ['customer.ssn'] }); + * ``` + */ +export class DataMasking { + readonly #provider?: EncryptionProvider; + readonly #throwOnMissingField: boolean; + + constructor(options?: DataMaskingConstructorOptions) { + this.#provider = options?.provider; + this.#throwOnMissingField = options?.throwOnMissingField ?? true; + } + + /** + * Irreversibly mask fields in a data object. Returns a deep copy. + * + * @example + * ```typescript + * const masked = masker.erase(data, { fields: ['email', 'customer.ssn'] }); + * ``` + */ + erase(data: T, options?: EraseOptions): T { + if (data === null || data === undefined) return data; + if (!options?.fields && !options?.maskingRules) { + return DEFAULT_MASK_VALUE as unknown as T; + } + + const copy = this.#deepCopy(data); + + if (options.maskingRules) { + for (const [field, rule] of Object.entries(options.maskingRules)) { + for (const path of this.#resolveFieldPaths(copy as Record, field)) { + const value = getAtPath(copy, path); + if (typeof value !== 'string') { + throw new DataMaskingUnsupportedTypeError( + `Masking rules only support string values, got ${typeof value} at path '${field}'` + ); + } + setAtPath(copy, path, applyMaskingRule(value, rule)); + } + } + + return copy; + } + + for (const field of options.fields ?? []) { + const paths = this.#resolveFieldPaths(copy as Record, field); + if (paths.length === 0 && this.#throwOnMissingField) { + throw new DataMaskingFieldNotFoundError(`Field not found: '${field}'`); + } + for (const path of paths) { + setAtPath(copy, path, DEFAULT_MASK_VALUE); + } + } + + return copy; + } + + /** + * Encrypt data using the configured provider. With fields, encrypts + * specific values in place. Without fields, encrypts the entire payload. + * + * @example + * ```typescript + * const encrypted = await masker.encrypt(data, { + * fields: ['customer.ssn'], + * context: { tenantId: 'acme' }, + * }); + * ``` + */ + async encrypt(data: T, options?: EncryptOptions): Promise { + const provider = this.#requireProvider(); + + if (!options?.fields) { + return provider.encrypt(JSON.stringify(data), options?.context); + } + + const copy = this.#deepCopy(data); + await this.#transformFields(copy, options.fields, (value) => { + return provider.encrypt(JSON.stringify(value), options.context); + }); + + return copy; + } + + /** + * Decrypt data using the configured provider. Automatically detects + * full-payload (string input) vs field-level (object input) format. + * + * @example + * ```typescript + * const decrypted = await masker.decrypt(encrypted, { + * fields: ['customer.ssn'], + * }); + * ``` + */ + async decrypt(data: T | string, options?: DecryptOptions): Promise { + const provider = this.#requireProvider(); + + if (typeof data === 'string') { + return JSON.parse(await provider.decrypt(data, options?.context)) as T; + } + + const copy = this.#deepCopy(data); + if (options?.fields) { + await this.#transformFields(copy, options.fields, async (value) => { + if (typeof value !== 'string') return value; + + return JSON.parse(await provider.decrypt(value, options.context)); + }); + } + + return copy; + } + + #requireProvider(): EncryptionProvider { + if (!this.#provider) { + throw new DataMaskingEncryptionError( + 'Encryption provider is required for encrypt/decrypt operations' + ); + } + + return this.#provider; + } + + #deepCopy(data: T): T { + try { + return structuredClone(data); + } catch { + throw new DataMaskingUnsupportedTypeError( + 'Data contains unsupported types for cloning' + ); + } + } + + async #transformFields( + data: T, + fields: string[], + transform: (value: unknown) => Promise + ): Promise { + const operations: Promise[] = []; + + for (const field of fields) { + for (const path of this.#resolveFieldPaths(data as Record, field)) { + operations.push( + transform(getAtPath(data, path)).then((result) => + setAtPath(data, path, result) + ) + ); + } + } + + await Promise.all(operations); + } + + #resolveFieldPaths(data: Record, expression: string): string[][] { + // JMESPath validates the expression and checks if it matches anything + const matched = search(expression, data); + if (matched == null || (Array.isArray(matched) && matched.length === 0)) { + return []; + } + + // Walk the data to resolve wildcards into concrete paths for write-back + const segments = expression.split(/\.|\[(\*)\]\.?/).filter(Boolean); + + const paths: string[][] = []; + const walk = (obj: unknown, i: number, current: string[]): void => { + if (i === segments.length) { + paths.push(current); + + return; + } + if (obj == null || typeof obj !== 'object') return; + + if (segments[i] === '*') { + if (!Array.isArray(obj)) return; + for (let j = 0; j < obj.length; j++) { + walk(obj[j], i + 1, [...current, String(j)]); + } + } else { + const next = (obj as Record)[segments[i]]; + if (next !== undefined) { + walk(next, i + 1, [...current, segments[i]]); + } + } + }; + + walk(data, 0, []); + + return paths; + } +} + +const RESERVED_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +function getAtPath(data: unknown, path: string[]): unknown { + let current = data; + for (const key of path) { + if (RESERVED_KEYS.has(key)) return undefined; + if (current == null || typeof current !== 'object') return undefined; + current = (current as Record)[key]; + } + + return current; +} + +function setAtPath(data: unknown, path: string[], value: unknown): void { + let current = data; + for (let i = 0; i < path.length - 1; i++) { + if (RESERVED_KEYS.has(path[i])) return; + if (current == null || typeof current !== 'object') return; + current = (current as Record)[path[i]]; + } + const lastKey = path[path.length - 1]; + if (RESERVED_KEYS.has(lastKey)) return; + if (current != null && typeof current === 'object') { + (current as Record)[lastKey] = value; + } +} + +function applyMaskingRule(value: string, rule: MaskingRule): string { + if (rule.regexPattern && rule.maskFormat) { + return value.replace(rule.regexPattern, rule.maskFormat); + } + if (rule.dynamicMask) return '*'.repeat(value.length); + if (rule.customMask !== undefined) return rule.customMask; + + return DEFAULT_MASK_VALUE; +} diff --git a/packages/data-masking/src/constants.ts b/packages/data-masking/src/constants.ts new file mode 100644 index 0000000000..ab813e50cd --- /dev/null +++ b/packages/data-masking/src/constants.ts @@ -0,0 +1 @@ +export const DEFAULT_MASK_VALUE = '*****'; diff --git a/packages/data-masking/src/errors.ts b/packages/data-masking/src/errors.ts new file mode 100644 index 0000000000..6e1c7ed31a --- /dev/null +++ b/packages/data-masking/src/errors.ts @@ -0,0 +1,23 @@ +/** Thrown when a field path does not match any value in the data. */ +export class DataMaskingFieldNotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = 'DataMaskingFieldNotFoundError'; + } +} + +/** Thrown when input data contains an unsupported type for the operation. */ +export class DataMaskingUnsupportedTypeError extends Error { + constructor(message: string) { + super(message); + this.name = 'DataMaskingUnsupportedTypeError'; + } +} + +/** Thrown when an encryption or decryption operation fails. */ +export class DataMaskingEncryptionError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'DataMaskingEncryptionError'; + } +} diff --git a/packages/data-masking/src/index.ts b/packages/data-masking/src/index.ts new file mode 100644 index 0000000000..32ff2797bc --- /dev/null +++ b/packages/data-masking/src/index.ts @@ -0,0 +1,15 @@ +export { DataMasking } from './DataMasking.js'; +export { + DataMaskingFieldNotFoundError, + DataMaskingUnsupportedTypeError, + DataMaskingEncryptionError, +} from './errors.js'; +export type { + EncryptionProvider, + EraseOptions, + EncryptOptions, + DecryptOptions, + MaskingRule, + DataMaskingConstructorOptions, +} from './types.js'; +export { DEFAULT_MASK_VALUE } from './constants.js'; diff --git a/packages/data-masking/src/provider/AWSEncryptionSDKProvider.ts b/packages/data-masking/src/provider/AWSEncryptionSDKProvider.ts new file mode 100644 index 0000000000..d3338973f4 --- /dev/null +++ b/packages/data-masking/src/provider/AWSEncryptionSDKProvider.ts @@ -0,0 +1,108 @@ +import type { buildDecrypt, buildEncrypt } from '@aws-crypto/client-node'; +import type { EncryptionProvider } from '../types.js'; + +export interface AWSEncryptionSDKProviderOptions { + keys: string[]; + localCacheCapacity?: number; + maxCacheAgeSeconds?: number; + maxMessagesEncrypted?: number; + maxBytesEncrypted?: number; +} + +type EncryptFn = ReturnType['encrypt']; +type DecryptFn = ReturnType['decrypt']; +type CacheManager = Parameters[0]; + +interface SDKClients { + encryptFn: EncryptFn; + decryptFn: DecryptFn; + cacheManager: CacheManager; +} + +export class AWSEncryptionSDKProvider implements EncryptionProvider { + readonly #options: AWSEncryptionSDKProviderOptions; + #clients?: SDKClients; + + constructor(options: AWSEncryptionSDKProviderOptions) { + this.#options = options; + } + + async #init(): Promise { + if (this.#clients) return this.#clients; + + // Dynamic import — @aws-crypto/client-node is an optional peer dep, + // so we only load it when encryption is actually used. + const { + buildEncrypt, + buildDecrypt, + KmsKeyringNode, + getLocalCryptographicMaterialsCache, + NodeCachingMaterialsManager, + } = await import('@aws-crypto/client-node'); + + const { encrypt } = buildEncrypt(); + const { decrypt } = buildDecrypt(); + + const keyring = new KmsKeyringNode({ + generatorKeyId: this.#options.keys[0], + keyIds: this.#options.keys.slice(1), + }); + + const cache = getLocalCryptographicMaterialsCache( + this.#options.localCacheCapacity ?? 100 + ); + + this.#clients = { + encryptFn: encrypt, + decryptFn: decrypt, + cacheManager: new NodeCachingMaterialsManager({ + backingMaterials: keyring, + cache, + maxAge: (this.#options.maxCacheAgeSeconds ?? 300) * 1000, + maxMessagesEncrypted: + this.#options.maxMessagesEncrypted ?? 4294967296, + maxBytesEncrypted: + this.#options.maxBytesEncrypted ?? Number.MAX_SAFE_INTEGER, + }), + }; + + return this.#clients; + } + + async encrypt( + data: string, + context?: Record + ): Promise { + const { encryptFn, cacheManager } = await this.#init(); + const plaintext = new TextEncoder().encode(data); + const { result } = await encryptFn(cacheManager, plaintext, { + encryptionContext: context, + }); + + return Buffer.from(result).toString('base64'); + } + + async decrypt( + data: string, + context?: Record + ): Promise { + const { decryptFn, cacheManager } = await this.#init(); + const ciphertext = Buffer.from(data, 'base64'); + const { plaintext, messageHeader } = await decryptFn( + cacheManager, + new Uint8Array(ciphertext) + ); + + if (context) { + for (const [key, value] of Object.entries(context)) { + if (messageHeader.encryptionContext[key] !== value) { + throw new Error( + `Encryption context mismatch for key '${key}'` + ); + } + } + } + + return new TextDecoder().decode(plaintext); + } +} diff --git a/packages/data-masking/src/provider/index.ts b/packages/data-masking/src/provider/index.ts new file mode 100644 index 0000000000..3c25c23ed6 --- /dev/null +++ b/packages/data-masking/src/provider/index.ts @@ -0,0 +1,4 @@ +export { + AWSEncryptionSDKProvider, + type AWSEncryptionSDKProviderOptions, +} from './AWSEncryptionSDKProvider.js'; diff --git a/packages/data-masking/src/types.ts b/packages/data-masking/src/types.ts new file mode 100644 index 0000000000..93e97c4fb4 --- /dev/null +++ b/packages/data-masking/src/types.ts @@ -0,0 +1,56 @@ +/** + * Interface for pluggable encryption providers. + * Implement this to use a custom encryption backend. + */ +export interface EncryptionProvider { + encrypt(data: string, context?: Record): Promise; + decrypt(data: string, context?: Record): Promise; +} + +/** Options for the {@link DataMasking} constructor. */ +export interface DataMaskingConstructorOptions { + /** Encryption provider for encrypt/decrypt operations. */ + provider?: EncryptionProvider; + /** Whether to throw when a field path doesn't match. Default: `true`. */ + throwOnMissingField?: boolean; +} + +/** Per-field custom masking configuration for {@link DataMasking.erase}. */ +export interface MaskingRule { + /** Regex pattern to match within the field value. */ + regexPattern?: RegExp; + /** Replacement format string (e.g., `'$1****$3'`). */ + maskFormat?: string; + /** If true, mask length matches original value length. */ + dynamicMask?: boolean; + /** Fixed replacement string. */ + customMask?: string; +} + +/** Options for {@link DataMasking.erase}. */ +export interface EraseOptions { + /** JMESPath expressions for fields to mask. */ + fields?: string[]; + /** Per-field custom masking rules keyed by dot-notation path. */ + maskingRules?: Record; +} + +/** Options for {@link DataMasking.encrypt}. */ +export interface EncryptOptions { + /** JMESPath expressions for fields to encrypt. If omitted, entire payload is encrypted. */ + fields?: string[]; + /** Encryption context (additional authenticated data). */ + context?: Record; + /** Provider-specific options (escape hatch). */ + providerOptions?: Record; +} + +/** Options for {@link DataMasking.decrypt}. */ +export interface DecryptOptions { + /** JMESPath expressions for fields to decrypt. */ + fields?: string[]; + /** Encryption context for verification. */ + context?: Record; + /** Provider-specific options (escape hatch). */ + providerOptions?: Record; +} diff --git a/packages/data-masking/tests/e2e/constants.ts b/packages/data-masking/tests/e2e/constants.ts new file mode 100644 index 0000000000..aa17ae2fd7 --- /dev/null +++ b/packages/data-masking/tests/e2e/constants.ts @@ -0,0 +1 @@ +export const RESOURCE_NAME_PREFIX = 'DataMasking'; diff --git a/packages/data-masking/tests/e2e/dataMasking.test.FunctionCode.ts b/packages/data-masking/tests/e2e/dataMasking.test.FunctionCode.ts new file mode 100644 index 0000000000..5cc9fe65d8 --- /dev/null +++ b/packages/data-masking/tests/e2e/dataMasking.test.FunctionCode.ts @@ -0,0 +1,42 @@ +import { DataMasking } from '../../src/index.js'; +import { AWSEncryptionSDKProvider } from '../../src/provider/index.js'; + +const provider = new AWSEncryptionSDKProvider({ + keys: [process.env.KMS_KEY_ARN ?? ''], +}); +const masker = new DataMasking({ provider }); +const maskerNoProvider = new DataMasking(); + +export const handlerErase = ( + event: Record +): Record => { + return maskerNoProvider.erase(event, { + fields: ['customer.ssn', 'payment.card'], + }); +}; + +export const handlerFieldEncrypt = async ( + event: Record +): Promise> => { + const encrypted = await masker.encrypt(event, { + fields: ['customer.ssn', 'payment.card'], + context: { purpose: 'e2e-test' }, + }); + + return masker.decrypt>(encrypted, { + fields: ['customer.ssn', 'payment.card'], + context: { purpose: 'e2e-test' }, + }); +}; + +export const handlerFullEncrypt = async ( + event: Record +): Promise> => { + const encrypted = await masker.encrypt(event, { + context: { purpose: 'e2e-test' }, + }); + + return masker.decrypt>(encrypted, { + context: { purpose: 'e2e-test' }, + }); +}; diff --git a/packages/data-masking/tests/e2e/dataMasking.test.ts b/packages/data-masking/tests/e2e/dataMasking.test.ts new file mode 100644 index 0000000000..056a65e0ad --- /dev/null +++ b/packages/data-masking/tests/e2e/dataMasking.test.ts @@ -0,0 +1,120 @@ +import { join } from 'node:path'; +import { fromUtf8, toUtf8 } from '@smithy/util-utf8'; +import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; +import { TestStack } from '@aws-lambda-powertools/testing-utils'; +import { TestNodejsFunction } from '@aws-lambda-powertools/testing-utils/resources/lambda'; +import { Key } from 'aws-cdk-lib/aws-kms'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { RESOURCE_NAME_PREFIX } from './constants.js'; + +const lambda = new LambdaClient({}); + +const invoke = async ( + functionName: string, + payload: Record +): Promise => { + const result = await lambda.send( + new InvokeCommand({ + FunctionName: functionName, + InvocationType: 'RequestResponse', + Payload: fromUtf8(JSON.stringify(payload)), + }) + ); + + return JSON.parse(toUtf8(result.Payload ?? new Uint8Array())); +}; + +describe('DataMasking E2E tests', () => { + const testStack = new TestStack({ + stackNameProps: { + stackNamePrefix: RESOURCE_NAME_PREFIX, + testName: 'dataMasking', + }, + }); + + const lambdaFunctionCodeFilePath = join( + __dirname, + 'dataMasking.test.FunctionCode.ts' + ); + + const kmsKey = new Key(testStack.stack, 'TestKmsKey', { + description: 'KMS key for DataMasking e2e tests', + }); + + let functionNameErase: string; + new TestNodejsFunction( + testStack, + { + entry: lambdaFunctionCodeFilePath, + handler: 'handlerErase', + }, + { nameSuffix: 'erase' } + ); + + let functionNameFieldEncrypt: string; + new TestNodejsFunction( + testStack, + { + entry: lambdaFunctionCodeFilePath, + handler: 'handlerFieldEncrypt', + environment: { + KMS_KEY_ARN: kmsKey.keyArn, + }, + }, + { nameSuffix: 'fieldEncrypt' } + ); + + let functionNameFullEncrypt: string; + new TestNodejsFunction( + testStack, + { + entry: lambdaFunctionCodeFilePath, + handler: 'handlerFullEncrypt', + environment: { + KMS_KEY_ARN: kmsKey.keyArn, + }, + }, + { nameSuffix: 'fullEncrypt' } + ); + + beforeAll(async () => { + await testStack.deploy(); + functionNameErase = testStack.findAndGetStackOutputValue('eraseFn'); + functionNameFieldEncrypt = + testStack.findAndGetStackOutputValue('fieldEncryptFn'); + functionNameFullEncrypt = + testStack.findAndGetStackOutputValue('fullEncryptFn'); + }, 300_000); + + afterAll(async () => { + await testStack.destroy(); + }, 300_000); + + const testPayload = { + orderId: '12345', + customer: { name: 'Jane', ssn: '123-45-6789' }, + payment: { card: '4111-1111-1111-1111', amount: 99.99 }, + }; + + it('erase masks specified fields', async () => { + const result = await invoke(functionNameErase, testPayload); + + expect(result).toEqual({ + orderId: '12345', + customer: { name: 'Jane', ssn: '*****' }, + payment: { card: '*****', amount: 99.99 }, + }); + }); + + it('field-level encrypt/decrypt round-trips correctly', async () => { + const result = await invoke(functionNameFieldEncrypt, testPayload); + + expect(result).toStrictEqual(testPayload); + }); + + it('full-payload encrypt/decrypt round-trips correctly', async () => { + const result = await invoke(functionNameFullEncrypt, testPayload); + + expect(result).toStrictEqual(testPayload); + }); +}); diff --git a/packages/data-masking/tests/unit/AWSEncryptionSDKProvider.test.ts b/packages/data-masking/tests/unit/AWSEncryptionSDKProvider.test.ts new file mode 100644 index 0000000000..f71a50926d --- /dev/null +++ b/packages/data-masking/tests/unit/AWSEncryptionSDKProvider.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AWSEncryptionSDKProvider } from '../../src/provider/AWSEncryptionSDKProvider.js'; + +vi.mock('@aws-crypto/client-node', () => { + const mockEncrypt = vi.fn(async (_cmm: unknown, plaintext: Uint8Array) => ({ + result: plaintext, + })); + const mockDecrypt = vi.fn( + async (_cmm: unknown, ciphertext: Uint8Array) => ({ + plaintext: ciphertext, + messageHeader: { encryptionContext: {} }, + }) + ); + + class MockKmsKeyringNode {} + class MockNodeCachingMaterialsManager {} + + return { + buildEncrypt: () => ({ encrypt: mockEncrypt }), + buildDecrypt: () => ({ decrypt: mockDecrypt }), + KmsKeyringNode: MockKmsKeyringNode, + getLocalCryptographicMaterialsCache: vi.fn().mockReturnValue({}), + NodeCachingMaterialsManager: MockNodeCachingMaterialsManager, + }; +}); + +describe('AWSEncryptionSDKProvider', () => { + it('can be instantiated with keys', () => { + const provider = new AWSEncryptionSDKProvider({ + keys: ['arn:aws:kms:us-east-1:123456789012:key/test-key'], + }); + + expect(provider).toBeDefined(); + }); + + it('encrypt returns a base64 string', async () => { + const provider = new AWSEncryptionSDKProvider({ + keys: ['arn:aws:kms:us-east-1:123456789012:key/test-key'], + }); + + const result = await provider.encrypt('test data'); + expect(typeof result).toBe('string'); + expect(() => Buffer.from(result, 'base64')).not.toThrow(); + }); + + it('decrypt returns the original string', async () => { + const provider = new AWSEncryptionSDKProvider({ + keys: ['arn:aws:kms:us-east-1:123456789012:key/test-key'], + }); + + const encrypted = await provider.encrypt('hello world'); + const decrypted = await provider.decrypt(encrypted); + expect(decrypted).toBe('hello world'); + }); +}); diff --git a/packages/data-masking/tests/unit/encrypt-decrypt.test.ts b/packages/data-masking/tests/unit/encrypt-decrypt.test.ts new file mode 100644 index 0000000000..4522134a45 --- /dev/null +++ b/packages/data-masking/tests/unit/encrypt-decrypt.test.ts @@ -0,0 +1,187 @@ +import fc from 'fast-check'; +import { describe, expect, it, vi } from 'vitest'; +import { DataMasking } from '../../src/index.js'; +import { DataMaskingEncryptionError } from '../../src/errors.js'; +import type { EncryptionProvider } from '../../src/types.js'; + +const createMockProvider = (): EncryptionProvider => ({ + encrypt: vi.fn(async (data: string) => `ENC:${data}`), + decrypt: vi.fn(async (data: string) => data.replace('ENC:', '')), +}); + +describe('DataMasking.encrypt()', () => { + it('encrypts specified fields in place', async () => { + const provider = createMockProvider(); + const masker = new DataMasking({ provider }); + const data = { + name: 'Jane', + customer: { ssn: '123-45-6789', city: 'Anytown' }, + }; + + const result = await masker.encrypt(data, { + fields: ['customer.ssn'], + }); + + expect(result).toEqual({ + name: 'Jane', + customer: { ssn: 'ENC:"123-45-6789"', city: 'Anytown' }, + }); + }); + + it('passes encryption context to provider', async () => { + const provider = createMockProvider(); + const masker = new DataMasking({ provider }); + + await masker.encrypt({ secret: 'val' }, { + fields: ['secret'], + context: { tenantId: 'acme' }, + }); + + expect(provider.encrypt).toHaveBeenCalledWith( + '"val"', + { tenantId: 'acme' } + ); + }); + + it('encrypts nested and array fields', async () => { + const provider = createMockProvider(); + const masker = new DataMasking({ provider }); + const data = { + orders: [ + { id: 1, card: '4111' }, + { id: 2, card: '5500' }, + ], + }; + + const result = await masker.encrypt(data, { + fields: ['orders[*].card'], + }); + + if (typeof result === 'string') throw new Error('Expected object'); + expect(result.orders[0].card).toBe('ENC:"4111"'); + expect(result.orders[1].card).toBe('ENC:"5500"'); + expect(result.orders[0].id).toBe(1); + }); + + it('throws DataMaskingEncryptionError without provider', async () => { + const masker = new DataMasking(); + + await expect( + masker.encrypt({ a: 1 }, { fields: ['a'] }) + ).rejects.toThrow(DataMaskingEncryptionError); + }); + + it('does not mutate original input', async () => { + const provider = createMockProvider(); + const masker = new DataMasking({ provider }); + const data = { secret: 'original' }; + + await masker.encrypt(data, { fields: ['secret'] }); + + expect(data.secret).toBe('original'); + }); +}); + +describe('DataMasking.encrypt() - full payload', () => { + it('encrypts entire payload when no fields specified', async () => { + const provider = createMockProvider(); + const masker = new DataMasking({ provider }); + const data = { name: 'Jane', ssn: '123-45-6789' }; + + const result = await masker.encrypt(data); + + expect(typeof result).toBe('string'); + expect(result).toBe(`ENC:${JSON.stringify(data)}`); + }); + + it('passes encryption context for full payload', async () => { + const provider = createMockProvider(); + const masker = new DataMasking({ provider }); + + await masker.encrypt({ a: 1 }, { context: { env: 'prod' } }); + + expect(provider.encrypt).toHaveBeenCalledWith( + '{"a":1}', + { env: 'prod' } + ); + }); +}); + +describe('DataMasking.decrypt() - full payload', () => { + it('decrypts opaque string and restores original data', async () => { + const provider = createMockProvider(); + const masker = new DataMasking({ provider }); + const original = { name: 'Jane', age: 30 }; + const encrypted = `ENC:${JSON.stringify(original)}`; + + const result = await masker.decrypt(encrypted); + + expect(result).toEqual(original); + }); +}); + +describe('DataMasking.decrypt() - field level', () => { + it('decrypts specified fields and restores original values', async () => { + const provider = createMockProvider(); + const masker = new DataMasking({ provider }); + const encrypted = { + name: 'Jane', + customer: { ssn: 'ENC:"123-45-6789"', city: 'Anytown' }, + }; + + const result = await masker.decrypt(encrypted, { + fields: ['customer.ssn'], + }); + + expect(result).toEqual({ + name: 'Jane', + customer: { ssn: '123-45-6789', city: 'Anytown' }, + }); + }); + + it('throws DataMaskingEncryptionError without provider', async () => { + const masker = new DataMasking(); + + await expect( + masker.decrypt({ a: 'encrypted' }, { fields: ['a'] }) + ).rejects.toThrow(DataMaskingEncryptionError); + }); +}); + +const jmesPathKey = fc.stringMatching(/^[a-z][a-z0-9_]{0,10}$/); + +describe('DataMasking encrypt/decrypt - property tests', () => { + it('full-payload encrypt then decrypt is identity', async () => { + const provider = createMockProvider(); + const masker = new DataMasking({ provider }); + + await fc.assert( + fc.asyncProperty(fc.jsonValue(), async (data) => { + if (data === null || data === undefined) return; + const encrypted = await masker.encrypt(data); + const result = await masker.decrypt(encrypted); + + // Compare via JSON since encrypt/decrypt round-trips through JSON.stringify/parse + expect(JSON.stringify(result)).toBe(JSON.stringify(data)); + }) + ); + }); + + it('field-level encrypt then decrypt restores original values', async () => { + const provider = createMockProvider(); + const masker = new DataMasking({ provider }); + + await fc.assert( + fc.asyncProperty( + fc.dictionary(jmesPathKey, fc.string(), { minKeys: 1 }), + async (data) => { + const fields = Object.keys(data); + const encrypted = await masker.encrypt(data, { fields }); + const decrypted = await masker.decrypt(encrypted, { fields }); + + expect(decrypted).toEqual(data); + } + ) + ); + }); +}); diff --git a/packages/data-masking/tests/unit/erase.test.ts b/packages/data-masking/tests/unit/erase.test.ts new file mode 100644 index 0000000000..8052e9cf2a --- /dev/null +++ b/packages/data-masking/tests/unit/erase.test.ts @@ -0,0 +1,239 @@ +import fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { + DataMaskingFieldNotFoundError, + DataMaskingUnsupportedTypeError, +} from '../../src/errors.js'; +import { DataMasking } from '../../src/index.js'; + +describe('DataMasking.erase()', () => { + const masker = new DataMasking(); + + it('masks specified nested fields with default mask value', () => { + const data = { + name: 'Jane', + customer: { ssn: '123-45-6789', city: 'Anytown' }, + }; + + const result = masker.erase(data, { + fields: ['customer.ssn'], + }); + + expect(result.customer.ssn).toBe('*****'); + expect(result.customer.city).toBe('Anytown'); + expect(result.name).toBe('Jane'); + }); + + it('masks multiple fields', () => { + const data = { email: 'j@example.com', phone: '555-1234', name: 'Jane' }; + + const result = masker.erase(data, { + fields: ['email', 'phone'], + }); + + expect(result.email).toBe('*****'); + expect(result.phone).toBe('*****'); + expect(result.name).toBe('Jane'); + }); + + it('masks wildcard array paths', () => { + const data = { + orders: [ + { id: 1, payment: '4111-1111' }, + { id: 2, payment: '5500-0000' }, + ], + }; + + const result = masker.erase(data, { + fields: ['orders[*].payment'], + }); + + expect(result.orders[0].payment).toBe('*****'); + expect(result.orders[1].payment).toBe('*****'); + expect(result.orders[0].id).toBe(1); + }); + + it('skips array elements where the nested field is missing', () => { + const data = { + items: [ + { id: 1, secret: 'hidden' }, + { id: 2 }, + { id: 3, secret: 'also hidden' }, + ], + }; + + const result = masker.erase(data, { + fields: ['items[*].secret'], + }); + + expect(result.items[0].secret).toBe('*****'); + expect(result.items[1]).toEqual({ id: 2 }); + expect(result.items[2].secret).toBe('*****'); + }); + + it('handles wildcard on an empty array', () => { + const lenientMasker = new DataMasking({ throwOnMissingField: false }); + const data = { orders: [] as unknown[] }; + + const result = lenientMasker.erase(data, { + fields: ['orders[*].payment'], + }); + + expect(result.orders).toEqual([]); + }); + + it('masks a deeply nested field', () => { + const data = { + a: { b: { c: { d: { secret: 'hidden', keep: 'visible' } } } }, + }; + + const result = masker.erase(data, { fields: ['a.b.c.d.secret'] }); + + expect(result.a.b.c.d.secret).toBe('*****'); + expect(result.a.b.c.d.keep).toBe('visible'); + }); + + it('returns null/undefined as-is', () => { + expect(masker.erase(null)).toBeNull(); + expect(masker.erase(undefined)).toBeUndefined(); + }); + + it('throws DataMaskingFieldNotFoundError for missing field when throwOnMissingField is true', () => { + const data = { name: 'Jane' }; + + expect(() => masker.erase(data, { fields: ['nonexistent'] })).toThrow( + DataMaskingFieldNotFoundError + ); + }); + + it('silently skips missing field when throwOnMissingField is false', () => { + const lenientMasker = new DataMasking({ throwOnMissingField: false }); + const data = { name: 'Jane' }; + + const result = lenientMasker.erase(data, { fields: ['nonexistent'] }); + + expect(result).toEqual({ name: 'Jane' }); + }); + + it('handles circular references by cloning them via structuredClone', () => { + const data: Record = { name: 'Jane' }; + data.self = data; + + const result = masker.erase(data, { fields: ['name'] }); + + expect(result.name).toBe('*****'); + }); +}); + +describe('DataMasking.erase() - custom masking rules', () => { + const masker = new DataMasking(); + + it('applies regex pattern masking', () => { + const data = { email: 'jane@example.com' }; + + const result = masker.erase(data, { + maskingRules: { + email: { + regexPattern: /(.)(.+?)(@.*)/, + maskFormat: '$1****$3', + }, + }, + }); + + expect(result.email).toBe('j****@example.com'); + }); + + it('applies dynamic mask matching original length', () => { + const data = { ssn: '123-45-6789' }; + + const result = masker.erase(data, { + maskingRules: { + ssn: { dynamicMask: true }, + }, + }); + + expect(result.ssn).toBe('***********'); + expect(result.ssn.length).toBe('123-45-6789'.length); + }); + + it('applies custom mask string', () => { + const data = { zip: '90210' }; + + const result = masker.erase(data, { + maskingRules: { + zip: { customMask: 'XXXXX' }, + }, + }); + + expect(result.zip).toBe('XXXXX'); + }); + + it('throws DataMaskingUnsupportedTypeError for non-string values', () => { + const data = { age: 30 }; + + expect(() => + masker.erase(data, { + maskingRules: { + age: { dynamicMask: true }, + }, + }) + ).toThrow(DataMaskingUnsupportedTypeError); + }); + + it('applies default mask when no rule options specified', () => { + const data = { secret: 'hidden' }; + + const result = masker.erase(data, { + maskingRules: { + secret: {}, + }, + }); + + expect(result.secret).toBe('*****'); + }); +}); + +const jmesPathKey = fc.stringMatching(/^[a-z][a-z0-9_]{0,10}$/); + +describe('DataMasking.erase() - property tests', () => { + const masker = new DataMasking(); + + it('never mutates the original input', () => { + const lenientMasker = new DataMasking({ throwOnMissingField: false }); + fc.assert( + fc.property(fc.dictionary(jmesPathKey, fc.jsonValue()), (data) => { + const original = structuredClone(data); + lenientMasker.erase(data, { fields: Object.keys(data) }); + + expect(data).toEqual(original); + }) + ); + }); + + it('with no fields always returns the default mask', () => { + fc.assert( + fc.property(fc.jsonValue(), (data) => { + if (data === null || data === undefined) return; + const result = masker.erase(data); + + expect(result).toBe('*****'); + }) + ); + }); + + it('replaces every targeted top-level field with the mask', () => { + fc.assert( + fc.property( + fc.dictionary(jmesPathKey, fc.string(), { minKeys: 1 }), + (data) => { + const fields = Object.keys(data); + const result = masker.erase(data, { fields }); + + for (const field of fields) { + expect(result[field]).toBe('*****'); + } + } + ) + ); + }); +}); diff --git a/packages/data-masking/tsconfig.cjs.json b/packages/data-masking/tsconfig.cjs.json new file mode 100644 index 0000000000..a8fadd417f --- /dev/null +++ b/packages/data-masking/tsconfig.cjs.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.cjs.json", + "compilerOptions": { + "composite": true, + "declaration": true, + "outDir": "./lib/cjs/", + "rootDir": "./src", + "tsBuildInfoFile": ".tsbuildinfo/cjs.json" + }, + "include": ["./src/**/*"] +} diff --git a/packages/data-masking/tsconfig.json b/packages/data-masking/tsconfig.json new file mode 100644 index 0000000000..7baf7d1c7f --- /dev/null +++ b/packages/data-masking/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./lib/esm", + "rootDir": "./src", + "tsBuildInfoFile": ".tsbuildinfo/esm.json", + "composite": true, + "declaration": true + }, + "include": ["./src/**/*"] +} diff --git a/packages/data-masking/vitest.config.ts b/packages/data-masking/vitest.config.ts new file mode 100644 index 0000000000..9f1196ef1f --- /dev/null +++ b/packages/data-masking/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineProject } from 'vitest/config'; + +export default defineProject({ + test: { + environment: 'node', + setupFiles: ['../testing/src/setupEnv.ts'], + }, +}); From 88496135d42121de17b96cb7f44ea4584edfcbe8 Mon Sep 17 00:00:00 2001 From: svozza Date: Sun, 29 Mar 2026 12:27:03 +0100 Subject: [PATCH 02/17] test(data-masking): add type tests for generics --- packages/data-masking/tests/tsconfig.json | 8 ++++ .../tests/types/data-masking.test-d.ts | 46 +++++++++++++++++++ packages/data-masking/vitest.config.ts | 5 +- 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 packages/data-masking/tests/tsconfig.json create mode 100644 packages/data-masking/tests/types/data-masking.test-d.ts diff --git a/packages/data-masking/tests/tsconfig.json b/packages/data-masking/tests/tsconfig.json new file mode 100644 index 0000000000..8362769a64 --- /dev/null +++ b/packages/data-masking/tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "../../", + "noEmit": true + }, + "include": ["../src/**/*", "./**/*"] +} diff --git a/packages/data-masking/tests/types/data-masking.test-d.ts b/packages/data-masking/tests/types/data-masking.test-d.ts new file mode 100644 index 0000000000..481bf0c06d --- /dev/null +++ b/packages/data-masking/tests/types/data-masking.test-d.ts @@ -0,0 +1,46 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { DataMasking } from '../../src/index.js'; + +describe('DataMasking type tests', () => { + const masker = new DataMasking(); + + it('erase preserves the input type', () => { + const data = { name: 'Jane', ssn: '123' }; + const result = masker.erase(data, { fields: ['ssn'] }); + + expectTypeOf(result).toEqualTypeOf<{ name: string; ssn: string }>(); + }); + + it('erase returns the same type for arrays', () => { + const data = [1, 2, 3]; + const result = masker.erase(data); + + expectTypeOf(result).toEqualTypeOf(); + }); + + it('erase returns null for null input', () => { + const result = masker.erase(null); + + expectTypeOf(result).toEqualTypeOf(); + }); + + it('encrypt returns T | string', async () => { + const data = { secret: 'value' }; + const result = await masker.encrypt(data); + + expectTypeOf(result).toEqualTypeOf<{ secret: string } | string>(); + }); + + it('decrypt infers T from the generic parameter', async () => { + const result = await masker.decrypt<{ secret: string }>('ciphertext'); + + expectTypeOf(result).toEqualTypeOf<{ secret: string }>(); + }); + + it('decrypt infers T from object input', async () => { + const data = { ssn: 'encrypted' }; + const result = await masker.decrypt(data, { fields: ['ssn'] }); + + expectTypeOf(result).toEqualTypeOf<{ ssn: string }>(); + }); +}); diff --git a/packages/data-masking/vitest.config.ts b/packages/data-masking/vitest.config.ts index 9f1196ef1f..fe5446c2ef 100644 --- a/packages/data-masking/vitest.config.ts +++ b/packages/data-masking/vitest.config.ts @@ -3,6 +3,9 @@ import { defineProject } from 'vitest/config'; export default defineProject({ test: { environment: 'node', - setupFiles: ['../testing/src/setupEnv.ts'], + typecheck: { + tsconfig: './tests/tsconfig.json', + include: ['./tests/types/**/*.ts'], + }, }, }); From 6daa4d6da0331bc3290c2d85e9b585eab8993fc6 Mon Sep 17 00:00:00 2001 From: svozza Date: Sun, 29 Mar 2026 19:47:18 +0100 Subject: [PATCH 03/17] chore: suppress sonarqube false positive on test regex --- packages/data-masking/tests/unit/erase.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/data-masking/tests/unit/erase.test.ts b/packages/data-masking/tests/unit/erase.test.ts index 8052e9cf2a..6b02387541 100644 --- a/packages/data-masking/tests/unit/erase.test.ts +++ b/packages/data-masking/tests/unit/erase.test.ts @@ -134,7 +134,7 @@ describe('DataMasking.erase() - custom masking rules', () => { const result = masker.erase(data, { maskingRules: { email: { - regexPattern: /(.)(.+?)(@.*)/, + regexPattern: /(.)(.+?)(@.*)/, // NOSONAR - test data, not user input maskFormat: '$1****$3', }, }, From 260f881bb8d154c91b385c26d03bde0dda70367b Mon Sep 17 00:00:00 2001 From: svozza Date: Sun, 29 Mar 2026 19:52:48 +0100 Subject: [PATCH 04/17] chore: fix sonarqube findings --- packages/data-masking/src/DataMasking.ts | 26 ++++++++++++++----- .../unit/AWSEncryptionSDKProvider.test.ts | 16 +++++------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/packages/data-masking/src/DataMasking.ts b/packages/data-masking/src/DataMasking.ts index 47c39d0f61..3e158f5c5a 100644 --- a/packages/data-masking/src/DataMasking.ts +++ b/packages/data-masking/src/DataMasking.ts @@ -8,8 +8,8 @@ import { import type { DataMaskingConstructorOptions, DecryptOptions, - EncryptOptions, EncryptionProvider, + EncryptOptions, EraseOptions, MaskingRule, } from './types.js'; @@ -52,7 +52,10 @@ export class DataMasking { if (options.maskingRules) { for (const [field, rule] of Object.entries(options.maskingRules)) { - for (const path of this.#resolveFieldPaths(copy as Record, field)) { + for (const path of this.#resolveFieldPaths( + copy as Record, + field + )) { const value = getAtPath(copy, path); if (typeof value !== 'string') { throw new DataMaskingUnsupportedTypeError( @@ -67,7 +70,10 @@ export class DataMasking { } for (const field of options.fields ?? []) { - const paths = this.#resolveFieldPaths(copy as Record, field); + const paths = this.#resolveFieldPaths( + copy as Record, + field + ); if (paths.length === 0 && this.#throwOnMissingField) { throw new DataMaskingFieldNotFoundError(`Field not found: '${field}'`); } @@ -164,7 +170,10 @@ export class DataMasking { const operations: Promise[] = []; for (const field of fields) { - for (const path of this.#resolveFieldPaths(data as Record, field)) { + for (const path of this.#resolveFieldPaths( + data as Record, + field + )) { operations.push( transform(getAtPath(data, path)).then((result) => setAtPath(data, path, result) @@ -176,7 +185,10 @@ export class DataMasking { await Promise.all(operations); } - #resolveFieldPaths(data: Record, expression: string): string[][] { + #resolveFieldPaths( + data: Record, + expression: string + ): string[][] { // JMESPath validates the expression and checks if it matches anything const matched = search(expression, data); if (matched == null || (Array.isArray(matched) && matched.length === 0)) { @@ -234,8 +246,8 @@ function setAtPath(data: unknown, path: string[], value: unknown): void { if (current == null || typeof current !== 'object') return; current = (current as Record)[path[i]]; } - const lastKey = path[path.length - 1]; - if (RESERVED_KEYS.has(lastKey)) return; + const lastKey = path.at(-1); + if (!lastKey || RESERVED_KEYS.has(lastKey)) return; if (current != null && typeof current === 'object') { (current as Record)[lastKey] = value; } diff --git a/packages/data-masking/tests/unit/AWSEncryptionSDKProvider.test.ts b/packages/data-masking/tests/unit/AWSEncryptionSDKProvider.test.ts index f71a50926d..749086149a 100644 --- a/packages/data-masking/tests/unit/AWSEncryptionSDKProvider.test.ts +++ b/packages/data-masking/tests/unit/AWSEncryptionSDKProvider.test.ts @@ -5,15 +5,13 @@ vi.mock('@aws-crypto/client-node', () => { const mockEncrypt = vi.fn(async (_cmm: unknown, plaintext: Uint8Array) => ({ result: plaintext, })); - const mockDecrypt = vi.fn( - async (_cmm: unknown, ciphertext: Uint8Array) => ({ - plaintext: ciphertext, - messageHeader: { encryptionContext: {} }, - }) - ); - - class MockKmsKeyringNode {} - class MockNodeCachingMaterialsManager {} + const mockDecrypt = vi.fn(async (_cmm: unknown, ciphertext: Uint8Array) => ({ + plaintext: ciphertext, + messageHeader: { encryptionContext: {} }, + })); + + const MockKmsKeyringNode = vi.fn(); + const MockNodeCachingMaterialsManager = vi.fn(); return { buildEncrypt: () => ({ encrypt: mockEncrypt }), From a0918dfbbc56a815587392a0b077fd74a00af0e8 Mon Sep 17 00:00:00 2001 From: svozza Date: Sun, 29 Mar 2026 19:54:34 +0100 Subject: [PATCH 05/17] refactor(data-masking): reduce cognitive complexity of erase method --- packages/data-masking/src/DataMasking.ts | 40 ++++++++++++++---------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/packages/data-masking/src/DataMasking.ts b/packages/data-masking/src/DataMasking.ts index 3e158f5c5a..e616062255 100644 --- a/packages/data-masking/src/DataMasking.ts +++ b/packages/data-masking/src/DataMasking.ts @@ -51,25 +51,33 @@ export class DataMasking { const copy = this.#deepCopy(data); if (options.maskingRules) { - for (const [field, rule] of Object.entries(options.maskingRules)) { - for (const path of this.#resolveFieldPaths( - copy as Record, - field - )) { - const value = getAtPath(copy, path); - if (typeof value !== 'string') { - throw new DataMaskingUnsupportedTypeError( - `Masking rules only support string values, got ${typeof value} at path '${field}'` - ); - } - setAtPath(copy, path, applyMaskingRule(value, rule)); + this.#applyMaskingRules(copy, options.maskingRules); + } else { + this.#eraseFields(copy, options.fields ?? []); + } + + return copy; + } + + #applyMaskingRules(copy: T, rules: Record): void { + for (const [field, rule] of Object.entries(rules)) { + for (const path of this.#resolveFieldPaths( + copy as Record, + field + )) { + const value = getAtPath(copy, path); + if (typeof value !== 'string') { + throw new DataMaskingUnsupportedTypeError( + `Masking rules only support string values, got ${typeof value} at path '${field}'` + ); } + setAtPath(copy, path, applyMaskingRule(value, rule)); } - - return copy; } + } - for (const field of options.fields ?? []) { + #eraseFields(copy: T, fields: string[]): void { + for (const field of fields) { const paths = this.#resolveFieldPaths( copy as Record, field @@ -81,8 +89,6 @@ export class DataMasking { setAtPath(copy, path, DEFAULT_MASK_VALUE); } } - - return copy; } /** From e8fbc36303079a1ca6a63a06a1e30256076c07a3 Mon Sep 17 00:00:00 2001 From: svozza Date: Sun, 29 Mar 2026 19:56:09 +0100 Subject: [PATCH 06/17] chore: suppress sonarqube CDK construct warnings --- packages/data-masking/tests/e2e/dataMasking.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/data-masking/tests/e2e/dataMasking.test.ts b/packages/data-masking/tests/e2e/dataMasking.test.ts index 056a65e0ad..0830d3039b 100644 --- a/packages/data-masking/tests/e2e/dataMasking.test.ts +++ b/packages/data-masking/tests/e2e/dataMasking.test.ts @@ -1,8 +1,8 @@ import { join } from 'node:path'; -import { fromUtf8, toUtf8 } from '@smithy/util-utf8'; -import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; import { TestStack } from '@aws-lambda-powertools/testing-utils'; import { TestNodejsFunction } from '@aws-lambda-powertools/testing-utils/resources/lambda'; +import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; +import { fromUtf8, toUtf8 } from '@smithy/util-utf8'; import { Key } from 'aws-cdk-lib/aws-kms'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { RESOURCE_NAME_PREFIX } from './constants.js'; From c19aa8aa0a965221d0f79316abeb19717edb968c Mon Sep 17 00:00:00 2001 From: svozza Date: Sun, 29 Mar 2026 20:05:58 +0100 Subject: [PATCH 07/17] ci: add data-masking to e2e test matrix --- .github/workflows/run-e2e-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/run-e2e-tests.yml b/.github/workflows/run-e2e-tests.yml index 8122dcf838..db06c70811 100644 --- a/.github/workflows/run-e2e-tests.yml +++ b/.github/workflows/run-e2e-tests.yml @@ -34,6 +34,7 @@ jobs: packages/event-handler, packages/tracer, packages/batch, + packages/data-masking, layers, ] version: [20, 22, 24] From aaf0b4ead2f3e26160f9fd4210189e5da0e0f2dd Mon Sep 17 00:00:00 2001 From: svozza Date: Sun, 29 Mar 2026 20:09:44 +0100 Subject: [PATCH 08/17] fix(data-masking): wire up e2e and type test scripts --- packages/data-masking/package.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/data-masking/package.json b/packages/data-masking/package.json index e15a55f88f..8a121a88dc 100644 --- a/packages/data-masking/package.json +++ b/packages/data-masking/package.json @@ -13,11 +13,11 @@ "test": "vitest --run tests/unit", "test:unit": "vitest --run tests/unit", "test:unit:coverage": "vitest --run tests/unit --coverage.enabled --coverage.thresholds.100 --coverage.include='src/**'", - "test:unit:types": "echo 'Not Implemented'", - "test:e2e:nodejs20x": "echo \"Not implemented\"", - "test:e2e:nodejs22x": "echo \"Not implemented\"", - "test:e2e:nodejs24x": "echo \"Not implemented\"", - "test:e2e": "echo \"Not implemented\"", + "test:unit:types": "vitest --typecheck --run tests/types", + "test:e2e:nodejs20x": "RUNTIME=nodejs20x vitest --run tests/e2e", + "test:e2e:nodejs22x": "RUNTIME=nodejs22x vitest --run tests/e2e", + "test:e2e:nodejs24x": "RUNTIME=nodejs24x vitest --run tests/e2e", + "test:e2e": "vitest --run tests/e2e", "build:cjs": "tsc --build tsconfig.cjs.json && echo '{ \"type\": \"commonjs\" }' > lib/cjs/package.json", "build:esm": "tsc --build tsconfig.json && echo '{ \"type\": \"module\" }' > lib/esm/package.json", "build": "npm run build:esm & npm run build:cjs", From 8b316a9e0a26cea7a45ef6f8ca45841a8a182de2 Mon Sep 17 00:00:00 2001 From: svozza Date: Sun, 29 Mar 2026 20:26:20 +0100 Subject: [PATCH 09/17] fix(data-masking): fix e2e stack output key lookups --- packages/data-masking/tests/e2e/dataMasking.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/data-masking/tests/e2e/dataMasking.test.ts b/packages/data-masking/tests/e2e/dataMasking.test.ts index 0830d3039b..6fb1bb110c 100644 --- a/packages/data-masking/tests/e2e/dataMasking.test.ts +++ b/packages/data-masking/tests/e2e/dataMasking.test.ts @@ -79,11 +79,11 @@ describe('DataMasking E2E tests', () => { beforeAll(async () => { await testStack.deploy(); - functionNameErase = testStack.findAndGetStackOutputValue('eraseFn'); + functionNameErase = testStack.findAndGetStackOutputValue('erase'); functionNameFieldEncrypt = - testStack.findAndGetStackOutputValue('fieldEncryptFn'); + testStack.findAndGetStackOutputValue('fieldEncrypt'); functionNameFullEncrypt = - testStack.findAndGetStackOutputValue('fullEncryptFn'); + testStack.findAndGetStackOutputValue('fullEncrypt'); }, 300_000); afterAll(async () => { From 5220d7682f15e36ddf438670d3a1b59f273ec432 Mon Sep 17 00:00:00 2001 From: svozza Date: Sun, 29 Mar 2026 20:42:28 +0100 Subject: [PATCH 10/17] fix(data-masking): grant KMS encrypt/decrypt to e2e Lambda functions --- packages/data-masking/tests/e2e/dataMasking.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/data-masking/tests/e2e/dataMasking.test.ts b/packages/data-masking/tests/e2e/dataMasking.test.ts index 6fb1bb110c..adad931203 100644 --- a/packages/data-masking/tests/e2e/dataMasking.test.ts +++ b/packages/data-masking/tests/e2e/dataMasking.test.ts @@ -52,7 +52,7 @@ describe('DataMasking E2E tests', () => { ); let functionNameFieldEncrypt: string; - new TestNodejsFunction( + const fieldEncryptFn = new TestNodejsFunction( testStack, { entry: lambdaFunctionCodeFilePath, @@ -63,9 +63,10 @@ describe('DataMasking E2E tests', () => { }, { nameSuffix: 'fieldEncrypt' } ); + kmsKey.grantEncryptDecrypt(fieldEncryptFn); let functionNameFullEncrypt: string; - new TestNodejsFunction( + const fullEncryptFn = new TestNodejsFunction( testStack, { entry: lambdaFunctionCodeFilePath, @@ -76,6 +77,7 @@ describe('DataMasking E2E tests', () => { }, { nameSuffix: 'fullEncrypt' } ); + kmsKey.grantEncryptDecrypt(fullEncryptFn); beforeAll(async () => { await testStack.deploy(); From ace1309c40bb2164f943a7f17138149413f24daa Mon Sep 17 00:00:00 2001 From: svozza Date: Sun, 29 Mar 2026 22:38:45 +0100 Subject: [PATCH 11/17] debug: add logging to erase handler --- .../data-masking/tests/e2e/dataMasking.test.FunctionCode.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/data-masking/tests/e2e/dataMasking.test.FunctionCode.ts b/packages/data-masking/tests/e2e/dataMasking.test.FunctionCode.ts index 5cc9fe65d8..a54262669e 100644 --- a/packages/data-masking/tests/e2e/dataMasking.test.FunctionCode.ts +++ b/packages/data-masking/tests/e2e/dataMasking.test.FunctionCode.ts @@ -10,9 +10,13 @@ const maskerNoProvider = new DataMasking(); export const handlerErase = ( event: Record ): Record => { - return maskerNoProvider.erase(event, { + console.log('handlerErase invoked', JSON.stringify(event)); + const result = maskerNoProvider.erase(event, { fields: ['customer.ssn', 'payment.card'], }); + console.log('handlerErase result', JSON.stringify(result)); + + return result; }; export const handlerFieldEncrypt = async ( From 9cd6a5f173b03e92ef4fbd3764445a236052d9a8 Mon Sep 17 00:00:00 2001 From: svozza Date: Sun, 29 Mar 2026 22:50:50 +0100 Subject: [PATCH 12/17] debug: add logging to invoke helper --- .../tests/e2e/dataMasking.test.FunctionCode.ts | 6 +----- packages/data-masking/tests/e2e/dataMasking.test.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/data-masking/tests/e2e/dataMasking.test.FunctionCode.ts b/packages/data-masking/tests/e2e/dataMasking.test.FunctionCode.ts index a54262669e..5cc9fe65d8 100644 --- a/packages/data-masking/tests/e2e/dataMasking.test.FunctionCode.ts +++ b/packages/data-masking/tests/e2e/dataMasking.test.FunctionCode.ts @@ -10,13 +10,9 @@ const maskerNoProvider = new DataMasking(); export const handlerErase = ( event: Record ): Record => { - console.log('handlerErase invoked', JSON.stringify(event)); - const result = maskerNoProvider.erase(event, { + return maskerNoProvider.erase(event, { fields: ['customer.ssn', 'payment.card'], }); - console.log('handlerErase result', JSON.stringify(result)); - - return result; }; export const handlerFieldEncrypt = async ( diff --git a/packages/data-masking/tests/e2e/dataMasking.test.ts b/packages/data-masking/tests/e2e/dataMasking.test.ts index adad931203..96b73af64b 100644 --- a/packages/data-masking/tests/e2e/dataMasking.test.ts +++ b/packages/data-masking/tests/e2e/dataMasking.test.ts @@ -21,7 +21,15 @@ const invoke = async ( }) ); - return JSON.parse(toUtf8(result.Payload ?? new Uint8Array())); + const raw = toUtf8(result.Payload ?? new Uint8Array()); + console.log('invoke response', { + functionName, + statusCode: result.StatusCode, + functionError: result.FunctionError, + raw, + }); + + return JSON.parse(raw); }; describe('DataMasking E2E tests', () => { From 0b316a00a3448919d2824aa6e4c9485c340b44fd Mon Sep 17 00:00:00 2001 From: svozza Date: Sun, 29 Mar 2026 23:08:57 +0100 Subject: [PATCH 13/17] fix(data-masking): return Promise from erase e2e handler for ESM Lambda runtime --- .../tests/e2e/dataMasking.test.FunctionCode.ts | 10 ++++++---- packages/data-masking/tests/e2e/dataMasking.test.ts | 10 +--------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/packages/data-masking/tests/e2e/dataMasking.test.FunctionCode.ts b/packages/data-masking/tests/e2e/dataMasking.test.FunctionCode.ts index 5cc9fe65d8..cc81ef9bb6 100644 --- a/packages/data-masking/tests/e2e/dataMasking.test.FunctionCode.ts +++ b/packages/data-masking/tests/e2e/dataMasking.test.FunctionCode.ts @@ -9,10 +9,12 @@ const maskerNoProvider = new DataMasking(); export const handlerErase = ( event: Record -): Record => { - return maskerNoProvider.erase(event, { - fields: ['customer.ssn', 'payment.card'], - }); +): Promise> => { + return Promise.resolve( + maskerNoProvider.erase(event, { + fields: ['customer.ssn', 'payment.card'], + }) + ); }; export const handlerFieldEncrypt = async ( diff --git a/packages/data-masking/tests/e2e/dataMasking.test.ts b/packages/data-masking/tests/e2e/dataMasking.test.ts index 96b73af64b..adad931203 100644 --- a/packages/data-masking/tests/e2e/dataMasking.test.ts +++ b/packages/data-masking/tests/e2e/dataMasking.test.ts @@ -21,15 +21,7 @@ const invoke = async ( }) ); - const raw = toUtf8(result.Payload ?? new Uint8Array()); - console.log('invoke response', { - functionName, - statusCode: result.StatusCode, - functionError: result.FunctionError, - raw, - }); - - return JSON.parse(raw); + return JSON.parse(toUtf8(result.Payload ?? new Uint8Array())); }; describe('DataMasking E2E tests', () => { From ea70d62668b47dcdc33596dd9a82a85eea01a1c9 Mon Sep 17 00:00:00 2001 From: svozza Date: Thu, 2 Apr 2026 15:32:40 +0200 Subject: [PATCH 14/17] feat(data-masking): remove jmespath dependency and add .* object wildcard support Replace jmespath validation with native path resolution, add object wildcard (.*) support alongside existing array wildcard ([*]), update JSDoc, add property-based tests, encryption context provider tests, and enable data-masking in CI test matrix. --- ...sable-run-linting-check-and-unit-tests.yml | 1 + package-lock.json | 3 +- packages/data-masking/package.json | 3 +- packages/data-masking/src/DataMasking.ts | 39 ++-- packages/data-masking/src/types.ts | 6 +- .../unit/AWSEncryptionSDKProvider.test.ts | 31 +++ .../tests/unit/encrypt-decrypt.test.ts | 122 +++++++++-- .../data-masking/tests/unit/erase.test.ts | 193 +++++++++++++++++- 8 files changed, 349 insertions(+), 49 deletions(-) diff --git a/.github/workflows/reusable-run-linting-check-and-unit-tests.yml b/.github/workflows/reusable-run-linting-check-and-unit-tests.yml index 37c5931244..6485b78a49 100644 --- a/.github/workflows/reusable-run-linting-check-and-unit-tests.yml +++ b/.github/workflows/reusable-run-linting-check-and-unit-tests.yml @@ -44,6 +44,7 @@ jobs: workspace: [ "packages/batch", "packages/commons", + "packages/data-masking", "packages/event-handler", "packages/idempotency", "packages/jmespath", diff --git a/package-lock.json b/package-lock.json index 2e65b3e66c..ea399d3d38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11263,8 +11263,7 @@ "version": "2.32.0", "license": "MIT-0", "dependencies": { - "@aws-lambda-powertools/commons": "2.32.0", - "@aws-lambda-powertools/jmespath": "2.32.0" + "@aws-lambda-powertools/commons": "2.32.0" }, "devDependencies": { "@aws-crypto/client-node": "^4.2.2", diff --git a/packages/data-masking/package.json b/packages/data-masking/package.json index 8a121a88dc..96d0021803 100644 --- a/packages/data-masking/package.json +++ b/packages/data-masking/package.json @@ -72,8 +72,7 @@ "url": "https://github.com/aws-powertools/powertools-lambda-typescript/issues" }, "dependencies": { - "@aws-lambda-powertools/commons": "2.32.0", - "@aws-lambda-powertools/jmespath": "2.32.0" + "@aws-lambda-powertools/commons": "2.32.0" }, "peerDependencies": { "@aws-crypto/client-node": ">=4.x" diff --git a/packages/data-masking/src/DataMasking.ts b/packages/data-masking/src/DataMasking.ts index e616062255..45a8d5a1cc 100644 --- a/packages/data-masking/src/DataMasking.ts +++ b/packages/data-masking/src/DataMasking.ts @@ -1,4 +1,3 @@ -import { search } from '@aws-lambda-powertools/jmespath'; import { DEFAULT_MASK_VALUE } from './constants.js'; import { DataMaskingEncryptionError, @@ -53,6 +52,7 @@ export class DataMasking { if (options.maskingRules) { this.#applyMaskingRules(copy, options.maskingRules); } else { + /* v8 ignore next -- @preserve fallback unreachable: line 46 returns early when fields is falsy */ this.#eraseFields(copy, options.fields ?? []); } @@ -195,13 +195,6 @@ export class DataMasking { data: Record, expression: string ): string[][] { - // JMESPath validates the expression and checks if it matches anything - const matched = search(expression, data); - if (matched == null || (Array.isArray(matched) && matched.length === 0)) { - return []; - } - - // Walk the data to resolve wildcards into concrete paths for write-back const segments = expression.split(/\.|\[(\*)\]\.?/).filter(Boolean); const paths: string[][] = []; @@ -214,9 +207,18 @@ export class DataMasking { if (obj == null || typeof obj !== 'object') return; if (segments[i] === '*') { - if (!Array.isArray(obj)) return; - for (let j = 0; j < obj.length; j++) { - walk(obj[j], i + 1, [...current, String(j)]); + if (Array.isArray(obj)) { + for (let j = 0; j < obj.length; j++) { + walk(obj[j], i + 1, [...current, String(j)]); + } + } else { + for (const key of Object.keys(obj as Record)) { + if (RESERVED_KEYS.has(key)) continue; + walk((obj as Record)[key], i + 1, [ + ...current, + key, + ]); + } } } else { const next = (obj as Record)[segments[i]]; @@ -235,28 +237,23 @@ export class DataMasking { const RESERVED_KEYS = new Set(['__proto__', 'constructor', 'prototype']); function getAtPath(data: unknown, path: string[]): unknown { - let current = data; + let current = data as Record; for (const key of path) { if (RESERVED_KEYS.has(key)) return undefined; - if (current == null || typeof current !== 'object') return undefined; - current = (current as Record)[key]; + current = current[key] as Record; } return current; } function setAtPath(data: unknown, path: string[], value: unknown): void { - let current = data; + let current = data as Record; for (let i = 0; i < path.length - 1; i++) { - if (RESERVED_KEYS.has(path[i])) return; - if (current == null || typeof current !== 'object') return; - current = (current as Record)[path[i]]; + current = current[path[i]] as Record; } const lastKey = path.at(-1); if (!lastKey || RESERVED_KEYS.has(lastKey)) return; - if (current != null && typeof current === 'object') { - (current as Record)[lastKey] = value; - } + current[lastKey] = value; } function applyMaskingRule(value: string, rule: MaskingRule): string { diff --git a/packages/data-masking/src/types.ts b/packages/data-masking/src/types.ts index 93e97c4fb4..e67018b214 100644 --- a/packages/data-masking/src/types.ts +++ b/packages/data-masking/src/types.ts @@ -29,7 +29,7 @@ export interface MaskingRule { /** Options for {@link DataMasking.erase}. */ export interface EraseOptions { - /** JMESPath expressions for fields to mask. */ + /** Dot-notation path expressions for fields to mask (supports `.*` and `[*]` wildcards). */ fields?: string[]; /** Per-field custom masking rules keyed by dot-notation path. */ maskingRules?: Record; @@ -37,7 +37,7 @@ export interface EraseOptions { /** Options for {@link DataMasking.encrypt}. */ export interface EncryptOptions { - /** JMESPath expressions for fields to encrypt. If omitted, entire payload is encrypted. */ + /** Dot-notation path expressions for fields to encrypt (supports `.*` and `[*]` wildcards). If omitted, entire payload is encrypted. */ fields?: string[]; /** Encryption context (additional authenticated data). */ context?: Record; @@ -47,7 +47,7 @@ export interface EncryptOptions { /** Options for {@link DataMasking.decrypt}. */ export interface DecryptOptions { - /** JMESPath expressions for fields to decrypt. */ + /** Dot-notation path expressions for fields to decrypt (supports `.*` and `[*]` wildcards). */ fields?: string[]; /** Encryption context for verification. */ context?: Record; diff --git a/packages/data-masking/tests/unit/AWSEncryptionSDKProvider.test.ts b/packages/data-masking/tests/unit/AWSEncryptionSDKProvider.test.ts index 749086149a..87f8701148 100644 --- a/packages/data-masking/tests/unit/AWSEncryptionSDKProvider.test.ts +++ b/packages/data-masking/tests/unit/AWSEncryptionSDKProvider.test.ts @@ -50,4 +50,35 @@ describe('AWSEncryptionSDKProvider', () => { const decrypted = await provider.decrypt(encrypted); expect(decrypted).toBe('hello world'); }); + + it('decrypt throws on encryption context mismatch', async () => { + const provider = new AWSEncryptionSDKProvider({ + keys: ['arn:aws:kms:us-east-1:123456789012:key/test-key'], + }); + + const encrypted = await provider.encrypt('secret'); + + await expect( + provider.decrypt(encrypted, { tenantId: 'acme' }) + ).rejects.toThrow("Encryption context mismatch for key 'tenantId'"); + }); + + it('decrypt succeeds when encryption context matches', async () => { + const { buildDecrypt } = await import('@aws-crypto/client-node'); + const { decrypt: mockDecrypt } = buildDecrypt(); + (mockDecrypt as ReturnType).mockImplementationOnce( + async (_cmm: unknown, ciphertext: Uint8Array) => ({ + plaintext: ciphertext, + messageHeader: { encryptionContext: { tenantId: 'acme' } }, + }) + ); + + const provider = new AWSEncryptionSDKProvider({ + keys: ['arn:aws:kms:us-east-1:123456789012:key/test-key'], + }); + + const encrypted = await provider.encrypt('secret'); + const decrypted = await provider.decrypt(encrypted, { tenantId: 'acme' }); + expect(decrypted).toBe('secret'); + }); }); diff --git a/packages/data-masking/tests/unit/encrypt-decrypt.test.ts b/packages/data-masking/tests/unit/encrypt-decrypt.test.ts index 4522134a45..df58cd6901 100644 --- a/packages/data-masking/tests/unit/encrypt-decrypt.test.ts +++ b/packages/data-masking/tests/unit/encrypt-decrypt.test.ts @@ -1,7 +1,7 @@ import fc from 'fast-check'; import { describe, expect, it, vi } from 'vitest'; -import { DataMasking } from '../../src/index.js'; import { DataMaskingEncryptionError } from '../../src/errors.js'; +import { DataMasking } from '../../src/index.js'; import type { EncryptionProvider } from '../../src/types.js'; const createMockProvider = (): EncryptionProvider => ({ @@ -32,15 +32,17 @@ describe('DataMasking.encrypt()', () => { const provider = createMockProvider(); const masker = new DataMasking({ provider }); - await masker.encrypt({ secret: 'val' }, { - fields: ['secret'], - context: { tenantId: 'acme' }, - }); - - expect(provider.encrypt).toHaveBeenCalledWith( - '"val"', - { tenantId: 'acme' } + await masker.encrypt( + { secret: 'val' }, + { + fields: ['secret'], + context: { tenantId: 'acme' }, + } ); + + expect(provider.encrypt).toHaveBeenCalledWith('"val"', { + tenantId: 'acme', + }); }); it('encrypts nested and array fields', async () => { @@ -63,12 +65,29 @@ describe('DataMasking.encrypt()', () => { expect(result.orders[0].id).toBe(1); }); + it('prevents prototype pollution when __proto__ is used as a field path', async () => { + const provider = createMockProvider(); + const lenientMasker = new DataMasking({ + provider, + throwOnMissingField: false, + }); + const data = { safe: 'value' }; + + const result = await lenientMasker.encrypt(data, { + fields: ['__proto__', 'safe'], + }); + + if (typeof result === 'string') throw new Error('Expected object'); + expect(result.safe).toBe('ENC:"value"'); + expect(Object.getPrototypeOf(result)).toEqual(Object.getPrototypeOf({})); + }); + it('throws DataMaskingEncryptionError without provider', async () => { const masker = new DataMasking(); - await expect( - masker.encrypt({ a: 1 }, { fields: ['a'] }) - ).rejects.toThrow(DataMaskingEncryptionError); + await expect(masker.encrypt({ a: 1 }, { fields: ['a'] })).rejects.toThrow( + DataMaskingEncryptionError + ); }); it('does not mutate original input', async () => { @@ -100,10 +119,7 @@ describe('DataMasking.encrypt() - full payload', () => { await masker.encrypt({ a: 1 }, { context: { env: 'prod' } }); - expect(provider.encrypt).toHaveBeenCalledWith( - '{"a":1}', - { env: 'prod' } - ); + expect(provider.encrypt).toHaveBeenCalledWith('{"a":1}', { env: 'prod' }); }); }); @@ -139,6 +155,27 @@ describe('DataMasking.decrypt() - field level', () => { }); }); + it('returns a copy when decrypting object without fields', async () => { + const provider = createMockProvider(); + const masker = new DataMasking({ provider }); + const data = { name: 'Jane', age: 30 }; + + const result = await masker.decrypt(data); + + expect(result).toEqual(data); + expect(provider.decrypt).not.toHaveBeenCalled(); + }); + + it('passes through non-string values during field-level decrypt', async () => { + const provider = createMockProvider(); + const masker = new DataMasking({ provider }); + const data = { num: 42, text: 'ENC:"hello"' }; + + const result = await masker.decrypt(data, { fields: ['num', 'text'] }); + + expect(result).toEqual({ num: 42, text: 'hello' }); + }); + it('throws DataMaskingEncryptionError without provider', async () => { const masker = new DataMasking(); @@ -148,7 +185,7 @@ describe('DataMasking.decrypt() - field level', () => { }); }); -const jmesPathKey = fc.stringMatching(/^[a-z][a-z0-9_]{0,10}$/); +const pathKey = fc.stringMatching(/^[a-z][a-z0-9_]{0,10}$/); describe('DataMasking encrypt/decrypt - property tests', () => { it('full-payload encrypt then decrypt is identity', async () => { @@ -167,13 +204,62 @@ describe('DataMasking encrypt/decrypt - property tests', () => { ); }); + it('parent.* encrypt then decrypt restores original values', async () => { + const provider = createMockProvider(); + const masker = new DataMasking({ provider }); + + await fc.assert( + fc.asyncProperty( + pathKey, + fc.dictionary(pathKey, fc.string(), { minKeys: 1 }), + async (parent, nested) => { + const data = { [parent]: nested }; + const encrypted = await masker.encrypt(data, { + fields: [`${parent}.*`], + }); + const decrypted = await masker.decrypt(encrypted, { + fields: [`${parent}.*`], + }); + + expect(decrypted).toEqual(data); + } + ) + ); + }); + + it('parent[*].child encrypt then decrypt restores original values', async () => { + const provider = createMockProvider(); + const masker = new DataMasking({ provider }); + + await fc.assert( + fc.asyncProperty( + pathKey, + pathKey, + fc.array(fc.string(), { minLength: 1, maxLength: 10 }), + async (parent, child, values) => { + const data = { + [parent]: values.map((v) => ({ [child]: v })), + }; + const encrypted = await masker.encrypt(data, { + fields: [`${parent}[*].${child}`], + }); + const decrypted = await masker.decrypt(encrypted, { + fields: [`${parent}[*].${child}`], + }); + + expect(decrypted).toEqual(data); + } + ) + ); + }); + it('field-level encrypt then decrypt restores original values', async () => { const provider = createMockProvider(); const masker = new DataMasking({ provider }); await fc.assert( fc.asyncProperty( - fc.dictionary(jmesPathKey, fc.string(), { minKeys: 1 }), + fc.dictionary(pathKey, fc.string(), { minKeys: 1 }), async (data) => { const fields = Object.keys(data); const encrypted = await masker.encrypt(data, { fields }); diff --git a/packages/data-masking/tests/unit/erase.test.ts b/packages/data-masking/tests/unit/erase.test.ts index 6b02387541..57b370a459 100644 --- a/packages/data-masking/tests/unit/erase.test.ts +++ b/packages/data-masking/tests/unit/erase.test.ts @@ -123,6 +123,90 @@ describe('DataMasking.erase()', () => { expect(result.name).toBe('*****'); }); + + it('throws DataMaskingUnsupportedTypeError for non-cloneable data', () => { + const data = { fn: () => {} }; + + expect(() => masker.erase(data, { fields: ['fn'] })).toThrow( + DataMaskingUnsupportedTypeError + ); + }); + + it('masks all properties of an object with .* wildcard', () => { + const data = { + credentials: { username: 'admin', password: 's3cret', token: 'abc123' }, + }; + + const result = masker.erase(data, { fields: ['credentials.*'] }); + + expect(result.credentials.username).toBe('*****'); + expect(result.credentials.password).toBe('*****'); + expect(result.credentials.token).toBe('*****'); + }); + + it('masks nested fields via .* on an intermediate object', () => { + const data = { + users: { + alice: { ssn: '111', name: 'Alice' }, + bob: { ssn: '222', name: 'Bob' }, + }, + }; + + const result = masker.erase(data, { fields: ['users.*.ssn'] }); + + expect(result.users.alice.ssn).toBe('*****'); + expect(result.users.bob.ssn).toBe('*****'); + expect(result.users.alice.name).toBe('Alice'); + expect(result.users.bob.name).toBe('Bob'); + }); + + it('ignores __proto__ keys during .* object wildcard traversal', () => { + const data = JSON.parse( + '{"secrets": {"__proto__": "injected", "safe": "value"}}' + ); + + const result = masker.erase(data, { fields: ['secrets.*'] }); + + expect(result.secrets.safe).toBe('*****'); + expect(result.secrets.__proto__).toBe('injected'); + }); + + it('prevents prototype pollution when __proto__ is used as a direct field path', () => { + const lenientMasker = new DataMasking({ throwOnMissingField: false }); + const data = { safe: 'value' }; + + const result = lenientMasker.erase(data, { + fields: ['__proto__', 'constructor', 'safe'], + }); + + expect(result.safe).toBe('*****'); + expect(Object.getPrototypeOf(result)).toEqual(Object.getPrototypeOf({})); + }); + + it('prevents prototype pollution on nested paths like foo.__proto__', () => { + const lenientMasker = new DataMasking({ throwOnMissingField: false }); + const data = { foo: { bar: 'value' } }; + + const result = lenientMasker.erase(data, { + fields: ['foo.__proto__', 'foo.bar'], + }); + + expect(result.foo.bar).toBe('*****'); + expect(Object.getPrototypeOf(result.foo)).toEqual( + Object.getPrototypeOf({}) + ); + }); + + it('skips primitives inside array wildcard paths', () => { + const lenientMasker = new DataMasking({ throwOnMissingField: false }); + const data = { items: ['a', 'b', 'c'] }; + + const result = lenientMasker.erase(data, { + fields: ['items[*].nested'], + }); + + expect(result.items).toEqual(['a', 'b', 'c']); + }); }); describe('DataMasking.erase() - custom masking rules', () => { @@ -193,7 +277,7 @@ describe('DataMasking.erase() - custom masking rules', () => { }); }); -const jmesPathKey = fc.stringMatching(/^[a-z][a-z0-9_]{0,10}$/); +const pathKey = fc.stringMatching(/^[a-z][a-z0-9_]{0,10}$/); describe('DataMasking.erase() - property tests', () => { const masker = new DataMasking(); @@ -201,7 +285,7 @@ describe('DataMasking.erase() - property tests', () => { it('never mutates the original input', () => { const lenientMasker = new DataMasking({ throwOnMissingField: false }); fc.assert( - fc.property(fc.dictionary(jmesPathKey, fc.jsonValue()), (data) => { + fc.property(fc.dictionary(pathKey, fc.jsonValue()), (data) => { const original = structuredClone(data); lenientMasker.erase(data, { fields: Object.keys(data) }); @@ -221,10 +305,113 @@ describe('DataMasking.erase() - property tests', () => { ); }); + it('replaces every value under parent.* with the mask', () => { + fc.assert( + fc.property( + pathKey, + fc.dictionary(pathKey, fc.string(), { minKeys: 1 }), + (parent, nested) => { + const data = { [parent]: nested }; + const result = masker.erase(data, { + fields: [`${parent}.*`], + }); + + for (const key of Object.keys(nested)) { + expect((result[parent] as Record)[key]).toBe( + '*****' + ); + } + } + ) + ); + }); + + it('masks nested field under each key with parent.*.child', () => { + fc.assert( + fc.property( + pathKey, + pathKey, + fc.dictionary( + pathKey, + fc.record({ secret: fc.string(), keep: fc.string() }), + { minKeys: 1 } + ), + (parent, child, nested) => { + const inner = Object.fromEntries( + Object.entries(nested).map(([k, v]) => [ + k, + { [child]: v.secret, keep: v.keep }, + ]) + ); + const data = { [parent]: inner }; + const result = masker.erase(data, { + fields: [`${parent}.*.${child}`], + }) as Record>>; + + for (const key of Object.keys(inner)) { + expect(result[parent][key][child]).toBe('*****'); + expect(result[parent][key].keep).toBe(inner[key].keep); + } + } + ) + ); + }); + + it('replaces every element field under parent[*].child with the mask', () => { + fc.assert( + fc.property( + pathKey, + pathKey, + fc.array(fc.string(), { minLength: 1, maxLength: 10 }), + (parent, child, values) => { + const data = { + [parent]: values.map((v) => ({ [child]: v, keep: 'visible' })), + }; + const result = masker.erase(data, { + fields: [`${parent}[*].${child}`], + }) as Record[]>; + + for (let i = 0; i < values.length; i++) { + expect(result[parent][i][child]).toBe('*****'); + expect(result[parent][i].keep).toBe('visible'); + } + } + ) + ); + }); + + it('[*] skips array elements missing the targeted field', () => { + fc.assert( + fc.property( + pathKey, + pathKey, + fc.array(fc.string(), { minLength: 1, maxLength: 10 }), + (parent, child, values) => { + const items = values.map((v, i) => + i % 2 === 0 ? { [child]: v, id: i } : { id: i } + ); + const data = { [parent]: items }; + const result = masker.erase(data, { + fields: [`${parent}[*].${child}`], + }) as Record[]>; + + for (let i = 0; i < items.length; i++) { + if (i % 2 === 0) { + expect(result[parent][i][child]).toBe('*****'); + } else { + expect(result[parent][i][child]).toBeUndefined(); + } + expect(result[parent][i].id).toBe(i); + } + } + ) + ); + }); + it('replaces every targeted top-level field with the mask', () => { fc.assert( fc.property( - fc.dictionary(jmesPathKey, fc.string(), { minKeys: 1 }), + fc.dictionary(pathKey, fc.string(), { minKeys: 1 }), (data) => { const fields = Object.keys(data); const result = masker.erase(data, { fields }); From d46f84d4bb37a8a1019b8b9c0b6e67fcdb7a3679 Mon Sep 17 00:00:00 2001 From: svozza Date: Thu, 2 Apr 2026 16:31:53 +0200 Subject: [PATCH 15/17] refactor(data-masking): extract resolveWildcardEntries and use arrow functions Reduce cyclomatic complexity in walk by extracting wildcard/literal segment resolution into a helper. Convert module-level functions to arrow expressions per project code standards. --- packages/data-masking/src/DataMasking.ts | 51 ++++++++++++------------ 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/packages/data-masking/src/DataMasking.ts b/packages/data-masking/src/DataMasking.ts index 45a8d5a1cc..502dd92de2 100644 --- a/packages/data-masking/src/DataMasking.ts +++ b/packages/data-masking/src/DataMasking.ts @@ -206,25 +206,8 @@ export class DataMasking { } if (obj == null || typeof obj !== 'object') return; - if (segments[i] === '*') { - if (Array.isArray(obj)) { - for (let j = 0; j < obj.length; j++) { - walk(obj[j], i + 1, [...current, String(j)]); - } - } else { - for (const key of Object.keys(obj as Record)) { - if (RESERVED_KEYS.has(key)) continue; - walk((obj as Record)[key], i + 1, [ - ...current, - key, - ]); - } - } - } else { - const next = (obj as Record)[segments[i]]; - if (next !== undefined) { - walk(next, i + 1, [...current, segments[i]]); - } + for (const [key, child] of resolveWildcardEntries(obj, segments[i])) { + walk(child, i + 1, [...current, key]); } }; @@ -236,7 +219,25 @@ export class DataMasking { const RESERVED_KEYS = new Set(['__proto__', 'constructor', 'prototype']); -function getAtPath(data: unknown, path: string[]): unknown { +const resolveWildcardEntries = ( + obj: object, + segment: string +): [string, unknown][] => { + if (segment !== '*') { + const next = (obj as Record)[segment]; + + return next !== undefined ? [[segment, next]] : []; + } + if (Array.isArray(obj)) { + return obj.map((v, i) => [String(i), v]); + } + + return Object.keys(obj) + .filter((k) => !RESERVED_KEYS.has(k)) + .map((k) => [k, (obj as Record)[k]]); +}; + +const getAtPath = (data: unknown, path: string[]): unknown => { let current = data as Record; for (const key of path) { if (RESERVED_KEYS.has(key)) return undefined; @@ -244,9 +245,9 @@ function getAtPath(data: unknown, path: string[]): unknown { } return current; -} +}; -function setAtPath(data: unknown, path: string[], value: unknown): void { +const setAtPath = (data: unknown, path: string[], value: unknown): void => { let current = data as Record; for (let i = 0; i < path.length - 1; i++) { current = current[path[i]] as Record; @@ -254,9 +255,9 @@ function setAtPath(data: unknown, path: string[], value: unknown): void { const lastKey = path.at(-1); if (!lastKey || RESERVED_KEYS.has(lastKey)) return; current[lastKey] = value; -} +}; -function applyMaskingRule(value: string, rule: MaskingRule): string { +const applyMaskingRule = (value: string, rule: MaskingRule): string => { if (rule.regexPattern && rule.maskFormat) { return value.replace(rule.regexPattern, rule.maskFormat); } @@ -264,4 +265,4 @@ function applyMaskingRule(value: string, rule: MaskingRule): string { if (rule.customMask !== undefined) return rule.customMask; return DEFAULT_MASK_VALUE; -} +}; From 1365d3dec93118d7a29ab6c8bcf62eba6da045e5 Mon Sep 17 00:00:00 2001 From: svozza Date: Thu, 2 Apr 2026 17:06:44 +0200 Subject: [PATCH 16/17] fix(data-masking): replace inverted negation with early return in resolveWildcardEntries --- packages/data-masking/src/DataMasking.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/data-masking/src/DataMasking.ts b/packages/data-masking/src/DataMasking.ts index 502dd92de2..4e058d582c 100644 --- a/packages/data-masking/src/DataMasking.ts +++ b/packages/data-masking/src/DataMasking.ts @@ -226,7 +226,9 @@ const resolveWildcardEntries = ( if (segment !== '*') { const next = (obj as Record)[segment]; - return next !== undefined ? [[segment, next]] : []; + if (next === undefined) return []; + + return [[segment, next]]; } if (Array.isArray(obj)) { return obj.map((v, i) => [String(i), v]); From f27eb0c105025feaad53fef25a022e7d0c9481fa Mon Sep 17 00:00:00 2001 From: svozza Date: Mon, 6 Apr 2026 22:44:53 +0100 Subject: [PATCH 17/17] docs(data-masking): add object wildcard (.*) examples --- docs/features/data-masking.md | 72 +++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/docs/features/data-masking.md b/docs/features/data-masking.md index 06cd81344c..692a2349f4 100644 --- a/docs/features/data-masking.md +++ b/docs/features/data-masking.md @@ -403,6 +403,78 @@ Here are common scenarios to best visualise how to use `fields`. } ``` +=== "All fields in an object" + + You want to erase all values under `credentials` without listing each key individually. + + > Expression: `masker.erase(data, { fields: ['credentials.*'] })` + + === "Data" + + ```json hl_lines="3-5" + { + "name": "Jane", + "credentials": { + "username": "admin", + "password": "s3cret", + "token": "abc123" + } + } + ``` + + === "Result" + + ```json hl_lines="3-5" + { + "name": "Jane", + "credentials": { + "username": "*****", + "password": "*****", + "token": "*****" + } + } + ``` + +=== "Nested field via object wildcard" + + You want to erase the `ssn` field under every key in the `users` object. + + > Expression: `masker.erase(data, { fields: ['users.*.ssn'] })` + + === "Data" + + ```json hl_lines="4 8" + { + "users": { + "alice": { + "ssn": "111-22-3333", + "name": "Alice" + }, + "bob": { + "ssn": "444-55-6666", + "name": "Bob" + } + } + } + ``` + + === "Result" + + ```json hl_lines="4 8" + { + "users": { + "alice": { + "ssn": "*****", + "name": "Alice" + }, + "bob": { + "ssn": "*****", + "name": "Bob" + } + } + } + ``` + ## Advanced ### Using multiple keys