diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 5b7c5a4..2ff95e6 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -105,17 +105,5 @@ jobs: working-directory: example - run: npm audit signatures working-directory: example - - name: Smoke test example handler - working-directory: example - run: | - node <<'NODE' - const assert = require('node:assert/strict') - const { handler } = require('./index') - - handler({}, {}) - .then((result) => assert.equal(result, 'all good!')) - .catch((error) => { - console.error(error) - process.exitCode = 1 - }) - NODE + - name: Test example error reporting against a local endpoint + run: npm run test:example diff --git a/CHANGELOG.md b/CHANGELOG.md index baaacca..69a28d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- Updated vulnerable transitive development dependencies. +- Preserved existing full Lambda context capture and added regression tests for identity, client context, application-added fields, and overlapping invocations. +- Documented context capture boundaries and redaction with the Raygun client's `onBeforeSend` hook. + ## 0.0.9 - Maintenance-only release; no public API or runtime behavior changes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a3ecced..ca83d72 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,19 @@ The recommended IDE for working on this project is Visual Studio Code. To run tests, run `npm run test` or run all tests from VSCode. +To test the actual example's success and error paths against a local mock +Raygun endpoint, run these commands from the repository root: + +```sh +npm run prepare +npm --prefix example ci --ignore-scripts +npm run test:example +``` + +The example test uses a test API key and does not contact AWS or Raygun. +The main test suite also checks overlapping async and callback invocations +for breadcrumb and context isolation. + ### Code analysis To check the code, run `npm run eslint` and `npm run tseslint`. diff --git a/README.md b/README.md index 6bfc015..17b8651 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,11 @@ exports.handler = awsHandler({ client }, async function (event, context) { ### AWS Lambda function context in Custom Data -The `awsHandler` also adds automatically the function call context in the "Custom Data" payload of the error report. +The `awsHandler` adds the full function call context to the error report's +"Custom Data" and the automatic "Running AWS Function" breadcrumb. The example +below shows common metadata fields, not an exhaustive list. Serializable +`identity`, `clientContext`, and application-added fields are also included when +present. Context changes made by your handler are reflected in the report. This payload can be found in the "Custom" tab in the Raygun Crash Reporting error report page. @@ -124,6 +128,31 @@ context: { } ``` +### Protecting sensitive data + +The wrapper does not automatically capture the event, but it does capture the +full context. Review context fields such as `identity`, `clientContext`, and +application-added data for credentials or personal information. Context also +contains infrastructure identifiers, including the function ARN and log names. +Avoid adding sensitive data to custom data, breadcrumbs, or error messages. + +The Raygun client's `filters` option applies only to request headers, query +parameters, and parsed request bodies. It does not redact Lambda context, +custom data, breadcrumbs, or error messages. Use the client's `onBeforeSend` hook +to remove or redact these sections before transport. For example, to omit all +custom data and breadcrumbs: + +```js +client.onBeforeSend((payload) => { + delete payload.details.userCustomData; + delete payload.details.breadcrumbs; + return payload; +}); +``` + +See the Raygun client's [sensitive-data guidance](https://github.com/MindscapeHQ/raygun4node#protecting-sensitive-data) +for request filtering and logging precautions. + ## Release History [View the changelog here](CHANGELOG.md) diff --git a/package-lock.json b/package-lock.json index 6b2964e..6953ca9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2207,9 +2207,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3161,9 +3161,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "dev": true, "funding": [ { @@ -3525,9 +3525,9 @@ "license": "MIT" }, "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", "dev": true, "license": "BSD-2-Clause" }, @@ -3746,9 +3746,9 @@ } }, "node_modules/ip-address": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", - "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz", + "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==", "dev": true, "license": "MIT", "engines": { @@ -4912,9 +4912,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4923,6 +4923,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/punycode": { @@ -6080,9 +6084,9 @@ } }, "node_modules/undici": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", - "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 6e0e87a..65d4174 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "//prepare": "prepare project for distribution", "prepare": "tsc", "//test": "runs tests on main project", - "test": "tap --disable-coverage test/*_test.js" + "test": "tap --disable-coverage test/*_test.js", + "test:example": "tap --disable-coverage test/example_integration.js" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/test/example_integration.js b/test/example_integration.js new file mode 100644 index 0000000..ad17376 --- /dev/null +++ b/test/example_integration.js @@ -0,0 +1,71 @@ +const test = require("tap").test; +const { Client } = require("raygun"); +const { makeClientWithMockServer } = require("./utils"); + +test("actual Lambda sample reports its error to a local endpoint", async (t) => { + const environment = await makeClientWithMockServer(); + t.teardown(() => environment.stop()); + const init = Client.prototype.init; + let sampleClient; + Client.prototype.init = function (options) { + sampleClient = this; + return init.call(this, { + ...options, + apiKey: "TEST_API_KEY", + host: "localhost", + port: environment.address.port, + useSSL: false, + }); + }; + let handler; + try { + ({ handler } = require("../example/index")); + } finally { + Client.prototype.init = init; + } + t.teardown(() => sampleClient.stop()); + + t.equal(await handler({}, { functionName: "sample-success" }), "all good!"); + t.equal(environment.server.entries.length, 0); + const nextRequest = environment.nextRequest(); + await t.rejects( + handler( + { error: true, token: "event-only-sentinel" }, + { + functionName: "sample-failure", + awsRequestId: "sample-request", + identity: { cognitoIdentityId: "sample-identity" }, + clientContext: { custom: { client: "sample-client" } }, + applicationField: "sample-application-field", + }, + ), + /It's an AWS error!/, + ); + const message = await nextRequest; + t.equal(environment.server.entries.length, 1); + t.equal(message.details.error.message, "It's an AWS error!"); + t.same(message.details.tags, ["AWS Handler"]); + t.same(message.details.userCustomData.context, { + functionName: "sample-failure", + awsRequestId: "sample-request", + identity: { cognitoIdentityId: "sample-identity" }, + clientContext: { custom: { client: "sample-client" } }, + applicationField: "sample-application-field", + }); + t.same(message.details.breadcrumbs[0].customData, { + functionName: "sample-failure", + awsRequestId: "sample-request", + identity: { cognitoIdentityId: "sample-identity" }, + clientContext: { custom: { client: "sample-client" } }, + applicationField: "sample-application-field", + }); + t.same( + message.details.breadcrumbs.map((crumb) => crumb.message), + [ + "Running AWS Function: sample-failure", + "breadcrumb on event received", + "event has error data!", + ], + ); + t.notMatch(JSON.stringify(message), "event-only-sentinel"); +}); diff --git a/test/raygun_aws_test.js b/test/raygun_aws_test.js index a8f7442..1867932 100644 --- a/test/raygun_aws_test.js +++ b/test/raygun_aws_test.js @@ -162,3 +162,156 @@ test("include scoped breadcrumbs", async function (t) { // custom breadcrumb t.equal(message.details.breadcrumbs[1].message, "custom breadcrumb"); }); + +for (const callbackStyle of [false, true]) { + test(`preserve full context payload (${callbackStyle ? "callback" : "async"})`, async (t) => { + const environment = await makeClientWithMockServer(); + t.teardown(() => environment.stop()); + const metadata = { + callbackWaitsForEmptyEventLoop: false, + functionVersion: "42", + functionName: "privacy-test", + memoryLimitInMB: "256", + logGroupName: "/aws/lambda/privacy-test", + logStreamName: "test-stream", + invokedFunctionArn: + "arn:aws:lambda:ap-southeast-2:123456789012:function:privacy-test", + awsRequestId: "request-123", + }; + const secret = "private-context-sentinel"; + const context = { + ...metadata, + identity: { cognitoIdentityId: secret }, + clientContext: { custom: { token: secret } }, + password: secret, + }; + const error = new Error("handler failed"); + const checkAndMutate = (event, receivedContext) => { + t.equal(receivedContext, context, "handler receives original context"); + t.equal( + event.token, + "event-only-sentinel", + "handler receives original event", + ); + receivedContext.functionName = "updated-function"; + receivedContext.extra = "added-by-handler"; + }; + let handler; + if (callbackStyle) { + handler = (event, receivedContext, callback) => { + checkAndMutate(event, receivedContext); + callback(error); + }; + } else { + handler = async (event, receivedContext) => { + checkAndMutate(event, receivedContext); + throw error; + }; + } + const lambda = awsHandler({ client: environment.client }, handler); + const nextRequest = environment.nextRequest(); + try { + await lambda({ token: "event-only-sentinel" }, context); + t.fail("handler error must be rethrown"); + } catch (caught) { + t.equal(caught, error); + } + const message = await nextRequest; + const expectedContext = { + ...metadata, + functionName: "updated-function", + identity: { cognitoIdentityId: secret }, + clientContext: { custom: { token: secret } }, + password: secret, + extra: "added-by-handler", + }; + t.same(message.details.userCustomData.context, expectedContext); + t.same(message.details.breadcrumbs[0].customData, expectedContext); + t.equal( + message.details.breadcrumbs[0].message, + "Running AWS Function: privacy-test", + ); + t.notMatch(JSON.stringify(message), "event-only-sentinel"); + }); +} + +for (const callbackStyle of [false, true]) { + test(`overlapping invocations stay isolated (${callbackStyle ? "callback" : "async"})`, async (t) => { + const environment = await makeClientWithMockServer(); + t.teardown(() => environment.stop()); + const { client } = environment; + const release = {}; + const gates = { + A: new Promise((resolve) => { + release.A = resolve; + }), + B: new Promise((resolve) => { + release.B = resolve; + }), + }; + const errors = { A: new Error("failure-A"), B: new Error("failure-B") }; + const work = async (event) => { + client.addBreadcrumb(`start-${event.id}`); + await gates[event.id]; + client.addBreadcrumb(`finish-${event.id}`); + throw errors[event.id]; + }; + let handler; + if (callbackStyle) { + handler = (event, context, callback) => { + work(event).catch(callback); + }; + } else { + handler = work; + } + const lambda = awsHandler({ client }, handler); + const first = lambda( + { id: "A" }, + { + functionName: "function-A", + awsRequestId: "request-A", + identity: { cognitoIdentityId: "identity-A" }, + clientContext: { custom: { invocation: "A" } }, + applicationField: "application-A", + }, + ).catch((error) => error); + const second = lambda( + { id: "B" }, + { + functionName: "function-B", + awsRequestId: "request-B", + identity: { cognitoIdentityId: "identity-B" }, + clientContext: { custom: { invocation: "B" } }, + applicationField: "application-B", + }, + ).catch((error) => error); + + release.B(); + t.equal(await second, errors.B); + t.equal( + environment.server.entries.length, + 1, + "B completes while A is suspended", + ); + release.A(); + t.equal(await first, errors.A); + t.equal(environment.server.entries.length, 2); + for (const [index, id] of ["B", "A"].entries()) { + const message = environment.server.entries[index]; + const context = { + functionName: `function-${id}`, + awsRequestId: `request-${id}`, + identity: { cognitoIdentityId: `identity-${id}` }, + clientContext: { custom: { invocation: id } }, + applicationField: `application-${id}`, + }; + t.equal(message.details.error.message, `failure-${id}`); + t.same(message.details.userCustomData.context, context); + t.same(message.details.breadcrumbs[0].customData, context); + t.same( + message.details.breadcrumbs.map((crumb) => crumb.message), + [`Running AWS Function: function-${id}`, `start-${id}`, `finish-${id}`], + ); + } + }); +}