@vtex/api 7.6.0 → 7.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/lib/clients/infra/VBase.d.ts +6 -0
- package/lib/clients/infra/VBase.js +6 -0
- package/lib/service/worker/index.js +3 -1
- package/lib/service/worker/listeners.js +5 -3
- package/lib/service/worker/runtime/graphql/middlewares/error.js +8 -2
- package/lib/service/worker/runtime/http/middlewares/error.js +20 -2
- package/lib/service/worker/runtime/http/middlewares/sanitizeResponse.d.ts +37 -0
- package/lib/service/worker/runtime/http/middlewares/sanitizeResponse.js +143 -0
- package/lib/utils/responseSanitizer.d.ts +52 -0
- package/lib/utils/responseSanitizer.js +354 -0
- package/lib/utils/stringRedactor.d.ts +19 -0
- package/lib/utils/stringRedactor.js +94 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
|
|
6
6
|
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [7.6.1]
|
|
9
|
+
|
|
10
|
+
### Security
|
|
11
|
+
|
|
12
|
+
- Error objects assigned to the response body no longer expose the request
|
|
13
|
+
configuration, credentials, connection agents or, outside link mode, the
|
|
14
|
+
stack. If the response cannot be sanitized it is withheld. A body the walk cannot
|
|
15
|
+
inspect completely is sent as it is, and the limit is reported.
|
|
16
|
+
- String response bodies built from an error (`` `failed: ${JSON.stringify(error)}` ``) no longer
|
|
17
|
+
expose its credentials: the request's tokens, the value of credential headers (`authorization`,
|
|
18
|
+
`proxy-authorization`, `VtexIdclientAutCookie`, `x-vtex-api-*`), the VTEX ID cookie and `Bearer`
|
|
19
|
+
tokens are replaced with `[Redacted]`. The same names are replaced when the request is
|
|
20
|
+
embedded as header lines (`Cookie`, `Set-Cookie`, `X-Vtex-Session`), not only as JSON keys.
|
|
21
|
+
- The body the runtime builds from a thrown value goes through the same sanitizer. An error that is
|
|
22
|
+
a property of the thrown one (`originalError`), or sits in a thrown object, no longer exposes its
|
|
23
|
+
request configuration, request or stack, and the upstream response headers are no longer
|
|
24
|
+
included. Linked apps keep the stack but no longer get the configuration in the body.
|
|
25
|
+
- Errors printed by the runtime no longer include the request configuration.
|
|
26
|
+
- GraphQL error responses no longer include the stack outside link mode.
|
|
27
|
+
|
|
8
28
|
## [7.6.0]
|
|
9
29
|
|
|
10
30
|
### Added
|
|
@@ -11,6 +11,12 @@ export declare class VBase extends InfraClient {
|
|
|
11
11
|
constructor(context: IOContext, options?: InstanceOptions);
|
|
12
12
|
getBucket: (bucket: string, tracingConfig?: RequestTracingConfig) => Promise<BucketMetadata>;
|
|
13
13
|
resetBucket: (bucket: string, tracingConfig?: RequestTracingConfig) => Promise<IOResponse<void>>;
|
|
14
|
+
/**
|
|
15
|
+
* @deprecated The VBase list-files route is no longer maintained by VTEX, and only a few internal
|
|
16
|
+
* applications have access to due to needed backwards compatibility, reason why this interface
|
|
17
|
+
* remains in the code. Using this method will result in 404 by any caller, regardless of account
|
|
18
|
+
* and workspace.
|
|
19
|
+
*/
|
|
14
20
|
listFiles: (bucket: string, opts?: string | VBaseOptions, tracingConfig?: RequestTracingConfig) => Promise<BucketFileList>;
|
|
15
21
|
getFile: (bucket: string, path: string, tracingConfig?: RequestTracingConfig) => Promise<{
|
|
16
22
|
data: Buffer;
|
|
@@ -38,6 +38,12 @@ class VBase extends InfraClient_1.InfraClient {
|
|
|
38
38
|
...tracingConfig === null || tracingConfig === void 0 ? void 0 : tracingConfig.tracing,
|
|
39
39
|
} });
|
|
40
40
|
};
|
|
41
|
+
/**
|
|
42
|
+
* @deprecated The VBase list-files route is no longer maintained by VTEX, and only a few internal
|
|
43
|
+
* applications have access to due to needed backwards compatibility, reason why this interface
|
|
44
|
+
* remains in the code. Using this method will result in 404 by any caller, regardless of account
|
|
45
|
+
* and workspace.
|
|
46
|
+
*/
|
|
41
47
|
this.listFiles = (bucket, opts, tracingConfig) => {
|
|
42
48
|
let params = {};
|
|
43
49
|
if (isVBaseOptions(opts)) {
|
|
@@ -28,6 +28,7 @@ const graphql_1 = require("./runtime/graphql");
|
|
|
28
28
|
const http_2 = require("./runtime/http");
|
|
29
29
|
const error_1 = require("./runtime/http/middlewares/error");
|
|
30
30
|
const rateLimit_1 = require("./runtime/http/middlewares/rateLimit");
|
|
31
|
+
const sanitizeResponse_1 = require("./runtime/http/middlewares/sanitizeResponse");
|
|
31
32
|
const router_2 = require("./runtime/http/router");
|
|
32
33
|
const routes_1 = require("./runtime/http/routes");
|
|
33
34
|
const statusTrack_1 = require("./runtime/statusTrack");
|
|
@@ -178,7 +179,8 @@ const startWorker = (serviceJSON) => {
|
|
|
178
179
|
.use((0, middlewares_1.addMetricsLoggerMiddleware)())
|
|
179
180
|
.use((0, rateLimit_1.concurrentRateLimiter)((_a = serviceJSON === null || serviceJSON === void 0 ? void 0 : serviceJSON.rateLimitPerReplica) === null || _a === void 0 ? void 0 : _a.concurrent))
|
|
180
181
|
.use((0, koa_compress_1.default)())
|
|
181
|
-
.use(middlewares_1.recorderMiddleware)
|
|
182
|
+
.use(middlewares_1.recorderMiddleware)
|
|
183
|
+
.use(sanitizeResponse_1.sanitizeResponse);
|
|
182
184
|
const service = (0, loaders_1.getService)();
|
|
183
185
|
const { config: { clients } } = service;
|
|
184
186
|
if (clients) {
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.addProcessListeners = exports.logger = void 0;
|
|
4
4
|
const os_1 = require("os");
|
|
5
5
|
const RequestCancelledError_1 = require("../../errors/RequestCancelledError");
|
|
6
|
+
const responseSanitizer_1 = require("../../utils/responseSanitizer");
|
|
6
7
|
const logger_1 = require("../logger");
|
|
7
8
|
exports.logger = new logger_1.Logger({ account: 'unhandled', workspace: 'unhandled', requestId: 'unhandled', operationId: 'unhandled', production: process.env.VTEX_PRODUCTION === 'true' });
|
|
8
9
|
let watched;
|
|
@@ -15,18 +16,19 @@ const addProcessListeners = () => {
|
|
|
15
16
|
return;
|
|
16
17
|
}
|
|
17
18
|
watched = process.on('uncaughtException', (err) => {
|
|
18
|
-
console.error('uncaughtException', err);
|
|
19
|
+
console.error('uncaughtException', (0, responseSanitizer_1.toLoggableError)(err));
|
|
19
20
|
if (err && exports.logger) {
|
|
20
21
|
err.type = 'uncaughtException';
|
|
21
22
|
exports.logger.error(err);
|
|
22
23
|
}
|
|
23
24
|
process.exit(420);
|
|
24
25
|
});
|
|
25
|
-
process.on('unhandledRejection', (reason
|
|
26
|
+
process.on('unhandledRejection', (reason) => {
|
|
26
27
|
if (reason instanceof RequestCancelledError_1.RequestCancelledError) {
|
|
27
28
|
return;
|
|
28
29
|
}
|
|
29
|
-
|
|
30
|
+
// The promise is not printed: inspecting it shows the rejection reason again, unsanitized.
|
|
31
|
+
console.error('unhandledRejection', (0, responseSanitizer_1.toLoggableError)(reason));
|
|
30
32
|
if (reason && exports.logger) {
|
|
31
33
|
reason.type = 'unhandledRejection';
|
|
32
34
|
exports.logger.error(reason);
|
|
@@ -36,8 +36,14 @@ const createLogErrorToSplunk = (vtex) => (err) => {
|
|
|
36
36
|
logger.log(log, level);
|
|
37
37
|
}
|
|
38
38
|
finally {
|
|
39
|
-
if (!constants_1.LINKED
|
|
40
|
-
|
|
39
|
+
if (!constants_1.LINKED) {
|
|
40
|
+
// Already logged above. The response must not carry them.
|
|
41
|
+
if ((_f = (_e = err === null || err === void 0 ? void 0 : err.extensions) === null || _e === void 0 ? void 0 : _e.exception) === null || _f === void 0 ? void 0 : _f.sensitive) {
|
|
42
|
+
delete err.extensions.exception.sensitive;
|
|
43
|
+
}
|
|
44
|
+
if (err) {
|
|
45
|
+
delete err.stack;
|
|
46
|
+
}
|
|
41
47
|
}
|
|
42
48
|
}
|
|
43
49
|
};
|
|
@@ -5,7 +5,9 @@ const constants_1 = require("../../../../../constants");
|
|
|
5
5
|
const RequestCancelledError_1 = require("../../../../../errors/RequestCancelledError");
|
|
6
6
|
const TooManyRequestsError_1 = require("../../../../../errors/TooManyRequestsError");
|
|
7
7
|
const error_1 = require("../../../../../utils/error");
|
|
8
|
+
const responseSanitizer_1 = require("../../../../../utils/responseSanitizer");
|
|
8
9
|
const logger_1 = require("../../../../logger");
|
|
10
|
+
const sanitizeResponse_1 = require("./sanitizeResponse");
|
|
9
11
|
const CACHE_CONTROL_HEADER = 'cache-control';
|
|
10
12
|
const META_HEADER = 'x-vtex-meta';
|
|
11
13
|
const ETAG_HEADER = 'etag';
|
|
@@ -45,6 +47,19 @@ const logAndRemoveSensitiveData = (ctx, err) => {
|
|
|
45
47
|
});
|
|
46
48
|
}
|
|
47
49
|
};
|
|
50
|
+
// What is sent to the client for a thrown value. It is built with the sanitizer that covers the
|
|
51
|
+
// bodies the app sets, so both paths expose the same: `cleanError` alone removes credentials only
|
|
52
|
+
// at the top level, and by a short list of header names, so an error that is a property of the
|
|
53
|
+
// thrown value (`originalError`) kept its config, request and stack.
|
|
54
|
+
const buildResponseBody = (thrown) => {
|
|
55
|
+
try {
|
|
56
|
+
return (0, error_1.cleanError)((0, responseSanitizer_1.sanitizeErrors)(thrown, { linked: constants_1.LINKED }).value);
|
|
57
|
+
}
|
|
58
|
+
catch (sanitizeError) {
|
|
59
|
+
console.error('[node-vtex-api error] the thrown value could not be sanitized', sanitizeError === null || sanitizeError === void 0 ? void 0 : sanitizeError.name);
|
|
60
|
+
return sanitizeResponse_1.WITHHELD_BODY;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
48
63
|
async function error(ctx, next) {
|
|
49
64
|
var _a;
|
|
50
65
|
try {
|
|
@@ -59,7 +74,8 @@ async function error(ctx, next) {
|
|
|
59
74
|
ctx.status = TooManyRequestsError_1.tooManyRequestsStatus;
|
|
60
75
|
return;
|
|
61
76
|
}
|
|
62
|
-
|
|
77
|
+
// The raw error carries the request config, whose headers include credentials.
|
|
78
|
+
console.error('[node-vtex-api error]', (0, responseSanitizer_1.toLoggableError)(e));
|
|
63
79
|
const err = (0, error_1.cleanError)(e);
|
|
64
80
|
// Add response
|
|
65
81
|
ctx.status = (e === null || e === void 0 ? void 0 : e.status) && e.status >= 400 && e.status <= 599
|
|
@@ -78,8 +94,10 @@ async function error(ctx, next) {
|
|
|
78
94
|
else {
|
|
79
95
|
ctx.set(CACHE_CONTROL_HEADER, `no-cache, no-store`);
|
|
80
96
|
}
|
|
97
|
+
// `err` is what is logged, which keeps the config and the request to debug with. It is not the
|
|
98
|
+
// body: the sanitized value is.
|
|
81
99
|
logAndRemoveSensitiveData(ctx, err);
|
|
82
|
-
ctx.body = ctx.body ||
|
|
100
|
+
ctx.body = ctx.body || buildResponseBody(e);
|
|
83
101
|
}
|
|
84
102
|
}
|
|
85
103
|
exports.error = error;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ServiceContext } from '../../typings';
|
|
2
|
+
/**
|
|
3
|
+
* Setting this environment variable to `off` disables the sanitizer.
|
|
4
|
+
*/
|
|
5
|
+
export declare const RESPONSE_SANITIZER_SWITCH = "VTEX_IO_RESPONSE_SANITIZER";
|
|
6
|
+
/**
|
|
7
|
+
* Sent when the body could not be sanitized: it is not known whether it is safe, so it is withheld.
|
|
8
|
+
*/
|
|
9
|
+
export declare const WITHHELD_BODY: {
|
|
10
|
+
code: string;
|
|
11
|
+
message: string;
|
|
12
|
+
};
|
|
13
|
+
export declare enum SanitizerReason {
|
|
14
|
+
ErrorRemoved = "error_object_removed",
|
|
15
|
+
SecretRemoved = "secret_removed",
|
|
16
|
+
LimitReached = "limit_reached",
|
|
17
|
+
Failed = "sanitizer_failed"
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Removes error objects (`Error`, `AxiosError`) from the response body, so apps that write a caught
|
|
21
|
+
* error to `ctx.body` do not expose the request configuration (credentials), the stack and the
|
|
22
|
+
* connection internals to the caller.
|
|
23
|
+
*
|
|
24
|
+
* String bodies are also covered, for errors that were turned into text: the credentials in them
|
|
25
|
+
* (the ones of the request, the credential headers, `Bearer` tokens) are replaced.
|
|
26
|
+
*
|
|
27
|
+
* It must be registered after `compress()`, which serializes the body: it needs to see the object,
|
|
28
|
+
* and to change it before it is serialized.
|
|
29
|
+
*
|
|
30
|
+
* The body is also sanitized when the downstream handlers throw after having set it, as the error
|
|
31
|
+
* middleware keeps an existing body. If the sanitizer itself fails the body is withheld. If it
|
|
32
|
+
* cannot inspect the whole body, the response is sent and the limit is reported. It never throws.
|
|
33
|
+
*
|
|
34
|
+
* It is a backstop for apps that return errors by mistake. It is not a boundary against an app that
|
|
35
|
+
* wants to disclose its own credentials, which has them.
|
|
36
|
+
*/
|
|
37
|
+
export declare function sanitizeResponse(ctx: ServiceContext, next: () => Promise<void>): Promise<void>;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sanitizeResponse = exports.SanitizerReason = exports.WITHHELD_BODY = exports.RESPONSE_SANITIZER_SWITCH = void 0;
|
|
4
|
+
const constants_1 = require("../../../../../constants");
|
|
5
|
+
const responseSanitizer_1 = require("../../../../../utils/responseSanitizer");
|
|
6
|
+
const stringRedactor_1 = require("../../../../../utils/stringRedactor");
|
|
7
|
+
/**
|
|
8
|
+
* Setting this environment variable to `off` disables the sanitizer.
|
|
9
|
+
*/
|
|
10
|
+
exports.RESPONSE_SANITIZER_SWITCH = 'VTEX_IO_RESPONSE_SANITIZER';
|
|
11
|
+
/**
|
|
12
|
+
* Sent when the body could not be sanitized: it is not known whether it is safe, so it is withheld.
|
|
13
|
+
*/
|
|
14
|
+
exports.WITHHELD_BODY = {
|
|
15
|
+
code: 'E_RESPONSE_WITHHELD',
|
|
16
|
+
message: 'The response could not be safely returned.',
|
|
17
|
+
};
|
|
18
|
+
var SanitizerReason;
|
|
19
|
+
(function (SanitizerReason) {
|
|
20
|
+
SanitizerReason["ErrorRemoved"] = "error_object_removed";
|
|
21
|
+
SanitizerReason["SecretRemoved"] = "secret_removed";
|
|
22
|
+
SanitizerReason["LimitReached"] = "limit_reached";
|
|
23
|
+
SanitizerReason["Failed"] = "sanitizer_failed";
|
|
24
|
+
})(SanitizerReason = exports.SanitizerReason || (exports.SanitizerReason = {}));
|
|
25
|
+
// Only the class of the error is reported: its message may carry what is being kept out.
|
|
26
|
+
const errorName = (err) => err === null || err === void 0 ? void 0 : err.name;
|
|
27
|
+
const warn = (ctx, reason, fields = {}) => {
|
|
28
|
+
var _a, _b, _c;
|
|
29
|
+
const payload = {
|
|
30
|
+
message: `response-sanitizer: ${reason}`,
|
|
31
|
+
reason,
|
|
32
|
+
routeId: (_b = (_a = ctx.vtex) === null || _a === void 0 ? void 0 : _a.route) === null || _b === void 0 ? void 0 : _b.id,
|
|
33
|
+
...fields,
|
|
34
|
+
};
|
|
35
|
+
try {
|
|
36
|
+
if ((_c = ctx.vtex) === null || _c === void 0 ? void 0 : _c.logger) {
|
|
37
|
+
ctx.vtex.logger.warn(payload);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
console.warn(JSON.stringify(payload));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
// Logging must never break the response: the event goes to the process output instead.
|
|
45
|
+
console.warn(JSON.stringify({ ...payload, loggerErrorName: errorName(err) }));
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const withholdBody = (ctx) => {
|
|
49
|
+
try {
|
|
50
|
+
if (ctx.status < 400) {
|
|
51
|
+
ctx.status = 500;
|
|
52
|
+
}
|
|
53
|
+
ctx.body = exports.WITHHELD_BODY;
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
// There is nothing else that can be done safely: as a last resort the response is emptied.
|
|
57
|
+
ctx.body = null;
|
|
58
|
+
warn(ctx, SanitizerReason.Failed, { withholdErrorName: errorName(err) });
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
// A string body can hold an error that was turned into text (`JSON.stringify(error)`), which has
|
|
62
|
+
// no error object left to find. What can be told apart is the credential itself.
|
|
63
|
+
const redactStringBody = (ctx, body) => {
|
|
64
|
+
var _a;
|
|
65
|
+
const { authToken, adminUserAuthToken, storeUserAuthToken } = (_a = ctx.vtex) !== null && _a !== void 0 ? _a : {};
|
|
66
|
+
const { value, redacted } = (0, stringRedactor_1.redactSecrets)(body, [authToken, adminUserAuthToken, storeUserAuthToken]);
|
|
67
|
+
if (redacted === 0) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// Koa keeps the type already set when the body is a string, and updates the length.
|
|
71
|
+
ctx.body = value;
|
|
72
|
+
warn(ctx, SanitizerReason.SecretRemoved, { redactions: redacted });
|
|
73
|
+
};
|
|
74
|
+
const sanitizeCtxBody = (ctx) => {
|
|
75
|
+
try {
|
|
76
|
+
if (process.env[exports.RESPONSE_SANITIZER_SWITCH] === 'off') {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const body = ctx.body;
|
|
80
|
+
if (typeof body === 'string') {
|
|
81
|
+
redactStringBody(ctx, body);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (body === null || typeof body !== 'object' || Buffer.isBuffer(body) || typeof body.pipe === 'function') {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// Most bodies have no error. A scan decides that without the copying walk; the walk runs only
|
|
88
|
+
// once an error has been found, or the scan could not finish.
|
|
89
|
+
const scan = (0, responseSanitizer_1.scanForErrors)(body);
|
|
90
|
+
if (scan === 'clean') {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const { value, replaced, truncated } = (0, responseSanitizer_1.sanitizeErrors)(body, { linked: constants_1.LINKED });
|
|
94
|
+
if (truncated) {
|
|
95
|
+
// What is beyond the limit is left as it is, outside an error. The response is still sent:
|
|
96
|
+
// withholding it would break the large legitimate responses. It is reported to be measured.
|
|
97
|
+
warn(ctx, SanitizerReason.LimitReached);
|
|
98
|
+
}
|
|
99
|
+
if (value === body) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// Koa sets `Content-Type: application/json` whenever an object is assigned to the body, which
|
|
103
|
+
// would override a type chosen by the app.
|
|
104
|
+
const contentType = ctx.response.get('Content-Type');
|
|
105
|
+
ctx.body = value;
|
|
106
|
+
if (contentType) {
|
|
107
|
+
ctx.set('Content-Type', contentType);
|
|
108
|
+
}
|
|
109
|
+
warn(ctx, SanitizerReason.ErrorRemoved, { errors: replaced });
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
// Fails closed: whatever made it fail may also be hiding a credential.
|
|
113
|
+
withholdBody(ctx);
|
|
114
|
+
warn(ctx, SanitizerReason.Failed, { errorName: errorName(err) });
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* Removes error objects (`Error`, `AxiosError`) from the response body, so apps that write a caught
|
|
119
|
+
* error to `ctx.body` do not expose the request configuration (credentials), the stack and the
|
|
120
|
+
* connection internals to the caller.
|
|
121
|
+
*
|
|
122
|
+
* String bodies are also covered, for errors that were turned into text: the credentials in them
|
|
123
|
+
* (the ones of the request, the credential headers, `Bearer` tokens) are replaced.
|
|
124
|
+
*
|
|
125
|
+
* It must be registered after `compress()`, which serializes the body: it needs to see the object,
|
|
126
|
+
* and to change it before it is serialized.
|
|
127
|
+
*
|
|
128
|
+
* The body is also sanitized when the downstream handlers throw after having set it, as the error
|
|
129
|
+
* middleware keeps an existing body. If the sanitizer itself fails the body is withheld. If it
|
|
130
|
+
* cannot inspect the whole body, the response is sent and the limit is reported. It never throws.
|
|
131
|
+
*
|
|
132
|
+
* It is a backstop for apps that return errors by mistake. It is not a boundary against an app that
|
|
133
|
+
* wants to disclose its own credentials, which has them.
|
|
134
|
+
*/
|
|
135
|
+
async function sanitizeResponse(ctx, next) {
|
|
136
|
+
try {
|
|
137
|
+
await next();
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
sanitizeCtxBody(ctx);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
exports.sanitizeResponse = sanitizeResponse;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface SanitizeErrorsOptions {
|
|
2
|
+
/**
|
|
3
|
+
* When true (linked apps in development, and logs) the stack is kept to help debugging.
|
|
4
|
+
*/
|
|
5
|
+
linked: boolean;
|
|
6
|
+
maxDepth?: number;
|
|
7
|
+
maxNodes?: number;
|
|
8
|
+
/**
|
|
9
|
+
* Total length of the arrays and objects visited, so that the cost of a long flat array is
|
|
10
|
+
* bounded the same as the number of containers.
|
|
11
|
+
*/
|
|
12
|
+
maxValues?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface SanitizeErrorsResult<T> {
|
|
15
|
+
/**
|
|
16
|
+
* The same reference received when nothing had to change.
|
|
17
|
+
*/
|
|
18
|
+
value: T;
|
|
19
|
+
/**
|
|
20
|
+
* Number of error instances replaced by their safe representation.
|
|
21
|
+
*/
|
|
22
|
+
replaced: number;
|
|
23
|
+
/**
|
|
24
|
+
* True when a limit (depth, containers, values) stopped the walk early. Outside an error the part
|
|
25
|
+
* not visited is left as it is. Inside an error it is replaced by a marker.
|
|
26
|
+
*/
|
|
27
|
+
truncated: boolean;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Finds `Error` instances (including `AxiosError`) in a response body, at any depth of plain
|
|
31
|
+
* objects and arrays, and replaces each one by a safe representation: the message, name, code and
|
|
32
|
+
* status are kept, while the request config, the Node request, the agents, the upstream headers
|
|
33
|
+
* and, unless linked, the stack are removed.
|
|
34
|
+
*
|
|
35
|
+
* Strings, buffers, streams and class instances other than errors are returned untouched.
|
|
36
|
+
*/
|
|
37
|
+
export declare const sanitizeErrors: <T>(value: T, options: SanitizeErrorsOptions) => SanitizeErrorsResult<T>;
|
|
38
|
+
export type ErrorScan = 'clean' | 'has-error' | 'truncated';
|
|
39
|
+
/**
|
|
40
|
+
* Says whether `value` contains an `Error`, without copying it. The copying walk is only needed
|
|
41
|
+
* when this returns `has-error`. `truncated` means a budget (depth, containers, or the values they
|
|
42
|
+
* hold) was spent before the value was fully inspected, so whatever was not visited must not be sent.
|
|
43
|
+
*/
|
|
44
|
+
export declare const scanForErrors: (value: object, options?: Pick<SanitizeErrorsOptions, 'maxDepth' | 'maxNodes' | 'maxValues'>) => ErrorScan;
|
|
45
|
+
/**
|
|
46
|
+
* The same as `sanitizeErrors` for what is written to the process output, where the stack is
|
|
47
|
+
* useful and kept. A thrown `Error` stays an error, so it is printed as one (stack, then its
|
|
48
|
+
* properties), without the request config, the request and the agents.
|
|
49
|
+
*
|
|
50
|
+
* It never throws.
|
|
51
|
+
*/
|
|
52
|
+
export declare const toLoggableError: (value: unknown) => unknown;
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.toLoggableError = exports.scanForErrors = exports.sanitizeErrors = void 0;
|
|
4
|
+
const error_1 = require("./error");
|
|
5
|
+
/**
|
|
6
|
+
* Properties of an error that hold HTTP-client internals: the request config (headers carry
|
|
7
|
+
* credentials), the Node request object, and the connection agents. They are never serialized.
|
|
8
|
+
* `response` is rebuilt separately, so that its headers and request are left out too.
|
|
9
|
+
*/
|
|
10
|
+
const INTERNAL_ERROR_KEYS = new Set(['config', 'request', 'httpAgent', 'httpsAgent', 'response']);
|
|
11
|
+
const DEFAULT_MAX_DEPTH = 32;
|
|
12
|
+
const DEFAULT_MAX_NODES = 200000;
|
|
13
|
+
// What the containers hold, counted by their length. About 35 ms to go through on a laptop, which
|
|
14
|
+
// is far past a legitimate response.
|
|
15
|
+
const DEFAULT_MAX_VALUES = 5000000;
|
|
16
|
+
const TRUNCATED = '[Truncated]';
|
|
17
|
+
const CIRCULAR = '[Circular]';
|
|
18
|
+
// `instanceof` is not enough for errors that come from another realm (a `vm` context), which
|
|
19
|
+
// `Object.prototype.toString` still identifies.
|
|
20
|
+
const isError = (node) => node instanceof Error || Object.prototype.toString.call(node) === '[object Error]';
|
|
21
|
+
// Buffers and typed arrays, which have one entry per byte when walked as objects.
|
|
22
|
+
const isBinary = (node) => Buffer.isBuffer(node) || ArrayBuffer.isView(node) || node instanceof ArrayBuffer;
|
|
23
|
+
const isPlainObject = (node) => {
|
|
24
|
+
const proto = Object.getPrototypeOf(node);
|
|
25
|
+
return proto === Object.prototype || proto === null;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* What is shown instead of a value that is kept out of an error: binary data, streams and
|
|
29
|
+
* instances of classes, which can hold large or circular graphs (sockets, agents, handles).
|
|
30
|
+
*/
|
|
31
|
+
const describeOpaque = (node) => {
|
|
32
|
+
var _a;
|
|
33
|
+
if (node instanceof Date) {
|
|
34
|
+
return Number.isNaN(node.getTime()) ? '[Invalid Date]' : node.toISOString();
|
|
35
|
+
}
|
|
36
|
+
const name = ((_a = node === null || node === void 0 ? void 0 : node.constructor) === null || _a === void 0 ? void 0 : _a.name) || 'Object';
|
|
37
|
+
if (isBinary(node)) {
|
|
38
|
+
return `[${name} ${node.byteLength} bytes]`;
|
|
39
|
+
}
|
|
40
|
+
return `[${name}]`;
|
|
41
|
+
};
|
|
42
|
+
class ErrorSanitizer {
|
|
43
|
+
constructor({ linked, maxDepth, maxNodes, maxValues }) {
|
|
44
|
+
this.replaced = 0;
|
|
45
|
+
this.truncated = false;
|
|
46
|
+
this.nodes = 0;
|
|
47
|
+
this.values = 0;
|
|
48
|
+
this.insideError = 0;
|
|
49
|
+
// The containers being walked, from the root. At most `maxDepth` + 1 of them, so searching it is
|
|
50
|
+
// cheaper than maintaining a set.
|
|
51
|
+
this.ancestors = [];
|
|
52
|
+
this.linked = linked;
|
|
53
|
+
this.maxDepth = maxDepth !== null && maxDepth !== void 0 ? maxDepth : DEFAULT_MAX_DEPTH;
|
|
54
|
+
this.maxNodes = maxNodes !== null && maxNodes !== void 0 ? maxNodes : DEFAULT_MAX_NODES;
|
|
55
|
+
this.maxValues = maxValues !== null && maxValues !== void 0 ? maxValues : DEFAULT_MAX_VALUES;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Copy-on-write walk: containers are copied only when something below them changed, and the
|
|
59
|
+
* received objects are never mutated (they may be frozen, cached or still in use by the app).
|
|
60
|
+
*/
|
|
61
|
+
walk(node, depth) {
|
|
62
|
+
if (node === null || typeof node !== 'object') {
|
|
63
|
+
return node;
|
|
64
|
+
}
|
|
65
|
+
if (this.ancestors.includes(node)) {
|
|
66
|
+
// A cycle. Inside an error it must not lead back to the received error, which has the config
|
|
67
|
+
// and the request. In a body, serializing it fails regardless of this walk.
|
|
68
|
+
return this.insideError > 0 ? CIRCULAR : node;
|
|
69
|
+
}
|
|
70
|
+
if (depth > this.maxDepth || this.nodes >= this.maxNodes) {
|
|
71
|
+
// Not inspected. Inside an error it must not be returned (it may hold the config). Outside
|
|
72
|
+
// one it is left as it is: withholding the whole body would break the large responses.
|
|
73
|
+
this.truncated = true;
|
|
74
|
+
return this.insideError > 0 ? TRUNCATED : node;
|
|
75
|
+
}
|
|
76
|
+
this.nodes++;
|
|
77
|
+
// Arrays and plain objects are by far the most common, and cannot be errors, binary data or
|
|
78
|
+
// streams: they are checked first.
|
|
79
|
+
if (Array.isArray(node)) {
|
|
80
|
+
return this.walkArray(node, depth);
|
|
81
|
+
}
|
|
82
|
+
if (isPlainObject(node)) {
|
|
83
|
+
return this.walkObject(node, depth);
|
|
84
|
+
}
|
|
85
|
+
return this.walkOther(node, depth);
|
|
86
|
+
}
|
|
87
|
+
// Charged per container, when it is entered, so the loops that visit the values have no counter.
|
|
88
|
+
spend(values) {
|
|
89
|
+
this.values += values;
|
|
90
|
+
if (this.values > this.maxValues) {
|
|
91
|
+
this.truncated = true;
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
walkArray(node, depth) {
|
|
97
|
+
if (!this.spend(node.length)) {
|
|
98
|
+
return this.insideError > 0 ? TRUNCATED : node;
|
|
99
|
+
}
|
|
100
|
+
this.ancestors.push(node);
|
|
101
|
+
let result = node;
|
|
102
|
+
for (let i = 0; i < node.length; i++) {
|
|
103
|
+
const value = node[i];
|
|
104
|
+
const safe = this.walkValue(value, depth);
|
|
105
|
+
if (safe !== value) {
|
|
106
|
+
result = result === node ? node.slice() : result;
|
|
107
|
+
result[i] = safe;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
this.ancestors.pop();
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
walkObject(node, depth) {
|
|
114
|
+
const keys = Object.keys(node);
|
|
115
|
+
if (!this.spend(keys.length)) {
|
|
116
|
+
return this.insideError > 0 ? TRUNCATED : node;
|
|
117
|
+
}
|
|
118
|
+
this.ancestors.push(node);
|
|
119
|
+
let result = node;
|
|
120
|
+
for (const key of keys) {
|
|
121
|
+
const value = node[key];
|
|
122
|
+
const safe = this.walkValue(value, depth);
|
|
123
|
+
if (safe !== value) {
|
|
124
|
+
result = result === node ? { ...node } : result;
|
|
125
|
+
result[key] = safe;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
this.ancestors.pop();
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
// Most values are primitives: skipping them here avoids a call per value.
|
|
132
|
+
walkValue(value, depth) {
|
|
133
|
+
return value === null || typeof value !== 'object' ? value : this.walk(value, depth + 1);
|
|
134
|
+
}
|
|
135
|
+
// Errors, and what is neither a plain object nor an array: buffers, streams, class instances.
|
|
136
|
+
walkOther(node, depth) {
|
|
137
|
+
if (isError(node)) {
|
|
138
|
+
this.replaced++;
|
|
139
|
+
return this.toSafeError(node, depth);
|
|
140
|
+
}
|
|
141
|
+
return this.insideError > 0 ? describeOpaque(node) : node;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Builds the object that is exposed instead of the error. It is made from a shallow copy that
|
|
145
|
+
* leaves out the HTTP-client internals, so those are never traversed nor serialized, and goes
|
|
146
|
+
* through `cleanError` to keep the output consistent with the one of the global error handler
|
|
147
|
+
* (circular references, private and function properties, long strings).
|
|
148
|
+
*
|
|
149
|
+
* What reaches `cleanError` is bounded: values beyond the limits, binary data and class
|
|
150
|
+
* instances were replaced by markers while walking, because `cleanError` has no limits of its own.
|
|
151
|
+
*/
|
|
152
|
+
toSafeError(error, depth) {
|
|
153
|
+
this.ancestors.push(error);
|
|
154
|
+
this.insideError++;
|
|
155
|
+
const source = error;
|
|
156
|
+
const copy = this.copyErrorProperties(source, depth);
|
|
157
|
+
const { response } = source;
|
|
158
|
+
if (response !== null && typeof response === 'object') {
|
|
159
|
+
copy.response = this.copyResponse(response, depth);
|
|
160
|
+
}
|
|
161
|
+
const safe = (0, error_1.cleanError)(copy);
|
|
162
|
+
// `cleanError` picks a fixed set of response properties, which leaves `statusText` out.
|
|
163
|
+
if (safe.response && typeof (response === null || response === void 0 ? void 0 : response.statusText) === 'string') {
|
|
164
|
+
safe.response.statusText = response.statusText;
|
|
165
|
+
}
|
|
166
|
+
this.insideError--;
|
|
167
|
+
this.ancestors.pop();
|
|
168
|
+
return safe;
|
|
169
|
+
}
|
|
170
|
+
copyErrorProperties(source, depth) {
|
|
171
|
+
const copy = {};
|
|
172
|
+
for (const key of Object.keys(source)) {
|
|
173
|
+
if (!INTERNAL_ERROR_KEYS.has(key)) {
|
|
174
|
+
copy[key] = this.walk(source[key], depth + 1);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
for (const property of ['name', 'message', 'code']) {
|
|
178
|
+
if (typeof source[property] === 'string') {
|
|
179
|
+
copy[property] = source[property];
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (this.linked && typeof source.stack === 'string') {
|
|
183
|
+
copy.stack = source.stack;
|
|
184
|
+
}
|
|
185
|
+
return copy;
|
|
186
|
+
}
|
|
187
|
+
// Only what the upstream answered. Its headers (cookies, tokens) and the request that originated
|
|
188
|
+
// it are not exposed.
|
|
189
|
+
copyResponse(response, depth) {
|
|
190
|
+
return {
|
|
191
|
+
data: this.walk(response.data, depth + 1),
|
|
192
|
+
status: response.status,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Finds `Error` instances (including `AxiosError`) in a response body, at any depth of plain
|
|
198
|
+
* objects and arrays, and replaces each one by a safe representation: the message, name, code and
|
|
199
|
+
* status are kept, while the request config, the Node request, the agents, the upstream headers
|
|
200
|
+
* and, unless linked, the stack are removed.
|
|
201
|
+
*
|
|
202
|
+
* Strings, buffers, streams and class instances other than errors are returned untouched.
|
|
203
|
+
*/
|
|
204
|
+
const sanitizeErrors = (value, options) => {
|
|
205
|
+
const sanitizer = new ErrorSanitizer(options);
|
|
206
|
+
const sanitized = sanitizer.walk(value, 0);
|
|
207
|
+
return {
|
|
208
|
+
replaced: sanitizer.replaced,
|
|
209
|
+
truncated: sanitizer.truncated,
|
|
210
|
+
value: sanitized,
|
|
211
|
+
};
|
|
212
|
+
};
|
|
213
|
+
exports.sanitizeErrors = sanitizeErrors;
|
|
214
|
+
const isPrimitiveValue = (value) => value === null || typeof value !== 'object';
|
|
215
|
+
const enter = (budget, values) => {
|
|
216
|
+
if (budget.nodes >= budget.maxNodes) {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
budget.nodes++;
|
|
220
|
+
budget.values += values;
|
|
221
|
+
return budget.values <= budget.maxValues;
|
|
222
|
+
};
|
|
223
|
+
// How far the linear pass may nest, in arrays and in objects alike. Deeper bodies fall through to
|
|
224
|
+
// the scan below, which also enforces the depth limit.
|
|
225
|
+
const SHALLOW_DEPTH = 4;
|
|
226
|
+
// Lists of plain records, and an object wrapping them, are the common large payload. One pass
|
|
227
|
+
// proves they hold no Error, which is cheaper than the copying walk and stops at the same budget.
|
|
228
|
+
const shallowValue = (value, budget, depth) => {
|
|
229
|
+
// Arrays and plain objects first: they are the common case and cannot be errors.
|
|
230
|
+
if (Array.isArray(value)) {
|
|
231
|
+
return shallowArray(value, budget, depth);
|
|
232
|
+
}
|
|
233
|
+
if (isPlainObject(value)) {
|
|
234
|
+
return shallowRecord(value, budget, depth);
|
|
235
|
+
}
|
|
236
|
+
if (isError(value)) {
|
|
237
|
+
return 'has-error';
|
|
238
|
+
}
|
|
239
|
+
return enter(budget, 0) ? 'clean' : 'truncated';
|
|
240
|
+
};
|
|
241
|
+
// What is below a container that is not a primitive. Recursion stops at the cutoff, whatever the
|
|
242
|
+
// container is, so its depth is bounded.
|
|
243
|
+
const shallowChild = (child, budget, depth) => depth >= SHALLOW_DEPTH ? undefined : shallowValue(child, budget, depth + 1);
|
|
244
|
+
const shallowArray = (items, budget, depth) => {
|
|
245
|
+
if (!enter(budget, items.length)) {
|
|
246
|
+
return 'truncated';
|
|
247
|
+
}
|
|
248
|
+
for (const item of items) {
|
|
249
|
+
if (isPrimitiveValue(item)) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const found = shallowChild(item, budget, depth);
|
|
253
|
+
if (found !== 'clean') {
|
|
254
|
+
return found;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return 'clean';
|
|
258
|
+
};
|
|
259
|
+
const shallowRecord = (record, budget, depth) => {
|
|
260
|
+
const keys = Object.keys(record);
|
|
261
|
+
if (!enter(budget, keys.length)) {
|
|
262
|
+
return 'truncated';
|
|
263
|
+
}
|
|
264
|
+
for (const key of keys) {
|
|
265
|
+
const value = record[key];
|
|
266
|
+
if (isPrimitiveValue(value)) {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const found = shallowChild(value, budget, depth);
|
|
270
|
+
if (found !== 'clean') {
|
|
271
|
+
return found;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return 'clean';
|
|
275
|
+
};
|
|
276
|
+
const scanChildren = (node, depth, state) => {
|
|
277
|
+
const isArray = Array.isArray(node);
|
|
278
|
+
const record = node;
|
|
279
|
+
const keys = isArray ? undefined : Object.keys(record);
|
|
280
|
+
const length = keys ? keys.length : node.length;
|
|
281
|
+
state.values += length;
|
|
282
|
+
if (state.values > state.maxValues) {
|
|
283
|
+
state.truncated = true;
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
state.ancestors.push(node);
|
|
287
|
+
for (let i = 0; i < length && !state.found && !state.truncated; i++) {
|
|
288
|
+
const child = keys ? record[keys[i]] : node[i];
|
|
289
|
+
if (child !== null && typeof child === 'object') {
|
|
290
|
+
scanNode(child, depth + 1, state);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
state.ancestors.pop();
|
|
294
|
+
};
|
|
295
|
+
const scanNode = (node, depth, state) => {
|
|
296
|
+
if (state.found || state.truncated || state.ancestors.includes(node)) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (depth > state.maxDepth || state.nodes >= state.maxNodes) {
|
|
300
|
+
state.truncated = true;
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
state.nodes++;
|
|
304
|
+
if (Array.isArray(node) || isPlainObject(node)) {
|
|
305
|
+
scanChildren(node, depth, state);
|
|
306
|
+
}
|
|
307
|
+
else if (isError(node)) {
|
|
308
|
+
state.found = true;
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
const deepScan = (value, maxDepth, maxNodes, maxValues) => {
|
|
312
|
+
const state = { ancestors: [], found: false, maxDepth, maxNodes, maxValues, nodes: 0, truncated: false, values: 0 };
|
|
313
|
+
scanNode(value, 0, state);
|
|
314
|
+
if (state.truncated) {
|
|
315
|
+
return 'truncated';
|
|
316
|
+
}
|
|
317
|
+
return state.found ? 'has-error' : 'clean';
|
|
318
|
+
};
|
|
319
|
+
/**
|
|
320
|
+
* Says whether `value` contains an `Error`, without copying it. The copying walk is only needed
|
|
321
|
+
* when this returns `has-error`. `truncated` means a budget (depth, containers, or the values they
|
|
322
|
+
* hold) was spent before the value was fully inspected, so whatever was not visited must not be sent.
|
|
323
|
+
*/
|
|
324
|
+
const scanForErrors = (value, options = {}) => {
|
|
325
|
+
var _a, _b, _c;
|
|
326
|
+
const maxNodes = (_a = options.maxNodes) !== null && _a !== void 0 ? _a : DEFAULT_MAX_NODES;
|
|
327
|
+
const maxValues = (_b = options.maxValues) !== null && _b !== void 0 ? _b : DEFAULT_MAX_VALUES;
|
|
328
|
+
const shallow = shallowValue(value, { maxNodes, maxValues, nodes: 0, values: 0 }, 0);
|
|
329
|
+
if (shallow !== undefined) {
|
|
330
|
+
return shallow;
|
|
331
|
+
}
|
|
332
|
+
return deepScan(value, (_c = options.maxDepth) !== null && _c !== void 0 ? _c : DEFAULT_MAX_DEPTH, maxNodes, maxValues);
|
|
333
|
+
};
|
|
334
|
+
exports.scanForErrors = scanForErrors;
|
|
335
|
+
/**
|
|
336
|
+
* The same as `sanitizeErrors` for what is written to the process output, where the stack is
|
|
337
|
+
* useful and kept. A thrown `Error` stays an error, so it is printed as one (stack, then its
|
|
338
|
+
* properties), without the request config, the request and the agents.
|
|
339
|
+
*
|
|
340
|
+
* It never throws.
|
|
341
|
+
*/
|
|
342
|
+
const toLoggableError = (value) => {
|
|
343
|
+
try {
|
|
344
|
+
const { value: safe } = (0, exports.sanitizeErrors)(value, { linked: true });
|
|
345
|
+
if (safe !== value && value !== null && typeof value === 'object' && isError(value)) {
|
|
346
|
+
return Object.assign(Object.create(Object.getPrototypeOf(value)), safe);
|
|
347
|
+
}
|
|
348
|
+
return safe;
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
return `[error omitted: it could not be sanitized (${err === null || err === void 0 ? void 0 : err.name})]`;
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
exports.toLoggableError = toLoggableError;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare const REDACTED = "[Redacted]";
|
|
2
|
+
export interface RedactResult {
|
|
3
|
+
value: string;
|
|
4
|
+
/**
|
|
5
|
+
* How many occurrences were replaced. Zero means `value` is the received string.
|
|
6
|
+
*/
|
|
7
|
+
redacted: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Replaces credentials in a text: the given secrets wherever they appear, the value of the
|
|
11
|
+
* credential headers in JSON text and in header lines, `Cookie` and `Set-Cookie` lines, the VTEX
|
|
12
|
+
* ID cookie, and `Bearer` tokens.
|
|
13
|
+
* The rest of the text is kept.
|
|
14
|
+
*
|
|
15
|
+
* It is for text that was built from an error, where the error object is no longer there to
|
|
16
|
+
* detect. It does not inspect anything else: a credential that is neither one of the secrets nor
|
|
17
|
+
* under one of those names is not found.
|
|
18
|
+
*/
|
|
19
|
+
export declare const redactSecrets: (text: string, secrets?: Array<string | undefined>) => RedactResult;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.redactSecrets = exports.REDACTED = void 0;
|
|
4
|
+
exports.REDACTED = '[Redacted]';
|
|
5
|
+
// Shorter values are not credentials, and replacing them everywhere would damage ordinary text.
|
|
6
|
+
const MIN_SECRET_LENGTH = 16;
|
|
7
|
+
/**
|
|
8
|
+
* Names under which credentials travel in HTTP headers. When an error is turned into text with
|
|
9
|
+
* `JSON.stringify`, its request config, and the headers in it, end up in the text under these keys.
|
|
10
|
+
*/
|
|
11
|
+
const SENSITIVE_KEYS = [
|
|
12
|
+
'authorization',
|
|
13
|
+
'cookie',
|
|
14
|
+
'proxy-authorization',
|
|
15
|
+
'vtexidclientautcookie',
|
|
16
|
+
'x-vtex-api-appkey',
|
|
17
|
+
'x-vtex-api-apptoken',
|
|
18
|
+
'x-vtex-credential',
|
|
19
|
+
'x-vtex-session',
|
|
20
|
+
];
|
|
21
|
+
// `"key":"value"`, also when the text was serialized twice (`\"key\":\"value\"`). Captures
|
|
22
|
+
// everything up to the value, which is what is replaced. The value is a run of characters that are
|
|
23
|
+
// not a quote, where a backslash only escapes the character after it. The two alternatives of the
|
|
24
|
+
// run exclude each other, so the match is linear.
|
|
25
|
+
const KEYED_VALUE = new RegExp(String.raw `(\\{0,2}"(?:${SENSITIVE_KEYS.join('|')})\\{0,2}"\s*:\s*\\{0,2}")(?:[^"\\]|\\[^"])+`, 'gi');
|
|
26
|
+
// A header written as a line of the request (`Proxy-Authorization: value`), which is how a
|
|
27
|
+
// serialized request object (`_header`) holds it. Short values are not credentials. `Cookie` is
|
|
28
|
+
// not in this list: the word opens ordinary sentences, so its lines are matched separately.
|
|
29
|
+
const HEADER_LINE_NAMES = [
|
|
30
|
+
'authorization',
|
|
31
|
+
'proxy-authorization',
|
|
32
|
+
'vtexidclientautcookie',
|
|
33
|
+
'x-vtex-api-appkey',
|
|
34
|
+
'x-vtex-api-apptoken',
|
|
35
|
+
'x-vtex-credential',
|
|
36
|
+
'x-vtex-session',
|
|
37
|
+
];
|
|
38
|
+
// The name starts a line. In a serialized request the line break is the two characters `\n`, so a
|
|
39
|
+
// word boundary never fires there: the `n` and the name are both word characters.
|
|
40
|
+
const AT_LINE_START = String.raw `(?<=^|\\n|\\r|\n|\r)`;
|
|
41
|
+
const HEADER_LINE = new RegExp(String.raw `${AT_LINE_START}((?:${HEADER_LINE_NAMES.join('|')})[ \t]*:[ \t]*)[^\s"\\][^\r\n"\\]{7,}`, 'gi');
|
|
42
|
+
// A `Cookie` or `Set-Cookie` line. The value has to be cookie pairs (`name=value`), so a sentence
|
|
43
|
+
// that happens to start with "Cookie:" is left as it is.
|
|
44
|
+
const COOKIE_LINE = new RegExp(String.raw `${AT_LINE_START}((?:set-)?cookie[ \t]*:[ \t]*)[^\s=;"\\]+=[^\r\n"\\]{6,}`, 'gi');
|
|
45
|
+
// The VTEX ID cookie, in a `Cookie` header or a `Set-Cookie` line.
|
|
46
|
+
const VTEX_ID_COOKIE = /\b(VtexIdclientAutCookie(?:_[a-z0-9-]+)?=)[^;\s"\\]+/gi;
|
|
47
|
+
// An `Authorization` value that is not under a key, as in a message or a copied header line.
|
|
48
|
+
const BEARER_TOKEN = /\b(Bearer\s+)[a-z0-9._~+/=-]{16,}/gi;
|
|
49
|
+
const redactKnown = (text, secrets) => {
|
|
50
|
+
let value = text;
|
|
51
|
+
let redacted = 0;
|
|
52
|
+
for (const secret of secrets) {
|
|
53
|
+
if (!secret || secret.length < MIN_SECRET_LENGTH || !value.includes(secret)) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const parts = value.split(secret);
|
|
57
|
+
redacted += parts.length - 1;
|
|
58
|
+
value = parts.join(exports.REDACTED);
|
|
59
|
+
}
|
|
60
|
+
return { redacted, value };
|
|
61
|
+
};
|
|
62
|
+
const redactPattern = (text, pattern) => {
|
|
63
|
+
let redacted = 0;
|
|
64
|
+
const value = text.replace(pattern, (match, prefix) => {
|
|
65
|
+
if (match === `${prefix}${exports.REDACTED}`) {
|
|
66
|
+
return match;
|
|
67
|
+
}
|
|
68
|
+
redacted++;
|
|
69
|
+
return `${prefix}${exports.REDACTED}`;
|
|
70
|
+
});
|
|
71
|
+
return { redacted, value: redacted > 0 ? value : text };
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Replaces credentials in a text: the given secrets wherever they appear, the value of the
|
|
75
|
+
* credential headers in JSON text and in header lines, `Cookie` and `Set-Cookie` lines, the VTEX
|
|
76
|
+
* ID cookie, and `Bearer` tokens.
|
|
77
|
+
* The rest of the text is kept.
|
|
78
|
+
*
|
|
79
|
+
* It is for text that was built from an error, where the error object is no longer there to
|
|
80
|
+
* detect. It does not inspect anything else: a credential that is neither one of the secrets nor
|
|
81
|
+
* under one of those names is not found.
|
|
82
|
+
*/
|
|
83
|
+
const redactSecrets = (text, secrets = []) => {
|
|
84
|
+
const known = redactKnown(text, secrets);
|
|
85
|
+
let value = known.value;
|
|
86
|
+
let redacted = known.redacted;
|
|
87
|
+
for (const pattern of [KEYED_VALUE, HEADER_LINE, COOKIE_LINE, VTEX_ID_COOKIE, BEARER_TOKEN]) {
|
|
88
|
+
const result = redactPattern(value, pattern);
|
|
89
|
+
value = result.value;
|
|
90
|
+
redacted += result.redacted;
|
|
91
|
+
}
|
|
92
|
+
return { redacted, value: redacted > 0 ? value : text };
|
|
93
|
+
};
|
|
94
|
+
exports.redactSecrets = redactSecrets;
|