@friggframework/core 2.0.0--canary.654.be44e66.0 → 2.0.0--canary.654.ebad223.0
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/.eslintrc.json +18 -1
- package/core/Worker.js +13 -22
- package/core/create-handler.js +28 -29
- package/handlers/app-definition-loader.js +17 -2
- package/handlers/app-handler-helpers.js +4 -1
- package/handlers/backend-utils.js +0 -5
- package/handlers/workers/dlq-processor.js +9 -11
- package/integrations/integration-base.js +1 -1
- package/logs/__fixtures__/vectors.js +17 -0
- package/logs/debug-shims.js +5 -2
- package/logs/record.js +33 -1
- package/logs/redact.js +55 -15
- package/logs/summarize-event.js +56 -4
- package/modules/module.js +1 -1
- package/modules/requester/oauth-2.js +1 -1
- package/package.json +5 -5
- package/user/repositories/user-repository-documentdb.js +6 -14
package/.eslintrc.json
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
1
|
{
|
|
2
|
-
"extends": "@friggframework/eslint-config"
|
|
2
|
+
"extends": "@friggframework/eslint-config",
|
|
3
|
+
"rules": {
|
|
4
|
+
"no-restricted-syntax": [
|
|
5
|
+
"warn",
|
|
6
|
+
{
|
|
7
|
+
"selector": "CallExpression[callee.object.object.name='process'][callee.object.property.name=/^(stdout|stderr)$/][callee.property.name='write']",
|
|
8
|
+
"message": "Write through the logger; only logs/sinks.js writes to fd 1 (ADR-048 §14)"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"overrides": [
|
|
13
|
+
{
|
|
14
|
+
"files": ["*.test.js", "database/utils/prisma-runner.js"],
|
|
15
|
+
"rules": {
|
|
16
|
+
"no-restricted-syntax": "off"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
]
|
|
3
20
|
}
|
package/core/Worker.js
CHANGED
|
@@ -3,8 +3,10 @@ const _ = require('lodash');
|
|
|
3
3
|
const { RequiredPropertyError } = require('../errors');
|
|
4
4
|
const { get } = require('../assertions');
|
|
5
5
|
const { runMessageScope } = require('./invocation-scope');
|
|
6
|
+
const { getLogger } = require('../logs');
|
|
6
7
|
|
|
7
8
|
const sqs = new SQSClient({ region: process.env.AWS_REGION });
|
|
9
|
+
const log = getLogger('frigg.worker');
|
|
8
10
|
|
|
9
11
|
class Worker {
|
|
10
12
|
async getQueueURL(params) {
|
|
@@ -27,43 +29,32 @@ class Worker {
|
|
|
27
29
|
|
|
28
30
|
for (const record of records) {
|
|
29
31
|
await runMessageScope(record, async () => {
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
let parsedEvent;
|
|
33
|
-
try {
|
|
34
|
-
parsedEvent = JSON.parse(record.body)?.event;
|
|
35
|
-
} catch {
|
|
36
|
-
parsedEvent = undefined;
|
|
37
|
-
}
|
|
38
|
-
console.log(`[Worker] record begin`, {
|
|
39
|
-
messageId: record.messageId,
|
|
40
|
-
event: parsedEvent,
|
|
41
|
-
receiveCount: record.attributes?.ApproximateReceiveCount,
|
|
42
|
-
});
|
|
32
|
+
// messageId, receiveCount and the event come from the scope.
|
|
33
|
+
log.debug('Record started', { eventName: 'frigg.worker.record_started' });
|
|
43
34
|
|
|
44
35
|
try {
|
|
45
36
|
const runParams = JSON.parse(record.body);
|
|
46
37
|
this._validateParams(runParams);
|
|
47
38
|
await this._run(runParams, context);
|
|
48
|
-
|
|
49
|
-
messageId: record.messageId,
|
|
50
|
-
event: runParams?.event,
|
|
51
|
-
});
|
|
39
|
+
log.debug('Record succeeded', { eventName: 'frigg.worker.record_succeeded' });
|
|
52
40
|
} catch (error) {
|
|
53
41
|
if (error.isHaltError) {
|
|
54
42
|
// HaltError means "discard this message, don't retry".
|
|
55
43
|
// Treat as success so SQS deletes it from the queue.
|
|
56
44
|
// Logged explicitly — silent discards made prod debugging
|
|
57
45
|
// extremely hard; keep this visible.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
event: parsedEvent,
|
|
61
|
-
reason: error.message,
|
|
46
|
+
log.error('Record halted (discarded, no retry)', {
|
|
47
|
+
eventName: 'frigg.worker.record_halted',
|
|
62
48
|
statusCode: error.statusCode,
|
|
49
|
+
error,
|
|
63
50
|
});
|
|
64
51
|
return;
|
|
65
52
|
}
|
|
66
|
-
|
|
53
|
+
// The message goes back to SQS, so WARN (ADR-048 §4).
|
|
54
|
+
log.warn('Record failed, returned for retry', {
|
|
55
|
+
eventName: 'frigg.worker.record_failed',
|
|
56
|
+
error,
|
|
57
|
+
});
|
|
67
58
|
batchItemFailures.push({ itemIdentifier: record.messageId });
|
|
68
59
|
}
|
|
69
60
|
});
|
package/core/create-handler.js
CHANGED
|
@@ -6,6 +6,7 @@ const { getLogger, toSanitizedSurrogate } = require('../logs');
|
|
|
6
6
|
const {
|
|
7
7
|
summarizeLambdaEvent,
|
|
8
8
|
toScopeInvocation,
|
|
9
|
+
toRequestInvocation,
|
|
9
10
|
} = require('../logs/summarize-event');
|
|
10
11
|
const {
|
|
11
12
|
runInvocationScope,
|
|
@@ -57,6 +58,8 @@ const createHandler = (optionByName = {}) => {
|
|
|
57
58
|
routeKey: eventSummary.routeKey,
|
|
58
59
|
invocation: toScopeInvocation(eventSummary),
|
|
59
60
|
};
|
|
61
|
+
// The full redacted summary goes only on the entry and failure records.
|
|
62
|
+
const requestInvocation = toRequestInvocation(eventSummary);
|
|
60
63
|
|
|
61
64
|
return runInvocationScope(
|
|
62
65
|
scopeFields,
|
|
@@ -64,6 +67,7 @@ const createHandler = (optionByName = {}) => {
|
|
|
64
67
|
try {
|
|
65
68
|
log.info('Handler invoked', {
|
|
66
69
|
eventName: 'frigg.handler.invoked',
|
|
70
|
+
invocation: requestInvocation,
|
|
67
71
|
});
|
|
68
72
|
|
|
69
73
|
// If enabled (i.e. if SECRET_ARN is set in process.env) Fetch secrets from AWS Secrets Manager, and set them as environment variables.
|
|
@@ -84,45 +88,28 @@ const createHandler = (optionByName = {}) => {
|
|
|
84
88
|
// Run the Lambda
|
|
85
89
|
return await method(event, context);
|
|
86
90
|
} catch (error) {
|
|
87
|
-
//
|
|
88
|
-
if (isUserFacingResponse) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
eventName: 'frigg.handler.rejected',
|
|
94
|
-
statusCode,
|
|
95
|
-
error,
|
|
96
|
-
});
|
|
97
|
-
return {
|
|
98
|
-
statusCode,
|
|
99
|
-
body: JSON.stringify({
|
|
100
|
-
error: error.message,
|
|
101
|
-
}),
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
log.error('Handler failed', {
|
|
106
|
-
eventName: 'frigg.handler.failed',
|
|
91
|
+
// Allow client-safe errors to pass through with their actual message
|
|
92
|
+
if (isUserFacingResponse && error.isClientSafe === true) {
|
|
93
|
+
const statusCode = error.statusCode || 400;
|
|
94
|
+
log.warn('Request rejected', {
|
|
95
|
+
eventName: 'frigg.handler.rejected',
|
|
96
|
+
statusCode,
|
|
107
97
|
error,
|
|
108
98
|
});
|
|
109
|
-
|
|
110
|
-
// Hide other errors with generic message
|
|
111
99
|
return {
|
|
112
|
-
statusCode
|
|
100
|
+
statusCode,
|
|
113
101
|
body: JSON.stringify({
|
|
114
|
-
error:
|
|
102
|
+
error: error.message,
|
|
115
103
|
}),
|
|
116
104
|
};
|
|
117
105
|
}
|
|
118
106
|
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
// itself is the failure record.
|
|
123
|
-
if (error.isHaltError === true) {
|
|
107
|
+
// Server-to-server: halt errors succeed and won't be
|
|
108
|
+
// retried, so the halt itself is the failure record.
|
|
109
|
+
if (!isUserFacingResponse && error.isHaltError === true) {
|
|
124
110
|
log.error('Handler halted', {
|
|
125
111
|
eventName: 'frigg.handler.halted',
|
|
112
|
+
invocation: requestInvocation,
|
|
126
113
|
error,
|
|
127
114
|
});
|
|
128
115
|
return;
|
|
@@ -130,8 +117,20 @@ const createHandler = (optionByName = {}) => {
|
|
|
130
117
|
|
|
131
118
|
log.error('Handler failed', {
|
|
132
119
|
eventName: 'frigg.handler.failed',
|
|
120
|
+
invocation: requestInvocation,
|
|
133
121
|
error,
|
|
134
122
|
});
|
|
123
|
+
|
|
124
|
+
// Don't leak implementation details to end users.
|
|
125
|
+
if (isUserFacingResponse) {
|
|
126
|
+
return {
|
|
127
|
+
statusCode: 500,
|
|
128
|
+
body: JSON.stringify({
|
|
129
|
+
error: 'An Internal Error Occurred',
|
|
130
|
+
}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
135
134
|
// The Lambda runtime writes a rethrown error itself, so
|
|
136
135
|
// rethrow a sanitized copy (ADR-048 §6 item 11).
|
|
137
136
|
throw toSanitizedSurrogate(error);
|
|
@@ -3,6 +3,7 @@ const path = require('node:path');
|
|
|
3
3
|
const fs = require('fs-extra');
|
|
4
4
|
const { resolveTelemetryConfig } = require('../telemetry/telemetry-config');
|
|
5
5
|
const { addDeniedKeys } = require('../logs/redact');
|
|
6
|
+
const { getLogger } = require('../logs');
|
|
6
7
|
const {
|
|
7
8
|
extractCredentialFieldsFromModules,
|
|
8
9
|
} = require('../database/encryption/encryption-schema-registry');
|
|
@@ -12,18 +13,32 @@ const {
|
|
|
12
13
|
|
|
13
14
|
// Handlers without a database never load the encryption registry, so the
|
|
14
15
|
// credential leaf keys are registered for redaction here too.
|
|
16
|
+
const warnedIgnoredKeys = new Set();
|
|
17
|
+
|
|
18
|
+
function warnIgnoredKeys(ignored) {
|
|
19
|
+
for (const key of ignored) {
|
|
20
|
+
if (warnedIgnoredKeys.has(key)) continue;
|
|
21
|
+
warnedIgnoredKeys.add(key);
|
|
22
|
+
getLogger('frigg.logger').warn(
|
|
23
|
+
'Credential field name is a record field; it stays visible in logs',
|
|
24
|
+
{ eventName: 'frigg.logger.denied_key_ignored', key }
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
15
29
|
function registerCredentialLogKeys(appDefinition, integrations) {
|
|
16
30
|
try {
|
|
17
|
-
extractCredentialFieldsFromModules(
|
|
31
|
+
const fields = extractCredentialFieldsFromModules(
|
|
18
32
|
getModulesDefinitionFromIntegrationClasses(integrations)
|
|
19
33
|
);
|
|
34
|
+
warnIgnoredKeys(addDeniedKeys(fields));
|
|
20
35
|
} catch {
|
|
21
36
|
// An odd integration shape must not stop the app from loading.
|
|
22
37
|
}
|
|
23
38
|
const schema = appDefinition.encryption?.schema;
|
|
24
39
|
if (schema && typeof schema === 'object') {
|
|
25
40
|
for (const config of Object.values(schema)) {
|
|
26
|
-
addDeniedKeys(config?.fields);
|
|
41
|
+
warnIgnoredKeys(addDeniedKeys(config?.fields));
|
|
27
42
|
}
|
|
28
43
|
}
|
|
29
44
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { createHandler } = require('@friggframework/core');
|
|
2
2
|
const { getLogger } = require('../logs');
|
|
3
|
+
const { summarizeExpressRequest } = require('../logs/summarize-event');
|
|
3
4
|
const express = require('express');
|
|
4
5
|
const bodyParser = require('body-parser');
|
|
5
6
|
const cors = require('cors');
|
|
@@ -35,14 +36,16 @@ const createApp = (applyMiddleware) => {
|
|
|
35
36
|
log.error('Request failed', {
|
|
36
37
|
eventName: 'frigg.http.request_failed',
|
|
37
38
|
statusCode,
|
|
39
|
+
invocation: summarizeExpressRequest(req),
|
|
38
40
|
error: boomError,
|
|
39
41
|
});
|
|
40
42
|
res.status(statusCode).json({ error: 'Internal Server Error' });
|
|
41
43
|
} else {
|
|
44
|
+
// A client error needs no stack; the logger scrubs the reason.
|
|
42
45
|
log.warn('Request rejected', {
|
|
43
46
|
eventName: 'frigg.http.request_rejected',
|
|
44
47
|
statusCode,
|
|
45
|
-
|
|
48
|
+
reason: boomError.message,
|
|
46
49
|
});
|
|
47
50
|
res.status(statusCode).json({ error: err.message });
|
|
48
51
|
}
|
|
@@ -273,11 +273,6 @@ const createQueueWorker = (integrationClass) => {
|
|
|
273
273
|
return;
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
-
console.error(
|
|
277
|
-
`Error in ${params.event} for ${integrationName}:`,
|
|
278
|
-
error
|
|
279
|
-
);
|
|
280
|
-
|
|
281
276
|
// 4xx HTTP errors are permanent — the requester already
|
|
282
277
|
// attempted token refresh (401) and backoff (429/5xx).
|
|
283
278
|
// By the time a 4xx reaches here, retrying won't help.
|
|
@@ -27,24 +27,22 @@ function extractQueueName(eventSourceARN) {
|
|
|
27
27
|
// The body can hold credentials, so only its size and digest are logged.
|
|
28
28
|
function describeBody(body) {
|
|
29
29
|
const text = typeof body === 'string' ? body : String(body ?? '');
|
|
30
|
+
let parsed = true;
|
|
31
|
+
try {
|
|
32
|
+
JSON.parse(text);
|
|
33
|
+
} catch {
|
|
34
|
+
parsed = false;
|
|
35
|
+
}
|
|
30
36
|
return {
|
|
37
|
+
parsed,
|
|
31
38
|
bodyLength: Buffer.byteLength(text),
|
|
32
39
|
bodySha256: crypto.createHash('sha256').update(text).digest('hex'),
|
|
33
40
|
};
|
|
34
41
|
}
|
|
35
42
|
|
|
36
|
-
function isJson(body) {
|
|
37
|
-
try {
|
|
38
|
-
JSON.parse(body);
|
|
39
|
-
return true;
|
|
40
|
-
} catch {
|
|
41
|
-
return false;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
43
|
function logRecord(record) {
|
|
46
|
-
const body = describeBody(record.body);
|
|
47
|
-
if (!
|
|
44
|
+
const { parsed, ...body } = describeBody(record.body);
|
|
45
|
+
if (!parsed) {
|
|
48
46
|
log.warn('DLQ message body is not JSON', {
|
|
49
47
|
eventName: 'frigg.queue.dlq.body_unparsed',
|
|
50
48
|
...body,
|
|
@@ -778,7 +778,7 @@ class IntegrationBase {
|
|
|
778
778
|
if (this.events[eventName]) {
|
|
779
779
|
if (typeof handlers[eventName] === 'string') {
|
|
780
780
|
this.logger.warn(
|
|
781
|
-
|
|
781
|
+
'Binding handler is ignored: the event is already set',
|
|
782
782
|
{
|
|
783
783
|
eventName: `${this.logger.name}.extension_handler_shadowed`,
|
|
784
784
|
bindingName,
|
|
@@ -7,6 +7,16 @@ const { summarizeLambdaEvent } = require('../summarize-event');
|
|
|
7
7
|
const secretUrl = `https://api.example.com/v1/items?api_key=${S.apiKeyQuery}`;
|
|
8
8
|
const SIGNATURE_VALUE = 'fakeSgnValue2Gh9Tc5Wp7LmQx3';
|
|
9
9
|
|
|
10
|
+
// Built by concatenation so push protection does not match the prefixes.
|
|
11
|
+
const TOKEN_TAIL = 'ZqPr3fixTail8Hn2Kd8Ws1Yc6';
|
|
12
|
+
const PREFIXED_TOKENS = [
|
|
13
|
+
'sk_' + 'live_' + TOKEN_TAIL,
|
|
14
|
+
'whsec' + '_' + TOKEN_TAIL,
|
|
15
|
+
'xox' + 'b-' + TOKEN_TAIL,
|
|
16
|
+
'gh' + 'p_' + TOKEN_TAIL,
|
|
17
|
+
'AK' + 'IA' + 'ZQPR3FIXTAIL8HN2',
|
|
18
|
+
];
|
|
19
|
+
|
|
10
20
|
function errorWithMessage(message, name = 'Error') {
|
|
11
21
|
const error = new Error(message);
|
|
12
22
|
error.name = name;
|
|
@@ -188,6 +198,13 @@ function vectors() {
|
|
|
188
198
|
fields: () => ({ note: `blob ${S.base64Run} end` }),
|
|
189
199
|
secrets: [S.base64Run],
|
|
190
200
|
},
|
|
201
|
+
{
|
|
202
|
+
name: 'provider token prefixes',
|
|
203
|
+
level: 'warn',
|
|
204
|
+
message: () => `provider rejected ${PREFIXED_TOKENS.join(' and ')}`,
|
|
205
|
+
fields: () => ({ note: PREFIXED_TOKENS.join(',') }),
|
|
206
|
+
secrets: PREFIXED_TOKENS,
|
|
207
|
+
},
|
|
191
208
|
{
|
|
192
209
|
name: '40-hex token',
|
|
193
210
|
level: 'error',
|
package/logs/debug-shims.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
const util = require('node:util');
|
|
2
2
|
const { getLogger } = require('./logger');
|
|
3
3
|
const { getContext } = require('./context');
|
|
4
|
-
const {
|
|
4
|
+
const {
|
|
5
|
+
summarizeLambdaEvent,
|
|
6
|
+
toRequestInvocation,
|
|
7
|
+
} = require('./summarize-event');
|
|
5
8
|
|
|
6
9
|
// Deprecated. Kept for one major version; use getLogger or this.logger.
|
|
7
10
|
const LEGACY_LOGGER_NAME = 'frigg.legacy';
|
|
@@ -29,7 +32,7 @@ function initDebugLog(...initMessages) {
|
|
|
29
32
|
if (!log.isLevelEnabled('DEBUG')) return;
|
|
30
33
|
const event = initMessages.find((m) => m && typeof m === 'object');
|
|
31
34
|
log.debug('Debug log initialized', {
|
|
32
|
-
invocation:
|
|
35
|
+
invocation: toRequestInvocation(summarizeLambdaEvent(event)),
|
|
33
36
|
});
|
|
34
37
|
}
|
|
35
38
|
|
package/logs/record.js
CHANGED
|
@@ -78,6 +78,29 @@ function buildMessage(message, callSite) {
|
|
|
78
78
|
return '[non-string message]';
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
const INVOCATION_KEY = 'invocation';
|
|
82
|
+
|
|
83
|
+
function isPlainObject(value) {
|
|
84
|
+
return (
|
|
85
|
+
value !== null &&
|
|
86
|
+
typeof value === 'object' &&
|
|
87
|
+
Object.getPrototypeOf(value) === Object.prototype
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A boundary adds request detail to the scope's invocation; scope keys win.
|
|
92
|
+
function extendInvocation(scoped, detail, dropped) {
|
|
93
|
+
const merged = { ...scoped };
|
|
94
|
+
for (const [key, value] of Object.entries(detail)) {
|
|
95
|
+
if (!Object.prototype.hasOwnProperty.call(scoped, key)) {
|
|
96
|
+
merged[key] = value;
|
|
97
|
+
} else if (JSON.stringify(scoped[key]) !== JSON.stringify(value)) {
|
|
98
|
+
dropped.add(`${INVOCATION_KEY}.${key}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return merged;
|
|
102
|
+
}
|
|
103
|
+
|
|
81
104
|
function traceFields(spanContext) {
|
|
82
105
|
if (
|
|
83
106
|
!spanContext ||
|
|
@@ -135,7 +158,8 @@ function buildRecord({
|
|
|
135
158
|
OWNED_KEYS.has(key) ||
|
|
136
159
|
RESERVED_KEYS.has(key) ||
|
|
137
160
|
(dropPayloads && PAYLOAD_KEYS.has(key)) ||
|
|
138
|
-
Object.prototype.hasOwnProperty.call(record, key)
|
|
161
|
+
(Object.prototype.hasOwnProperty.call(record, key) &&
|
|
162
|
+
!canExtendInvocation(key, value))
|
|
139
163
|
) {
|
|
140
164
|
dropped.add(key);
|
|
141
165
|
continue;
|
|
@@ -149,10 +173,18 @@ function buildRecord({
|
|
|
149
173
|
const value =
|
|
150
174
|
key === 'error' ? serializeError(accepted[key]) : safe?.[key];
|
|
151
175
|
if (value === undefined || value === null) continue;
|
|
176
|
+
if (key === INVOCATION_KEY && isPlainObject(record[key])) {
|
|
177
|
+
record[key] = extendInvocation(record[key], value, dropped);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
152
180
|
record[key] = value;
|
|
153
181
|
if (isCallSite) callSiteKeys.push(key);
|
|
154
182
|
}
|
|
155
183
|
};
|
|
184
|
+
const canExtendInvocation = (key, value) =>
|
|
185
|
+
key === INVOCATION_KEY &&
|
|
186
|
+
isPlainObject(record[key]) &&
|
|
187
|
+
isPlainObject(value);
|
|
156
188
|
place(readFields(scope), false);
|
|
157
189
|
place(readFields(bindings), false);
|
|
158
190
|
place(callSite, true);
|
package/logs/redact.js
CHANGED
|
@@ -14,8 +14,8 @@ const DENIED_SUFFIXES = [
|
|
|
14
14
|
];
|
|
15
15
|
|
|
16
16
|
// Core encryption registry leaves and exact names the suffix rule does not
|
|
17
|
-
// cover. `header` is Node's raw `_header` request text. `domain`
|
|
18
|
-
//
|
|
17
|
+
// cover. `header` is Node's raw `_header` request text. `domain` is not
|
|
18
|
+
// seeded (too common); a module can still register it as a credential field.
|
|
19
19
|
const deniedKeys = new Set([
|
|
20
20
|
'hashword',
|
|
21
21
|
'cookies',
|
|
@@ -25,6 +25,29 @@ const deniedKeys = new Set([
|
|
|
25
25
|
'header',
|
|
26
26
|
]);
|
|
27
27
|
|
|
28
|
+
// Record-contract fields. A credential field with one of these names must not
|
|
29
|
+
// blank the field in every record.
|
|
30
|
+
const PROTECTED_KEYS = new Set(
|
|
31
|
+
[
|
|
32
|
+
'integrationId',
|
|
33
|
+
'integrationType',
|
|
34
|
+
'userId',
|
|
35
|
+
'version',
|
|
36
|
+
'entityId',
|
|
37
|
+
'credentialId',
|
|
38
|
+
'requestId',
|
|
39
|
+
'messageId',
|
|
40
|
+
'processId',
|
|
41
|
+
'integrationEvent',
|
|
42
|
+
'eventName',
|
|
43
|
+
'handlerName',
|
|
44
|
+
'method',
|
|
45
|
+
'route',
|
|
46
|
+
'routeKey',
|
|
47
|
+
'statusCode',
|
|
48
|
+
].map((key) => key.toLowerCase())
|
|
49
|
+
);
|
|
50
|
+
|
|
28
51
|
const PAIR_KEYS = new Set(['code', 'codeverifier']);
|
|
29
52
|
const DIGEST_KEY = /(sha(1|256)|hash|digest|checksum)$/;
|
|
30
53
|
|
|
@@ -33,7 +56,11 @@ const MAX_SCRUB_INPUT = 65536;
|
|
|
33
56
|
const URL_IN_TEXT = /\b[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s"'<>`]+/g;
|
|
34
57
|
const URL_TRAILING_PUNCT = /[.,;:!?)\]}]+$/;
|
|
35
58
|
const ABSOLUTE_URL = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//;
|
|
36
|
-
const
|
|
59
|
+
const RAW_ABSOLUTE_PATH = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/?#]*([^?#]*)/;
|
|
60
|
+
const RAW_RELATIVE_PATH = /^[^?#]*/;
|
|
61
|
+
const PATH_TOKEN_SEGMENT = /^[A-Za-z0-9_.~-]{20,}$/;
|
|
62
|
+
const PREFIXED_TOKEN =
|
|
63
|
+
/(?<![A-Za-z0-9_-])(?:(?:sk|rk)_(?:live|test)_|whsec_|xox[abprs]-|gh[opsu]_|github_pat_|shp(?:at|ss)_|glpat-|npm_)[A-Za-z0-9_-]{16,}|\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g;
|
|
37
64
|
const JWT = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*/g;
|
|
38
65
|
const AUTH_SCHEME = /\b(Bearer|bearer|BEARER|Basic|basic|BASIC|Token|token|TOKEN)(\s+)([A-Za-z0-9._~+/=-]{8,})/g;
|
|
39
66
|
const AUTH_SCHEME_WORD = /^(bearer|basic|token)$/i;
|
|
@@ -62,7 +89,7 @@ function normalizeKey(key) {
|
|
|
62
89
|
|
|
63
90
|
function isDeniedKey(key) {
|
|
64
91
|
const normalized = normalizeKey(key);
|
|
65
|
-
if (!normalized) return false;
|
|
92
|
+
if (!normalized || PROTECTED_KEYS.has(normalized)) return false;
|
|
66
93
|
if (deniedKeys.has(normalized)) return true;
|
|
67
94
|
return DENIED_SUFFIXES.some((suffix) => normalized.endsWith(suffix));
|
|
68
95
|
}
|
|
@@ -80,13 +107,19 @@ function isPairKey(key, { withState = false } = {}) {
|
|
|
80
107
|
);
|
|
81
108
|
}
|
|
82
109
|
|
|
110
|
+
// Returns the leaves it skipped as record-contract keys, so a caller can warn.
|
|
83
111
|
function addDeniedKeys(keys) {
|
|
84
|
-
|
|
112
|
+
const ignored = [];
|
|
113
|
+
if (!Array.isArray(keys)) return ignored;
|
|
85
114
|
for (const key of keys) {
|
|
86
115
|
if (typeof key !== 'string') continue;
|
|
87
|
-
const
|
|
88
|
-
|
|
116
|
+
const rawLeaf = key.split('.').pop();
|
|
117
|
+
const leaf = normalizeKey(rawLeaf);
|
|
118
|
+
if (!leaf) continue;
|
|
119
|
+
if (PROTECTED_KEYS.has(leaf)) ignored.push(rawLeaf);
|
|
120
|
+
else deniedKeys.add(leaf);
|
|
89
121
|
}
|
|
122
|
+
return ignored;
|
|
90
123
|
}
|
|
91
124
|
|
|
92
125
|
function hasMixedClasses(run) {
|
|
@@ -107,7 +140,8 @@ function isRedactedValue(value) {
|
|
|
107
140
|
}
|
|
108
141
|
|
|
109
142
|
function scrubTokens(text, { allowHex = false, segment = false } = {}) {
|
|
110
|
-
let out = text.replace(
|
|
143
|
+
let out = text.replace(PREFIXED_TOKEN, redacted);
|
|
144
|
+
out = out.replace(JWT, redacted);
|
|
111
145
|
out = out.replace(AUTH_SCHEME, (match, scheme, space, credential) => {
|
|
112
146
|
const classes = [/[A-Z]/, /[a-z]/, /[0-9]/, /[._~+/=-]/].filter((re) =>
|
|
113
147
|
re.test(credential)
|
|
@@ -164,11 +198,18 @@ function redactHash(hash) {
|
|
|
164
198
|
return scrubTokens(hash);
|
|
165
199
|
}
|
|
166
200
|
|
|
201
|
+
// A long segment mixing upper case, lower case and digits is an opaque
|
|
202
|
+
// token (webhook secrets live in paths); hex ids and UUIDs lack one class.
|
|
203
|
+
function redactPathSegment(segment) {
|
|
204
|
+
if (PATH_TOKEN_SEGMENT.test(segment) && hasMixedClasses(segment)) {
|
|
205
|
+
return redacted(segment);
|
|
206
|
+
}
|
|
207
|
+
return scrubTokens(segment, { segment: true });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The raw path, not URL#pathname, which percent-encodes `{proxy+}`.
|
|
167
211
|
function redactPath(pathname) {
|
|
168
|
-
return pathname
|
|
169
|
-
.split('/')
|
|
170
|
-
.map((segment) => scrubTokens(segment, { segment: true }))
|
|
171
|
-
.join('/');
|
|
212
|
+
return pathname.split('/').map(redactPathSegment).join('/');
|
|
172
213
|
}
|
|
173
214
|
|
|
174
215
|
function redactUrlFallback(raw) {
|
|
@@ -189,8 +230,7 @@ function redactUrl(url) {
|
|
|
189
230
|
try {
|
|
190
231
|
if (ABSOLUTE_URL.test(raw)) {
|
|
191
232
|
const u = new URL(raw);
|
|
192
|
-
const path =
|
|
193
|
-
u.pathname === '/' && !ROOT_SLASH.test(raw) ? '' : u.pathname;
|
|
233
|
+
const path = raw.match(RAW_ABSOLUTE_PATH)[1];
|
|
194
234
|
return `${u.protocol}//${u.host}${redactPath(path)}${redactParams(
|
|
195
235
|
u.search,
|
|
196
236
|
'?'
|
|
@@ -198,7 +238,7 @@ function redactUrl(url) {
|
|
|
198
238
|
}
|
|
199
239
|
if (raw.startsWith('/')) {
|
|
200
240
|
const u = new URL(raw, 'http://relative.invalid');
|
|
201
|
-
return `${redactPath(
|
|
241
|
+
return `${redactPath(raw.match(RAW_RELATIVE_PATH)[0])}${redactParams(
|
|
202
242
|
u.search,
|
|
203
243
|
'?'
|
|
204
244
|
)}${redactHash(u.hash)}`;
|
package/logs/summarize-event.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const { redactUrl } = require('./redact');
|
|
2
|
+
|
|
1
3
|
// Best-effort extraction of the logical event/processId/integrationId from a
|
|
2
4
|
// JSON message body. Used only for log correlation. Never throws.
|
|
3
5
|
function summarizeMessageBody(bodyStr) {
|
|
@@ -32,13 +34,26 @@ function unique(list) {
|
|
|
32
34
|
return [...new Set(list)];
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
// The route template keeps ids out of the field: REST v1 `resource`, or the
|
|
38
|
+
// path part of an HTTP API v2 `routeKey` such as `GET /api/{id}`.
|
|
39
|
+
function routeTemplate(event, path) {
|
|
40
|
+
if (typeof event.resource === 'string' && event.resource) {
|
|
41
|
+
return event.resource;
|
|
42
|
+
}
|
|
43
|
+
if (typeof event.routeKey === 'string') {
|
|
44
|
+
const space = event.routeKey.indexOf(' ');
|
|
45
|
+
if (space > 0) return event.routeKey.slice(space + 1);
|
|
46
|
+
}
|
|
47
|
+
return path;
|
|
48
|
+
}
|
|
49
|
+
|
|
35
50
|
function summarizeHttp(event) {
|
|
36
51
|
const path = event.path || event.rawPath;
|
|
37
52
|
const summary = {
|
|
38
53
|
source: 'http',
|
|
39
54
|
method: event.httpMethod || event.requestContext?.http?.method,
|
|
40
55
|
path,
|
|
41
|
-
route: path,
|
|
56
|
+
route: routeTemplate(event, path),
|
|
42
57
|
queryKeys: unique([
|
|
43
58
|
...objectKeys(event.queryStringParameters),
|
|
44
59
|
...objectKeys(event.multiValueQueryStringParameters),
|
|
@@ -98,8 +113,6 @@ function toScopeInvocation(summary) {
|
|
|
98
113
|
source: 'http',
|
|
99
114
|
method: summary.method,
|
|
100
115
|
route: summary.route,
|
|
101
|
-
queryKeys: summary.queryKeys ?? [],
|
|
102
|
-
headerNames: summary.headerNames ?? [],
|
|
103
116
|
};
|
|
104
117
|
if (summary.routeKey) invocation.routeKey = summary.routeKey;
|
|
105
118
|
return invocation;
|
|
@@ -107,4 +120,43 @@ function toScopeInvocation(summary) {
|
|
|
107
120
|
return { source: summary.source };
|
|
108
121
|
}
|
|
109
122
|
|
|
110
|
-
|
|
123
|
+
// Per-request detail for the entry and failure records, never for the
|
|
124
|
+
// scope. A raw path can hold a token, so it goes through redactUrl.
|
|
125
|
+
function toRequestDetails(summary) {
|
|
126
|
+
if (!summary || summary.source !== 'http') return {};
|
|
127
|
+
return {
|
|
128
|
+
path: redactUrl(summary.path ?? ''),
|
|
129
|
+
queryKeys: summary.queryKeys ?? [],
|
|
130
|
+
headerNames: summary.headerNames ?? [],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The full redacted request summary (ADR-048 §6: it goes under `invocation`).
|
|
135
|
+
function toRequestInvocation(summary) {
|
|
136
|
+
return { ...toScopeInvocation(summary), ...toRequestDetails(summary) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The same shape from an express request, for the express error boundary.
|
|
140
|
+
function summarizeExpressRequest(req) {
|
|
141
|
+
try {
|
|
142
|
+
if (!req || typeof req !== 'object') return { source: 'http' };
|
|
143
|
+
return {
|
|
144
|
+
source: 'http',
|
|
145
|
+
method: req.method,
|
|
146
|
+
path: redactUrl(req.path ?? ''),
|
|
147
|
+
queryKeys: objectKeys(req.query),
|
|
148
|
+
headerNames: unique(objectKeys(req.headers).map((name) => name.toLowerCase())),
|
|
149
|
+
};
|
|
150
|
+
} catch {
|
|
151
|
+
return { source: 'http' };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = {
|
|
156
|
+
summarizeLambdaEvent,
|
|
157
|
+
summarizeMessageBody,
|
|
158
|
+
toScopeInvocation,
|
|
159
|
+
toRequestDetails,
|
|
160
|
+
toRequestInvocation,
|
|
161
|
+
summarizeExpressRequest,
|
|
162
|
+
};
|
package/modules/module.js
CHANGED
|
@@ -32,7 +32,7 @@ class Module extends Delegate {
|
|
|
32
32
|
this.credential = entityObj?.credential;
|
|
33
33
|
this.definition = definition;
|
|
34
34
|
this.name = this.definition.moduleName;
|
|
35
|
-
this.logger = getLogger(`module.${this.name}`).child(() => ({
|
|
35
|
+
this.logger = getLogger(`module.${this.name ?? 'unknown'}`).child(() => ({
|
|
36
36
|
entityId: this.entity?.id,
|
|
37
37
|
credentialId:
|
|
38
38
|
this.credential?.id ??
|
|
@@ -357,7 +357,7 @@ class OAuth2Requester extends Requester {
|
|
|
357
357
|
eventName: `${this.logger.name}.token_refresh_failed`,
|
|
358
358
|
statusCode:
|
|
359
359
|
error?.statusCode ?? error?.status ?? error?.response?.status,
|
|
360
|
-
error,
|
|
360
|
+
reason: error?.message,
|
|
361
361
|
});
|
|
362
362
|
|
|
363
363
|
if (!this._isDefinitiveAuthRejection(error)) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/core",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0--canary.654.
|
|
4
|
+
"version": "2.0.0--canary.654.ebad223.0",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
|
|
7
7
|
"@aws-sdk/client-kms": "^3.588.0",
|
|
@@ -47,9 +47,9 @@
|
|
|
47
47
|
}
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@friggframework/eslint-config": "2.0.0--canary.654.
|
|
51
|
-
"@friggframework/prettier-config": "2.0.0--canary.654.
|
|
52
|
-
"@friggframework/test": "2.0.0--canary.654.
|
|
50
|
+
"@friggframework/eslint-config": "2.0.0--canary.654.ebad223.0",
|
|
51
|
+
"@friggframework/prettier-config": "2.0.0--canary.654.ebad223.0",
|
|
52
|
+
"@friggframework/test": "2.0.0--canary.654.ebad223.0",
|
|
53
53
|
"@prisma/client": "^6.19.3",
|
|
54
54
|
"@types/lodash": "4.17.15",
|
|
55
55
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
@@ -89,5 +89,5 @@
|
|
|
89
89
|
"publishConfig": {
|
|
90
90
|
"access": "public"
|
|
91
91
|
},
|
|
92
|
-
"gitHead": "
|
|
92
|
+
"gitHead": "ebad2239696f6fbfd7918a327bdbd45c737dc7e0"
|
|
93
93
|
}
|
|
@@ -148,13 +148,8 @@ class UserRepositoryDocumentDB extends UserRepositoryInterface {
|
|
|
148
148
|
|
|
149
149
|
// Defensive check: verify document was found after insert
|
|
150
150
|
if (!created) {
|
|
151
|
-
log.error('User not found after insert', {
|
|
152
|
-
eventName: 'frigg.user.not_found_after_insert',
|
|
153
|
-
insertedId: fromObjectId(insertedId),
|
|
154
|
-
paramKeys: Object.keys(params),
|
|
155
|
-
});
|
|
156
151
|
throw new Error(
|
|
157
|
-
|
|
152
|
+
`Failed to create individual user: Document not found after insert (insertedId ${fromObjectId(insertedId)}). ` +
|
|
158
153
|
'This indicates a database consistency issue.'
|
|
159
154
|
);
|
|
160
155
|
}
|
|
@@ -192,13 +187,8 @@ class UserRepositoryDocumentDB extends UserRepositoryInterface {
|
|
|
192
187
|
|
|
193
188
|
// Defensive check: verify document was found after insert
|
|
194
189
|
if (!created) {
|
|
195
|
-
log.error('Organization user not found after insert', {
|
|
196
|
-
eventName: 'frigg.user.organization_not_found_after_insert',
|
|
197
|
-
insertedId: fromObjectId(insertedId),
|
|
198
|
-
paramKeys: Object.keys(params),
|
|
199
|
-
});
|
|
200
190
|
throw new Error(
|
|
201
|
-
|
|
191
|
+
`Failed to create organization user: Document not found after insert (insertedId ${fromObjectId(insertedId)}). ` +
|
|
202
192
|
'This indicates a database consistency issue.'
|
|
203
193
|
);
|
|
204
194
|
}
|
|
@@ -284,7 +274,8 @@ class UserRepositoryDocumentDB extends UserRepositoryInterface {
|
|
|
284
274
|
// Defensive check: verify document was found after update
|
|
285
275
|
if (!updated) {
|
|
286
276
|
throw new Error(
|
|
287
|
-
|
|
277
|
+
'Failed to update individual user: Document not found after update. ' +
|
|
278
|
+
'This indicates a database consistency issue.'
|
|
288
279
|
);
|
|
289
280
|
}
|
|
290
281
|
|
|
@@ -317,7 +308,8 @@ class UserRepositoryDocumentDB extends UserRepositoryInterface {
|
|
|
317
308
|
|
|
318
309
|
if (!updated) {
|
|
319
310
|
throw new Error(
|
|
320
|
-
|
|
311
|
+
'Failed to update organization user: Document not found after update. ' +
|
|
312
|
+
'This indicates a database consistency issue.'
|
|
321
313
|
);
|
|
322
314
|
}
|
|
323
315
|
|