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/.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] 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..692a2349f4 --- /dev/null +++ b/docs/features/data-masking.md @@ -0,0 +1,693 @@ +--- +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": "*****" + } + ] + } + ``` + +=== "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 + +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..ea399d3d38 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,26 @@ "@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" + }, + "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..96d0021803 --- /dev/null +++ b/packages/data-masking/package.json @@ -0,0 +1,100 @@ +{ + "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": "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", + "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" + }, + "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..4e058d582c --- /dev/null +++ b/packages/data-masking/src/DataMasking.ts @@ -0,0 +1,270 @@ +import { DEFAULT_MASK_VALUE } from './constants.js'; +import { + DataMaskingEncryptionError, + DataMaskingFieldNotFoundError, + DataMaskingUnsupportedTypeError, +} from './errors.js'; +import type { + DataMaskingConstructorOptions, + DecryptOptions, + EncryptionProvider, + EncryptOptions, + 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) { + 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 ?? []); + } + + 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)); + } + } + } + + #eraseFields(copy: T, fields: string[]): void { + for (const field of 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); + } + } + } + + /** + * 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[][] { + 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; + + for (const [key, child] of resolveWildcardEntries(obj, segments[i])) { + walk(child, i + 1, [...current, key]); + } + }; + + walk(data, 0, []); + + return paths; + } +} + +const RESERVED_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +const resolveWildcardEntries = ( + obj: object, + segment: string +): [string, unknown][] => { + if (segment !== '*') { + const next = (obj as Record)[segment]; + + if (next === undefined) return []; + + return [[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; + current = current[key] as Record; + } + + return current; +}; + +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; + } + const lastKey = path.at(-1); + if (!lastKey || RESERVED_KEYS.has(lastKey)) return; + current[lastKey] = value; +}; + +const 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..e67018b214 --- /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 { + /** 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; +} + +/** Options for {@link DataMasking.encrypt}. */ +export interface EncryptOptions { + /** 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; + /** Provider-specific options (escape hatch). */ + providerOptions?: Record; +} + +/** Options for {@link DataMasking.decrypt}. */ +export interface DecryptOptions { + /** Dot-notation path expressions for fields to decrypt (supports `.*` and `[*]` wildcards). */ + 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..cc81ef9bb6 --- /dev/null +++ b/packages/data-masking/tests/e2e/dataMasking.test.FunctionCode.ts @@ -0,0 +1,44 @@ +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 +): Promise> => { + return Promise.resolve( + 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..adad931203 --- /dev/null +++ b/packages/data-masking/tests/e2e/dataMasking.test.ts @@ -0,0 +1,122 @@ +import { join } from 'node:path'; +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'; + +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; + const fieldEncryptFn = new TestNodejsFunction( + testStack, + { + entry: lambdaFunctionCodeFilePath, + handler: 'handlerFieldEncrypt', + environment: { + KMS_KEY_ARN: kmsKey.keyArn, + }, + }, + { nameSuffix: 'fieldEncrypt' } + ); + kmsKey.grantEncryptDecrypt(fieldEncryptFn); + + let functionNameFullEncrypt: string; + const fullEncryptFn = new TestNodejsFunction( + testStack, + { + entry: lambdaFunctionCodeFilePath, + handler: 'handlerFullEncrypt', + environment: { + KMS_KEY_ARN: kmsKey.keyArn, + }, + }, + { nameSuffix: 'fullEncrypt' } + ); + kmsKey.grantEncryptDecrypt(fullEncryptFn); + + beforeAll(async () => { + await testStack.deploy(); + functionNameErase = testStack.findAndGetStackOutputValue('erase'); + functionNameFieldEncrypt = + testStack.findAndGetStackOutputValue('fieldEncrypt'); + functionNameFullEncrypt = + testStack.findAndGetStackOutputValue('fullEncrypt'); + }, 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/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/tests/unit/AWSEncryptionSDKProvider.test.ts b/packages/data-masking/tests/unit/AWSEncryptionSDKProvider.test.ts new file mode 100644 index 0000000000..87f8701148 --- /dev/null +++ b/packages/data-masking/tests/unit/AWSEncryptionSDKProvider.test.ts @@ -0,0 +1,84 @@ +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: {} }, + })); + + const MockKmsKeyringNode = vi.fn(); + const MockNodeCachingMaterialsManager = vi.fn(); + + 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'); + }); + + 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 new file mode 100644 index 0000000000..df58cd6901 --- /dev/null +++ b/packages/data-masking/tests/unit/encrypt-decrypt.test.ts @@ -0,0 +1,273 @@ +import fc from 'fast-check'; +import { describe, expect, it, vi } from 'vitest'; +import { DataMaskingEncryptionError } from '../../src/errors.js'; +import { DataMasking } from '../../src/index.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('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 + ); + }); + + 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('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(); + + await expect( + masker.decrypt({ a: 'encrypted' }, { fields: ['a'] }) + ).rejects.toThrow(DataMaskingEncryptionError); + }); +}); + +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 () => { + 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('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(pathKey, 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..57b370a459 --- /dev/null +++ b/packages/data-masking/tests/unit/erase.test.ts @@ -0,0 +1,426 @@ +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('*****'); + }); + + 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', () => { + const masker = new DataMasking(); + + it('applies regex pattern masking', () => { + const data = { email: 'jane@example.com' }; + + const result = masker.erase(data, { + maskingRules: { + email: { + regexPattern: /(.)(.+?)(@.*)/, // NOSONAR - test data, not user input + 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 pathKey = 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(pathKey, 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 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(pathKey, 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..fe5446c2ef --- /dev/null +++ b/packages/data-masking/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineProject } from 'vitest/config'; + +export default defineProject({ + test: { + environment: 'node', + typecheck: { + tsconfig: './tests/tsconfig.json', + include: ['./tests/types/**/*.ts'], + }, + }, +});