diff --git a/Node/ai-logic-sensitive-data/README.md b/Node/ai-logic-sensitive-data/README.md new file mode 100644 index 000000000..65826be4e --- /dev/null +++ b/Node/ai-logic-sensitive-data/README.md @@ -0,0 +1,150 @@ +# Firebase AI Logic: Redact Sensitive Data with Cloud Sensitive Data Protection (2nd Gen) + +This sample demonstrates how to use **Firebase AI Logic triggers** (available in `firebase-functions` 7.4.0 and later) in conjunction with [**Cloud Sensitive Data Protection (DLP)**](https://cloud.google.com/security/products/sensitive-data-protection) to automatically inspect and redact sensitive data (PII) **before** sending prompts to the Gemini API and **after** receiving responses from the model. + +## Introduction + +[Firebase AI Logic triggers](https://firebase.google.com/docs/ai-logic/pre-and-post-request-scripts) allow you to run custom server-side scripts that intercept every `generateContent` request sent to the Gemini API via Firebase AI Logic — *without modifying your client application code*. + +This sample implements two 2nd Gen Cloud Functions: + +1. **`redactPrompt` (`beforeGenerateContent`)**: + Runs *before* a request reaches the Gemini API. It inspects all text parts in the prompt's `contents` (as well as `systemInstruction`, if present) for sensitive information (such as email addresses, phone numbers, credit card numbers, and US social security numbers) and replaces them with standard infoType placeholders (e.g., `[EMAIL_ADDRESS]`, `[PHONE_NUMBER]`). If sensitive data was found, the function returns the modified request. If no sensitive data was detected, it returns without modifying the request. + +2. **`redactResponse` (`afterGenerateContent`)**: + Runs *after* the Gemini API returns a response and *before* that response is returned to the client app. It inspects the generated text in the response `candidates` for sensitive information and redacts any matching tokens. + +### Request Flow + +``` +[Client App] + │ + │ 1. generateContent request + ▼ +[Firebase AI Logic Proxy] + │ + │ 2. Triggers beforeGenerateContent + ▼ +[Cloud Function: redactPrompt] ──(Cloud SDP / DLP API)──> Redacts sensitive data in prompt + │ + │ 3. Sanitized prompt forwarded to Gemini API + ▼ +[Gemini API (Google AI / Vertex AI)] + │ + │ 4. Response generated + ▼ +[Firebase AI Logic Proxy] + │ + │ 5. Triggers afterGenerateContent + ▼ +[Cloud Function: redactResponse] ──(Cloud SDP / DLP API)──> Redacts sensitive data in response + │ + │ 6. Sanitized response returned to client + ▼ +[Client App] +``` + +## Documentation Region Tags (`includecode`) + +This sample provides region tags formatted for use with DevSite `includecode` in the Firebase AI Logic documentation ([pre-and-post-request-scripts.md](https://firebase.google.com/docs/ai-logic/pre-and-post-request-scripts)): + +| Region Tag | Description | +| --- | --- | +| `ai_logic_imports` | Imports from `firebase-functions`, `firebase-functions/v2/ai`, and `@google-cloud/dlp`. | +| `ai_logic_redact_helper` | The `redactSensitiveData` utility function that calls `dlp.deidentifyContent(...)`. | +| `ai_logic_before_generate_content` | The `redactPrompt` pre-request trigger (`beforeGenerateContent`). | +| `ai_logic_after_generate_content` | The `redactResponse` post-request trigger (`afterGenerateContent`). | +| `ai_logic_pre_request` | Self-contained pre-request snippet including imports, DLP helper, and `beforeGenerateContent` trigger. | +| `ai_logic_sensitive_data_all` | Complete end-to-end sample containing imports, helper, and both triggers. | + +To reference these snippets in Firebase DevSite documentation: + +```markdown + +{% includecode github_path="firebase/functions-samples/Node/ai-logic-sensitive-data/functions/index.js" region_tag="ai_logic_before_generate_content" adjust_indentation="auto" %} + + +{% includecode github_path="firebase/functions-samples/Node/ai-logic-sensitive-data/functions/index.js" region_tag="ai_logic_after_generate_content" adjust_indentation="auto" %} +``` + +## Prerequisites + +1. A **Firebase Project** upgraded to the [Blaze "pay-as-you-go" plan](https://firebase.google.com/pricing). +2. [Firebase CLI](https://firebase.google.com/docs/cli) installed and authenticated: + ```bash + npm install -g firebase-tools + firebase login + ``` +3. Enable the **Cloud Sensitive Data Protection API** (formerly Cloud DLP): + ```bash + gcloud services enable dlp.googleapis.com --project=YOUR_PROJECT_ID + ``` +4. **Grant IAM Permissions**: + - The default compute / Cloud Build service account requires the Cloud Build Service Account role: + ```bash + gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \ + --member="serviceAccount:YOUR_PROJECT_NUMBER-compute@developer.gserviceaccount.com" \ + --role="roles/cloudbuild.builds.builder" + ``` + - The Cloud Functions runtime service account requires permissions to invoke Cloud Sensitive Data Protection: + ```bash + gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \ + --member="serviceAccount:YOUR_PROJECT_ID@appspot.gserviceaccount.com" \ + --role="roles/dlp.user" + ``` + +## Setup & Deployment + +1. Navigate to the sample directory: + ```bash + cd Node/ai-logic-sensitive-data + ``` + +2. Select your Firebase project: + ```bash + firebase use --add + ``` + +3. Install dependencies in the `functions` directory: + ```bash + cd functions + npm install + ``` + +4. Deploy the functions to Firebase: + ```bash + firebase deploy --only functions + ``` + + Once deployed, the `redactPrompt` and `redactResponse` functions are automatically registered with Firebase AI Logic in the `global` region as blocking triggers. + +## Local Testing & Verification + +The sample includes an automated unit test suite using Node.js's native test runner (`node:test`). The tests mock the Cloud Sensitive Data Protection client to verify: +- Sensitive infoTypes (emails, phone numbers, credit cards, SSNs) are properly detected and redacted. +- Non-sensitive text and non-text parts (e.g., inline images) are preserved untouched. +- Handlers return undefined when no sensitive data is detected, leaving requests and responses untouched. +- Proper handling of string and object `systemInstruction`. +- Both Gemini Developer API (`geminiV1Beta`) and Vertex AI (`vertexV1Beta1`) request structures. + +To run the unit tests: + +```bash +cd functions +npm test +``` + +## Customizing Redaction Rules + +In [`functions/index.js`](functions/index.js), you can customize: + +- **InfoTypes**: Add or remove infoTypes (such as `PERSON_NAME`, `PASSPORT`, `IP_ADDRESS`, `STREET_ADDRESS`) in `inspectConfig.infoTypes`. +- **Transformations**: Change `primitiveTransformation` from `replaceWithInfoTypeConfig: {}` to: + - `replaceConfig: { newValue: { stringValue: "[REDACTED]" } }` to use a generic replacement token. + - Character masking (e.g. masking all but the last 4 digits of a card number). + - Cryptographic hash or tokenization. +- **DLP Templates**: Use pre-configured Cloud SDP templates in Google Cloud Console with `inspectTemplateName` and `deidentifyTemplateName`. + +## License + +© Google, 2024. Licensed under an [Apache-2.0](../../LICENSE) license. diff --git a/Node/ai-logic-sensitive-data/firebase.json b/Node/ai-logic-sensitive-data/firebase.json new file mode 100644 index 000000000..734105b2d --- /dev/null +++ b/Node/ai-logic-sensitive-data/firebase.json @@ -0,0 +1,15 @@ +{ + "functions": { + "source": "functions", + "codebase": "ai-logic-sensitive-data", + "ignore": [ + "node_modules", + ".git", + "firebase-debug.log", + "firebase-debug.*.log" + ], + "predeploy": [ + "npm --prefix \"$RESOURCE_DIR\" run lint" + ] + } +} diff --git a/Node/ai-logic-sensitive-data/functions/.gitignore b/Node/ai-logic-sensitive-data/functions/.gitignore new file mode 100644 index 000000000..7022e3730 --- /dev/null +++ b/Node/ai-logic-sensitive-data/functions/.gitignore @@ -0,0 +1,3 @@ +# Node.js dependency directory +node_modules/ +tsconfig-compile.json diff --git a/Node/ai-logic-sensitive-data/functions/eslint.config.js b/Node/ai-logic-sensitive-data/functions/eslint.config.js new file mode 100644 index 000000000..60d38ed0b --- /dev/null +++ b/Node/ai-logic-sensitive-data/functions/eslint.config.js @@ -0,0 +1,16 @@ +export default [ + { + files: ["**/*.js", "**/*.cjs", "**/*.mjs"], + rules: { + "no-console": "off", + "no-unused-vars": "off", + "no-undef": "off", + "no-empty": "off", + "no-useless-escape": "off", + "no-prototype-builtins": "off", + "no-redeclare": "off", + "no-constant-condition": "off", + "no-case-declarations": "off" + } + } +]; diff --git a/Node/ai-logic-sensitive-data/functions/index.js b/Node/ai-logic-sensitive-data/functions/index.js new file mode 100644 index 000000000..ded703f56 --- /dev/null +++ b/Node/ai-logic-sensitive-data/functions/index.js @@ -0,0 +1,172 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// [START ai_logic_sensitive_data_all] +// [START ai_logic_pre_request] +// [START ai_logic_imports] +import { logger } from "firebase-functions"; +import { + beforeGenerateContent, + afterGenerateContent, +} from "firebase-functions/v2/ai"; +import { DlpServiceClient } from "@google-cloud/dlp"; +// [END ai_logic_imports] + +// [START ai_logic_redact_helper] +let dlp = new DlpServiceClient(); + +/** + * Redacts sensitive data from a text string using Cloud Sensitive Data Protection (DLP). + * + * Inspects for basic sensitive infoTypes (email, phone number, credit card number, SSN) + * and replaces detected values with their infoType placeholder (e.g. "[EMAIL_ADDRESS]"). + * + * @param {string} text - The raw input text string to inspect and redact. + * @returns {Promise} The redacted text, or the original text if empty or unchanged. + */ +export async function redactSensitiveData(text) { + if (typeof text !== "string" || !text.trim()) { + return text; + } + + const projectId = await dlp.getProjectId(); + + const [response] = await dlp.deidentifyContent({ + parent: `projects/${projectId}/locations/global`, + item: { value: text }, + inspectConfig: { + infoTypes: [ + { name: "EMAIL_ADDRESS" }, + { name: "PHONE_NUMBER" }, + { name: "CREDIT_CARD_NUMBER" }, + { name: "US_SOCIAL_SECURITY_NUMBER" }, + ], + }, + deidentifyConfig: { + infoTypeTransformations: { + transformations: [ + { + primitiveTransformation: { + replaceWithInfoTypeConfig: {}, + }, + }, + ], + }, + }, + }); + + return response?.item?.value ?? text; +} +// [END ai_logic_redact_helper] + +// [START ai_logic_before_generate_content] +/** + * Pre-request trigger for Firebase AI Logic. + * Runs before each generateContent request is forwarded to the Gemini API. + * Intercepts the request and redacts sensitive data (PII) from prompt contents + * and system instructions before reaching the model. + */ +export const redactPrompt = beforeGenerateContent(async (event) => { + const request = event.data?.request; + if (!request) return; + + let modified = false; + + // Redact sensitive data from prompt contents + for (const content of request.contents ?? []) { + for (const part of content?.parts ?? []) { + if (part?.text) { + const redacted = await redactSensitiveData(part.text); + if (redacted !== part.text) { + part.text = redacted; + modified = true; + } + } + } + } + + // Redact sensitive data from system instruction if present + if (typeof request.systemInstruction === "string") { + const redacted = await redactSensitiveData(request.systemInstruction); + if (redacted !== request.systemInstruction) { + request.systemInstruction = redacted; + modified = true; + } + } else if (request.systemInstruction) { + const parts = request.systemInstruction.parts ?? [request.systemInstruction]; + for (const part of parts) { + if (part?.text) { + const redacted = await redactSensitiveData(part.text); + if (redacted !== part.text) { + part.text = redacted; + modified = true; + } + } + } + } + + // Returning nothing (or undefined) leaves the request untouched. + // If modified, return the updated request object. + if (modified) { + logger.info("Redacted sensitive data from prompt request"); + return request; + } +}); +// [END ai_logic_before_generate_content] +// [END ai_logic_pre_request] + +// [START ai_logic_after_generate_content] +/** + * Post-request trigger for Firebase AI Logic. + * Runs after the model generates a response and before it is returned to the client. + * Inspects all response candidates and redacts any sensitive data generated by the model. + */ +export const redactResponse = afterGenerateContent(async (event) => { + const response = event.data?.response; + if (!response?.candidates) return; + + let modified = false; + + // Redact sensitive data from model response candidates + for (const candidate of response.candidates) { + for (const part of candidate?.content?.parts ?? []) { + if (part?.text) { + const redacted = await redactSensitiveData(part.text); + if (redacted !== part.text) { + part.text = redacted; + modified = true; + } + } + } + } + + // Returning nothing (or undefined) leaves the response untouched. + // If modified, return the updated response object. + if (modified) { + logger.info("Redacted sensitive data from model response candidates"); + return response; + } +}); +// [END ai_logic_after_generate_content] +// [END ai_logic_sensitive_data_all] + +/** + * Allows injecting a custom DlpServiceClient for testing purposes. + * @internal + */ +export function setDlpClientForTesting(client) { + dlp = client; +} diff --git a/Node/ai-logic-sensitive-data/functions/index.test.js b/Node/ai-logic-sensitive-data/functions/index.test.js new file mode 100644 index 000000000..bf9b64fa7 --- /dev/null +++ b/Node/ai-logic-sensitive-data/functions/index.test.js @@ -0,0 +1,715 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { + redactSensitiveData, + redactPrompt, + redactResponse, + setDlpClientForTesting, +} from "./index.js"; + +/** + * Creates a mock DLP client that simulates deidentification using regex for standard infoTypes. + */ +function createMockDlpClient(opts) { + let callCount = 0; + + const mockClient = { + get callCount() { + return callCount; + }, + async getProjectId() { + return "test-project"; + }, + async deidentifyContent(request) { + callCount++; + if (opts?.shouldFail) { + throw new Error("DLP service unavailable"); + } + + const text = request?.item?.value ?? ""; + let transformed = text; + + // Mock DLP redaction rules matching default infoTypes + transformed = transformed.replace( + /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, + "[EMAIL_ADDRESS]" + ); + transformed = transformed.replace( + /\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/g, + "[PHONE_NUMBER]" + ); + transformed = transformed.replace( + /\b(?:\d{4}[- ]?){3}\d{4}\b/g, + "[CREDIT_CARD_NUMBER]" + ); + transformed = transformed.replace( + /\b\d{3}-\d{2}-\d{4}\b/g, + "[US_SOCIAL_SECURITY_NUMBER]" + ); + + return [ + { + item: { value: transformed }, + overview: { transformedBytes: transformed.length }, + }, + request, + {}, + ]; + }, + }; + + return mockClient; +} + +/** + * Invokes an AI Logic Cloud Function handler with a simulated Cloud Function request and response. + */ +function invokeAiTrigger(fn, event) { + return new Promise((resolve, reject) => { + const req = { body: event }; + const res = { + statusCode: 200, + status(code) { + this.statusCode = code; + return this; + }, + send(data) { + if (this.statusCode >= 400) { + reject(new Error(data?.message || `HTTP ${this.statusCode}`)); + } else { + resolve(data); + } + }, + }; + Promise.resolve(fn(req, res)).catch(reject); + }); +} + +describe("AI Logic Sensitive Data Redaction Sample", () => { + let mockDlp; + + beforeEach(() => { + mockDlp = createMockDlpClient(); + setDlpClientForTesting(mockDlp); + }); + + describe("redactSensitiveData helper", () => { + it("should return empty string immediately without calling DLP", async () => { + const result = await redactSensitiveData(""); + assert.equal(result, ""); + assert.equal(mockDlp.callCount, 0); + }); + + it("should return whitespace-only string immediately without calling DLP", async () => { + const result = await redactSensitiveData(" \t\n "); + assert.equal(result, " \t\n "); + assert.equal(mockDlp.callCount, 0); + }); + + it("should redact email addresses", async () => { + const input = "Please contact me at alice@example.com for further info."; + const result = await redactSensitiveData(input); + assert.equal( + result, + "Please contact me at [EMAIL_ADDRESS] for further info." + ); + assert.equal(mockDlp.callCount, 1); + }); + + it("should redact multiple types of sensitive info in the same text", async () => { + const input = + "Customer alice@example.com with phone 555-123-4567, SSN 123-45-6789, CC 4111-2222-3333-4444."; + const result = await redactSensitiveData(input); + assert.equal( + result, + "Customer [EMAIL_ADDRESS] with phone [PHONE_NUMBER], SSN [US_SOCIAL_SECURITY_NUMBER], CC [CREDIT_CARD_NUMBER]." + ); + }); + + it("should return original text when no sensitive info is detected", async () => { + const input = "Explain quantum computing in simple terms."; + const result = await redactSensitiveData(input); + assert.equal(result, input); + assert.equal(mockDlp.callCount, 1); + }); + + it("should return non-string inputs unchanged without calling DLP", async () => { + assert.equal(await redactSensitiveData(null), null); + assert.equal(await redactSensitiveData(undefined), undefined); + assert.equal(await redactSensitiveData(12345), 12345); + const obj = { foo: "bar" }; + assert.equal(await redactSensitiveData(obj), obj); + assert.equal(mockDlp.callCount, 0); + }); + + it("should fall back to original text when DLP response is missing item or value", async () => { + const emptyItemDlp = { + async getProjectId() { + return "test-project"; + }, + async deidentifyContent() { + return [{}]; // no item + }, + }; + setDlpClientForTesting(emptyItemDlp); + + const input = "Sensitive data test"; + const result = await redactSensitiveData(input); + assert.equal(result, input); + }); + + it("should propagate errors thrown by the DLP service", async () => { + const failingDlp = createMockDlpClient({ shouldFail: true }); + setDlpClientForTesting(failingDlp); + + await assert.rejects( + async () => { + await redactSensitiveData("test@example.com"); + }, + { message: "DLP service unavailable" } + ); + }); + }); + + describe("redactPrompt (beforeGenerateContent)", () => { + it("should redact sensitive data from single prompt content part", async () => { + const event = { + data: { + api: "google.cloud.aiplatform.v1beta1", + model: "projects/my-project/locations/us-central1/publishers/google/models/gemini-1.5-flash", + request: { + contents: [ + { + role: "user", + parts: [ + { text: "My email address is support@acme.org, please subscribe me." }, + ], + }, + ], + }, + }, + }; + + const responseBody = await invokeAiTrigger(redactPrompt, event); + + assert.ok(responseBody); + assert.equal( + responseBody.contents[0].parts[0].text, + "My email address is [EMAIL_ADDRESS], please subscribe me." + ); + }); + + it("should redact sensitive data across multiple conversation turns and parts", async () => { + const event = { + data: { + api: "google.ai.generativelanguage.v1beta", + model: "models/gemini-1.5-pro", + request: { + contents: [ + { + role: "user", + parts: [{ text: "Call me at 800-555-0199." }], + }, + { + role: "model", + parts: [{ text: "Got it! Any other info?" }], + }, + { + role: "user", + parts: [ + { text: "Also, my email is john.doe@example.com." }, + { text: "And SSN is 000-11-2222." }, + ], + }, + ], + }, + }, + }; + + const responseBody = await invokeAiTrigger(redactPrompt, event); + + assert.ok(responseBody); + assert.equal( + responseBody.contents[0].parts[0].text, + "Call me at [PHONE_NUMBER]." + ); + assert.equal( + responseBody.contents[1].parts[0].text, + "Got it! Any other info?" + ); + assert.equal( + responseBody.contents[2].parts[0].text, + "Also, my email is [EMAIL_ADDRESS]." + ); + assert.equal( + responseBody.contents[2].parts[1].text, + "And SSN is [US_SOCIAL_SECURITY_NUMBER]." + ); + }); + + it("should preserve non-text parts (e.g. inlineData) untouched", async () => { + const event = { + data: { + api: "google.cloud.aiplatform.v1beta1", + model: "gemini-1.5-flash", + request: { + contents: [ + { + role: "user", + parts: [ + { inlineData: { mimeType: "image/jpeg", data: "base64data==" } }, + { text: "Contact contact@example.com about this image." }, + ], + }, + ], + }, + }, + }; + + const responseBody = await invokeAiTrigger(redactPrompt, event); + + assert.ok(responseBody); + assert.deepEqual(responseBody.contents[0].parts[0], { + inlineData: { mimeType: "image/jpeg", data: "base64data==" }, + }); + assert.equal( + responseBody.contents[0].parts[1].text, + "Contact [EMAIL_ADDRESS] about this image." + ); + }); + + it("should return empty object (leaving request untouched) when prompt contains no sensitive data", async () => { + const event = { + data: { + api: "google.cloud.aiplatform.v1beta1", + model: "gemini-1.5-flash", + request: { + contents: [ + { + role: "user", + parts: [{ text: "Write a poem about the sunrise." }], + }, + ], + }, + }, + }; + + const responseBody = await invokeAiTrigger(redactPrompt, event); + assert.equal(responseBody.contents, undefined); + }); + + it("should leave request untouched when contents array is empty", async () => { + const event = { + data: { + api: "google.cloud.aiplatform.v1beta1", + model: "gemini-1.5-flash", + request: { + contents: [], + }, + }, + }; + + const responseBody = await invokeAiTrigger(redactPrompt, event); + assert.equal(responseBody.contents, undefined); + }); + + it("should redact sensitive data in string systemInstruction", async () => { + const event = { + data: { + api: "google.cloud.aiplatform.v1beta1", + model: "gemini-1.5-pro", + request: { + systemInstruction: "Always BCC admin@example.com for all requests.", + contents: [ + { + role: "user", + parts: [{ text: "Hello there!" }], + }, + ], + }, + }, + }; + + const responseBody = await invokeAiTrigger(redactPrompt, event); + + assert.ok(responseBody); + assert.equal( + responseBody.systemInstruction, + "Always BCC [EMAIL_ADDRESS] for all requests." + ); + }); + + it("should redact sensitive data in object systemInstruction parts", async () => { + const event = { + data: { + api: "google.cloud.aiplatform.v1beta1", + model: "gemini-1.5-pro", + request: { + systemInstruction: { + role: "system", + parts: [{ text: "Escalate emergencies to 800-555-0100 immediately." }], + }, + contents: [ + { + role: "user", + parts: [{ text: "Clean text" }], + }, + ], + }, + }, + }; + + const responseBody = await invokeAiTrigger(redactPrompt, event); + + assert.ok(responseBody); + assert.equal( + responseBody.systemInstruction.parts[0].text, + "Escalate emergencies to [PHONE_NUMBER] immediately." + ); + }); + + it("should redact sensitive data in Part systemInstruction ({ text })", async () => { + const event = { + data: { + api: "google.ai.generativelanguage.v1beta", + model: "gemini-1.5-pro", + request: { + systemInstruction: { + text: "Notify dev-ops at ops@example.com on error.", + }, + contents: [ + { + role: "user", + parts: [{ text: "System check" }], + }, + ], + }, + }, + }; + + const responseBody = await invokeAiTrigger(redactPrompt, event); + + assert.ok(responseBody); + assert.equal( + responseBody.systemInstruction.text, + "Notify dev-ops at [EMAIL_ADDRESS] on error." + ); + }); + + it("should redact sensitive data in systemInstruction when contents array is empty", async () => { + const event = { + data: { + api: "google.cloud.aiplatform.v1beta1", + model: "gemini-1.5-flash", + request: { + systemInstruction: "Report to lead@startup.io immediately.", + contents: [], + }, + }, + }; + + const responseBody = await invokeAiTrigger(redactPrompt, event); + + assert.ok(responseBody); + assert.equal( + responseBody.systemInstruction, + "Report to [EMAIL_ADDRESS] immediately." + ); + }); + + it("should redact sensitive data in systemInstruction when contents is undefined", async () => { + const event = { + data: { + api: "google.cloud.aiplatform.v1beta1", + model: "gemini-1.5-flash", + request: { + systemInstruction: "Direct queries to support@service.com.", + }, + }, + }; + + const responseBody = await invokeAiTrigger(redactPrompt, event); + + assert.ok(responseBody); + assert.equal( + responseBody.systemInstruction, + "Direct queries to [EMAIL_ADDRESS]." + ); + }); + + it("should gracefully handle undefined request", async () => { + const event = { + data: {}, + }; + + const responseBody = await invokeAiTrigger(redactPrompt, event); + assert.equal(responseBody.contents, undefined); + }); + + it("should safely handle contents or parts containing null or non-object items", async () => { + const event = { + data: { + api: "google.cloud.aiplatform.v1beta1", + model: "gemini-1.5-flash", + request: { + contents: [ + null, + { parts: null }, + { + role: "user", + parts: [ + null, + undefined, + {}, + { text: "" }, + { text: "Call 555-432-1098 please." }, + ], + }, + ], + }, + }, + }; + + const responseBody = await invokeAiTrigger(redactPrompt, event); + assert.ok(responseBody); + assert.equal( + responseBody.contents[2].parts[4].text, + "Call [PHONE_NUMBER] please." + ); + }); + + it("should safely handle systemInstruction parts containing null or non-object items", async () => { + const event = { + data: { + api: "google.cloud.aiplatform.v1beta1", + model: "gemini-1.5-flash", + request: { + systemInstruction: { + parts: [null, undefined, {}, { text: "Contact contact@corp.com" }], + }, + contents: [], + }, + }, + }; + + const responseBody = await invokeAiTrigger(redactPrompt, event); + assert.ok(responseBody); + assert.equal( + responseBody.systemInstruction.parts[3].text, + "Contact [EMAIL_ADDRESS]" + ); + }); + + it("should expose the expected blockingTrigger endpoint configuration", () => { + const endpoint = redactPrompt.__endpoint; + assert.ok(endpoint); + assert.equal(endpoint.platform, "gcfv2"); + assert.equal( + endpoint.blockingTrigger?.eventType, + "google.firebase.ailogic.v1.beforeGenerate" + ); + }); + }); + + describe("redactResponse (afterGenerateContent)", () => { + it("should redact sensitive data from candidate text", async () => { + const event = { + data: { + api: "google.cloud.aiplatform.v1beta1", + model: "gemini-1.5-flash", + response: { + candidates: [ + { + content: { + role: "model", + parts: [ + { + text: "We have updated your record. The receipt was sent to customer@example.com.", + }, + ], + }, + finishReason: "STOP", + index: 0, + }, + ], + }, + }, + }; + + const responseBody = await invokeAiTrigger(redactResponse, event); + + assert.ok(responseBody); + assert.equal( + responseBody.candidates[0].content.parts[0].text, + "We have updated your record. The receipt was sent to customer@example.com." + .replace("customer@example.com", "[EMAIL_ADDRESS]") + ); + assert.equal(responseBody.candidates[0].finishReason, "STOP"); + assert.equal(responseBody.candidates[0].index, 0); + }); + + it("should redact sensitive data across multiple candidates", async () => { + const event = { + data: { + api: "google.cloud.aiplatform.v1beta1", + model: "gemini-1.5-flash", + response: { + candidates: [ + { + content: { + role: "model", + parts: [{ text: "Candidate 1: Call 123-456-7890." }], + }, + index: 0, + }, + { + content: { + role: "model", + parts: [{ text: "Candidate 2: Email dev@example.com." }], + }, + index: 1, + }, + ], + }, + }, + }; + + const responseBody = await invokeAiTrigger(redactResponse, event); + + assert.ok(responseBody); + assert.equal( + responseBody.candidates[0].content.parts[0].text, + "Candidate 1: Call [PHONE_NUMBER]." + ); + assert.equal( + responseBody.candidates[1].content.parts[0].text, + "Candidate 2: Email [EMAIL_ADDRESS]." + ); + }); + + it("should return empty object (leaving response untouched) when response candidates have no sensitive data", async () => { + const event = { + data: { + api: "google.cloud.aiplatform.v1beta1", + model: "gemini-1.5-flash", + response: { + candidates: [ + { + content: { + role: "model", + parts: [{ text: "Here is a safe summary of the weather." }], + }, + }, + ], + }, + }, + }; + + const responseBody = await invokeAiTrigger(redactResponse, event); + assert.equal(responseBody.candidates, undefined); + }); + + it("should leave response untouched when response candidates array is empty", async () => { + const event = { + data: { + api: "google.cloud.aiplatform.v1beta1", + model: "gemini-1.5-flash", + response: { + candidates: [], + }, + }, + }; + + const responseBody = await invokeAiTrigger(redactResponse, event); + assert.equal(responseBody.candidates, undefined); + }); + + it("should gracefully handle undefined response", async () => { + const event = { + data: {}, + }; + + const responseBody = await invokeAiTrigger(redactResponse, event); + assert.equal(responseBody.candidates, undefined); + }); + + it("should safely handle candidate with missing content (e.g. SAFETY block)", async () => { + const event = { + data: { + api: "google.cloud.aiplatform.v1beta1", + model: "gemini-1.5-flash", + response: { + candidates: [ + { + finishReason: "SAFETY", + index: 0, + }, + ], + }, + }, + }; + + const responseBody = await invokeAiTrigger(redactResponse, event); + assert.equal(responseBody.candidates, undefined); + }); + + it("should safely handle candidate containing null or non-object parts", async () => { + const event = { + data: { + api: "google.cloud.aiplatform.v1beta1", + model: "gemini-1.5-flash", + response: { + candidates: [ + { + content: { + role: "model", + parts: [ + null, + undefined, + {}, + { text: "" }, + { text: "Your temporary code is sent to user@domain.com." }, + ], + }, + index: 0, + }, + ], + }, + }, + }; + + const responseBody = await invokeAiTrigger(redactResponse, event); + assert.ok(responseBody); + assert.equal( + responseBody.candidates[0].content.parts[4].text, + "Your temporary code is sent to [EMAIL_ADDRESS]." + ); + }); + + it("should expose the expected blockingTrigger endpoint configuration", () => { + const endpoint = redactResponse.__endpoint; + assert.ok(endpoint); + assert.equal(endpoint.platform, "gcfv2"); + assert.equal( + endpoint.blockingTrigger?.eventType, + "google.firebase.ailogic.v1.afterGenerate" + ); + }); + }); +}); diff --git a/Node/ai-logic-sensitive-data/functions/package.json b/Node/ai-logic-sensitive-data/functions/package.json new file mode 100644 index 000000000..eb3cb2073 --- /dev/null +++ b/Node/ai-logic-sensitive-data/functions/package.json @@ -0,0 +1,30 @@ +{ + "name": "ai-logic-sensitive-data", + "description": "Cloud Functions for Firebase (2nd Gen) sample using Cloud Sensitive Data Protection to redact sensitive data before and after AI Logic requests", + "main": "index.js", + "type": "module", + "engines": { + "node": "24" + }, + "scripts": { + "lint": "eslint .", + "test": "node --test index.test.js", + "serve": "firebase emulators:start --only functions", + "shell": "firebase functions:shell", + "start": "npm run shell", + "deploy": "firebase deploy --only functions", + "logs": "firebase functions:log", + "compile": "cp ../../../tsconfig.template.json ./tsconfig-compile.json && tsc --project tsconfig-compile.json" + }, + "dependencies": { + "@google-cloud/dlp": "^6.0.0", + "firebase-admin": "^14.2.0", + "firebase-functions": "^7.4.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "eslint": "^8.57.1", + "eslint-config-google": "^0.14.0" + }, + "private": true +} diff --git a/README.md b/README.md index d53679bc4..94e60e2eb 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,12 @@ Trigger a function based on a Firebase Alert, and send information about the ale Learn how to trigger a function based on an event sent by an extension +### Firebase AI Logic: Redact sensitive data with Cloud Sensitive Data Protection + +- [Node 2nd gen](/Node/ai-logic-sensitive-data/) + +Intercept and redact sensitive data (PII) before sending prompts to the Gemini API and after generating responses using Firebase AI Logic triggers and Cloud Sensitive Data Protection. + ### Unit testing - [Test with Jest](/Node/test-functions-jest/)