@dhyasama/totem-merge 7.1.0 → 7.1.2
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/.github/pull_request_template.md +15 -0
- package/.github/workflows/secret-scan.yml +42 -0
- package/.gitleaks.toml +197 -0
- package/bin/bootstrap +11 -0
- package/cli.js +2 -2
- package/config.js +1 -5
- package/index.js +0 -1
- package/infrastructure/security-checks/runtime-secret-contract.cjs +91 -0
- package/infrastructure/security-checks/runtime-secrets.md +16 -0
- package/lib/helpers.js +4 -14
- package/lib/organizations.js +2 -8
- package/lib/people.js +1 -7
- package/lib/runtimeSecrets.js +44 -0
- package/package.json +7 -7
- package/test/config.js +1 -1
- package/test/index.js +10 -14
- package/test/merge-security.test.js +119 -0
- package/test/runtime-secrets.test.js +27 -0
- package/lib/changeBSON.js +0 -22
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
## What and why
|
|
2
|
+
|
|
3
|
+
<!-- What does this change do, and why is it needed? -->
|
|
4
|
+
|
|
5
|
+
## Author self-review
|
|
6
|
+
|
|
7
|
+
Totem has a single engineer, so this PR is self-reviewed under the single-engineer exception in the Change Management Policy. Before merging:
|
|
8
|
+
|
|
9
|
+
- [ ] I re-read the full diff after the last push
|
|
10
|
+
- [ ] The change was tested (how: )
|
|
11
|
+
- [ ] No secrets, credentials, or customer data are added (`gitleaks` passing)
|
|
12
|
+
- [ ] Access, IAM, or security-group changes are least-privilege and explained above
|
|
13
|
+
- [ ] Rollback plan is clear (revert this PR, or: )
|
|
14
|
+
|
|
15
|
+
After merge without peer approval, justify this PR on Vanta's Code Changes page.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
name: Secret scan
|
|
2
|
+
# Fails if a push or pull request adds a credential. Scans only the new commits,
|
|
3
|
+
# with the rules in .gitleaks.toml; findings are redacted in the log.
|
|
4
|
+
on:
|
|
5
|
+
push:
|
|
6
|
+
pull_request:
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
jobs:
|
|
10
|
+
gitleaks:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
timeout-minutes: 10
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
15
|
+
with:
|
|
16
|
+
fetch-depth: 0
|
|
17
|
+
persist-credentials: false
|
|
18
|
+
- name: Install gitleaks 8.30.1 (checksum verified)
|
|
19
|
+
run: |
|
|
20
|
+
curl -sSfL -o gitleaks.tgz https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz
|
|
21
|
+
echo "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb gitleaks.tgz" | sha256sum -c -
|
|
22
|
+
tar -xzf gitleaks.tgz gitleaks
|
|
23
|
+
- name: Scan the new commits
|
|
24
|
+
env:
|
|
25
|
+
EVENT: ${{ github.event_name }}
|
|
26
|
+
BEFORE: ${{ github.event.before }}
|
|
27
|
+
AFTER: ${{ github.sha }}
|
|
28
|
+
PR_BASE: ${{ github.event.pull_request.base.sha }}
|
|
29
|
+
PR_HEAD: ${{ github.event.pull_request.head.sha }}
|
|
30
|
+
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
31
|
+
run: |
|
|
32
|
+
if [ "$EVENT" = "pull_request" ]; then
|
|
33
|
+
RANGE="$PR_BASE..$PR_HEAD"
|
|
34
|
+
elif [ -n "$BEFORE" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ] && git cat-file -e "$BEFORE^{commit}" 2>/dev/null; then
|
|
35
|
+
RANGE="$BEFORE..$AFTER"
|
|
36
|
+
elif git rev-parse --verify -q "origin/$DEFAULT_BRANCH" >/dev/null && [ "$(git rev-parse "origin/$DEFAULT_BRANCH")" != "$AFTER" ]; then
|
|
37
|
+
RANGE="origin/$DEFAULT_BRANCH..$AFTER"
|
|
38
|
+
else
|
|
39
|
+
RANGE="-1 $AFTER"
|
|
40
|
+
fi
|
|
41
|
+
echo "Scanning: $RANGE"
|
|
42
|
+
./gitleaks git . --config .gitleaks.toml --log-opts="$RANGE" --redact --no-banner --exit-code 1
|
package/.gitleaks.toml
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# Secret scanning rules for gitleaks (https://github.com/gitleaks/gitleaks).
|
|
2
|
+
# Used by .github/workflows/secret-scan.yml on every push and pull request.
|
|
3
|
+
# Defaults plus Totem-specific rules; values are never stored here, only patterns.
|
|
4
|
+
title = "Totem secret scan"
|
|
5
|
+
|
|
6
|
+
[extend]
|
|
7
|
+
useDefault = true
|
|
8
|
+
|
|
9
|
+
[[rules]]
|
|
10
|
+
id = "totem-personal-api-key"
|
|
11
|
+
description = "Totem personal API key (opaque tvc_ key)"
|
|
12
|
+
regex = '''\btvc_[A-Za-z0-9_-]{43}\b'''
|
|
13
|
+
keywords = ["tvc_"]
|
|
14
|
+
|
|
15
|
+
[[rules]]
|
|
16
|
+
id = "mongodb-uri-creds"
|
|
17
|
+
description = "MongoDB connection string with embedded credentials"
|
|
18
|
+
regex = '''mongodb(?:\+srv)?://([^:/\s'"@]+):([^@/\s'"]+)@'''
|
|
19
|
+
secretGroup = 2
|
|
20
|
+
keywords = ["mongodb"]
|
|
21
|
+
|
|
22
|
+
[[rules]]
|
|
23
|
+
id = "redis-uri-creds"
|
|
24
|
+
description = "Redis/Valkey URI with embedded password"
|
|
25
|
+
regex = '''rediss?://[^:/\s'"@]*:([^@/\s'"]+)@'''
|
|
26
|
+
secretGroup = 1
|
|
27
|
+
keywords = ["redis://", "rediss://"]
|
|
28
|
+
|
|
29
|
+
[[rules]]
|
|
30
|
+
id = "aiven-service-password"
|
|
31
|
+
description = "Aiven service password"
|
|
32
|
+
regex = '''AVNS_[A-Za-z0-9_-]{12,}'''
|
|
33
|
+
keywords = ["AVNS_"]
|
|
34
|
+
|
|
35
|
+
[[rules]]
|
|
36
|
+
id = "basic-auth-url"
|
|
37
|
+
description = "HTTP(S) URL with embedded basic-auth password"
|
|
38
|
+
regex = '''https?://[A-Za-z0-9._%+-]+:([^@/\s'"]{6,})@[A-Za-z0-9.-]+'''
|
|
39
|
+
secretGroup = 1
|
|
40
|
+
keywords = ["http://", "https://"]
|
|
41
|
+
|
|
42
|
+
[[rules]]
|
|
43
|
+
id = "google-api-key-any"
|
|
44
|
+
description = "Google API key (Maps, Gemini, etc.)"
|
|
45
|
+
regex = '''AIza[0-9A-Za-z_-]{35}'''
|
|
46
|
+
keywords = ["AIza"]
|
|
47
|
+
|
|
48
|
+
[[rules]]
|
|
49
|
+
id = "google-oauth-client-secret"
|
|
50
|
+
description = "Google OAuth client secret (new or legacy format)"
|
|
51
|
+
regex = '''(?i)(?:client_?secret|clientSecret)['"]?\s*[:=]\s*['"](GOCSPX-[A-Za-z0-9_-]{20,}|[A-Za-z0-9_-]{24})['"]'''
|
|
52
|
+
secretGroup = 1
|
|
53
|
+
keywords = ["client_secret", "clientsecret", "gocspx"]
|
|
54
|
+
|
|
55
|
+
[[rules]]
|
|
56
|
+
id = "gocspx-anywhere"
|
|
57
|
+
description = "Google OAuth client secret prefix"
|
|
58
|
+
regex = '''GOCSPX-[A-Za-z0-9_-]{20,}'''
|
|
59
|
+
keywords = ["GOCSPX-"]
|
|
60
|
+
|
|
61
|
+
[[rules]]
|
|
62
|
+
id = "openai-key-any"
|
|
63
|
+
description = "OpenAI API key"
|
|
64
|
+
regex = '''sk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{20,}T3BlbkFJ[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9]{48}'''
|
|
65
|
+
keywords = ["sk-"]
|
|
66
|
+
|
|
67
|
+
[[rules]]
|
|
68
|
+
id = "aws-access-key-id-any"
|
|
69
|
+
description = "AWS access key ID"
|
|
70
|
+
regex = '''\b((?:AKIA|ASIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA)[A-Z0-9]{16})\b'''
|
|
71
|
+
keywords = ["AKIA", "ASIA", "AGPA", "AIDA", "AROA", "AIPA", "ANPA", "ANVA"]
|
|
72
|
+
|
|
73
|
+
[[rules]]
|
|
74
|
+
id = "aws-secret-access-key"
|
|
75
|
+
description = "AWS secret access key assignment"
|
|
76
|
+
regex = '''(?i)(?:aws)?_?secret_?(?:access)?_?key['"]?\s*[:=]\s*['"]([A-Za-z0-9/+=]{40})['"]'''
|
|
77
|
+
secretGroup = 1
|
|
78
|
+
keywords = ["secret"]
|
|
79
|
+
|
|
80
|
+
[[rules]]
|
|
81
|
+
id = "private-key-block"
|
|
82
|
+
description = "PEM private key"
|
|
83
|
+
regex = '''-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY'''
|
|
84
|
+
keywords = ["PRIVATE KEY"]
|
|
85
|
+
|
|
86
|
+
[[rules]]
|
|
87
|
+
id = "gcp-service-account"
|
|
88
|
+
description = "GCP service account JSON"
|
|
89
|
+
regex = '''"private_key_id"\s*:\s*"[a-f0-9]{40}"'''
|
|
90
|
+
keywords = ["private_key_id"]
|
|
91
|
+
|
|
92
|
+
[[rules]]
|
|
93
|
+
id = "npm-token"
|
|
94
|
+
description = "npm access token"
|
|
95
|
+
regex = '''npm_[A-Za-z0-9]{36}|//registry\.npmjs\.org/:_authToken\s*=\s*([A-Za-z0-9-]{20,})'''
|
|
96
|
+
keywords = ["npm_", "_authToken"]
|
|
97
|
+
|
|
98
|
+
[[rules]]
|
|
99
|
+
id = "github-token"
|
|
100
|
+
description = "GitHub token"
|
|
101
|
+
regex = '''\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b|github_pat_[A-Za-z0-9_]{50,}'''
|
|
102
|
+
keywords = ["ghp_", "gho_", "ghu_", "ghs_", "ghr_", "github_pat_"]
|
|
103
|
+
|
|
104
|
+
[[rules]]
|
|
105
|
+
id = "slack-token"
|
|
106
|
+
description = "Slack token or webhook"
|
|
107
|
+
regex = '''xox[baprs]-[A-Za-z0-9-]{10,}|https://hooks\.slack\.com/services/T[A-Za-z0-9]+/B[A-Za-z0-9]+/[A-Za-z0-9]+'''
|
|
108
|
+
keywords = ["xox", "hooks.slack.com"]
|
|
109
|
+
|
|
110
|
+
[[rules]]
|
|
111
|
+
id = "stripe-key"
|
|
112
|
+
description = "Stripe live/test secret key"
|
|
113
|
+
regex = '''\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{20,}\b'''
|
|
114
|
+
keywords = ["sk_live", "sk_test", "rk_live", "rk_test"]
|
|
115
|
+
|
|
116
|
+
[[rules]]
|
|
117
|
+
id = "sendgrid-key"
|
|
118
|
+
description = "SendGrid API key"
|
|
119
|
+
regex = '''SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}'''
|
|
120
|
+
keywords = ["SG."]
|
|
121
|
+
|
|
122
|
+
[[rules]]
|
|
123
|
+
id = "twilio-key"
|
|
124
|
+
description = "Twilio SID/key"
|
|
125
|
+
regex = '''\b(?:SK|AC)[a-f0-9]{32}\b'''
|
|
126
|
+
keywords = ["SK", "AC"]
|
|
127
|
+
entropy = 3.0
|
|
128
|
+
|
|
129
|
+
[[rules]]
|
|
130
|
+
id = "uuid-token-assignment"
|
|
131
|
+
description = "UUID-shaped token assigned near a secret-like keyword (Postmark, Logentries, Fixer, etc.)"
|
|
132
|
+
regex = '''(?i)(?:token|api_?key|apikey|secret|logentries|postmark|le_token)['"]?\s*[:=]\s*['"]([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})['"]'''
|
|
133
|
+
secretGroup = 1
|
|
134
|
+
keywords = ["token", "key", "secret", "logentries", "postmark"]
|
|
135
|
+
|
|
136
|
+
[[rules]]
|
|
137
|
+
id = "hex-or-b64-token-assignment"
|
|
138
|
+
description = "Long token/secret/apikey literal assigned in code"
|
|
139
|
+
regex = '''(?i)(?:api_?key|apikey|api_?secret|access_?token|auth_?token|secret_?key|client_?secret|private_?key|signing_?secret|jwt_?secret|session_?secret|cookie_?secret|encryption_?key|webhook_?secret)['"]?\s*[:=]\s*['"]([A-Za-z0-9_\-/+=.]{16,})['"]'''
|
|
140
|
+
secretGroup = 1
|
|
141
|
+
entropy = 3.2
|
|
142
|
+
keywords = ["key", "secret", "token"]
|
|
143
|
+
|
|
144
|
+
[[rules]]
|
|
145
|
+
id = "password-assignment"
|
|
146
|
+
description = "Password literal assigned in code"
|
|
147
|
+
regex = '''(?i)(?:password|passwd|pwd|pass)['"]?\s*[:=]\s*['"]([^'"\s]{8,})['"]'''
|
|
148
|
+
secretGroup = 1
|
|
149
|
+
entropy = 3.0
|
|
150
|
+
keywords = ["password", "passwd", "pwd", "pass"]
|
|
151
|
+
|
|
152
|
+
[[rules]]
|
|
153
|
+
id = "jwt-literal"
|
|
154
|
+
description = "JSON Web Token literal"
|
|
155
|
+
regex = '''\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b'''
|
|
156
|
+
keywords = ["eyJ"]
|
|
157
|
+
|
|
158
|
+
[[rules]]
|
|
159
|
+
id = "azure-storage-key"
|
|
160
|
+
description = "Azure storage account key"
|
|
161
|
+
regex = '''(?i)AccountKey\s*=\s*([A-Za-z0-9+/=]{86,88})'''
|
|
162
|
+
secretGroup = 1
|
|
163
|
+
keywords = ["AccountKey"]
|
|
164
|
+
|
|
165
|
+
[[rules]]
|
|
166
|
+
id = "generic-bearer"
|
|
167
|
+
description = "Hardcoded bearer token"
|
|
168
|
+
regex = '''(?i)['"]?authorization['"]?\s*[:=]\s*['"]Bearer\s+([A-Za-z0-9_\-.=]{20,})['"]'''
|
|
169
|
+
secretGroup = 1
|
|
170
|
+
keywords = ["bearer"]
|
|
171
|
+
|
|
172
|
+
[[allowlists]]
|
|
173
|
+
description = "Paths that are never source secrets"
|
|
174
|
+
paths = [
|
|
175
|
+
'''(^|/)node_modules/''',
|
|
176
|
+
'''(^|/)bower_components/''',
|
|
177
|
+
'''(^|/)\.next/''',
|
|
178
|
+
'''(^|/)dist/''',
|
|
179
|
+
'''(^|/)build/''',
|
|
180
|
+
'''(^|/)vendor/''',
|
|
181
|
+
'''package-lock\.json$''',
|
|
182
|
+
'''yarn\.lock$''',
|
|
183
|
+
'''pnpm-lock\.yaml$''',
|
|
184
|
+
'''\.min\.js$''',
|
|
185
|
+
'''\.map$''',
|
|
186
|
+
'''\.(png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|pdf|xlsx|docx|mp3|wav|zip)$''',
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
[[allowlists]]
|
|
190
|
+
description = "Obvious placeholders"
|
|
191
|
+
regexes = [
|
|
192
|
+
'''(?i)process\.env''',
|
|
193
|
+
'''(?i)(your|my|example|sample|dummy|fake|placeholder|changeme|xxx+|\*{4,}|<[^>]+>)''',
|
|
194
|
+
'''(?i)mongodb(\+srv)?://(user|username|USER|USERNAME|<user>)[:@]''',
|
|
195
|
+
'''(?i)localhost''',
|
|
196
|
+
'''(?i)synthetic''',
|
|
197
|
+
]
|
package/bin/bootstrap
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
var runtimeSecrets = require('../lib/runtimeSecrets');
|
|
5
|
+
|
|
6
|
+
runtimeSecrets.loadRuntimeSecrets()
|
|
7
|
+
.then(function() { require('../cli'); })
|
|
8
|
+
.catch(function(error) {
|
|
9
|
+
console.error('runtime_secret_bootstrap_failed', { code: error.name || error.code || 'UnknownError' });
|
|
10
|
+
process.exit(1);
|
|
11
|
+
});
|
package/cli.js
CHANGED
|
@@ -85,8 +85,8 @@ var buildProgram = function buildProgram() {
|
|
|
85
85
|
console.log('Getting orgs...');
|
|
86
86
|
|
|
87
87
|
async.parallel([
|
|
88
|
-
Organization.getById.bind(Organization, target, {
|
|
89
|
-
Organization.getById.bind(Organization, source, {
|
|
88
|
+
Organization.getById.bind(Organization, target, { isWorkerProcess: true, includeLPs: false }),
|
|
89
|
+
Organization.getById.bind(Organization, source, { isWorkerProcess: true, includeLPs: false })
|
|
90
90
|
], function(err, results) {
|
|
91
91
|
|
|
92
92
|
if (err) { done(err); }
|
package/config.js
CHANGED
|
@@ -8,12 +8,8 @@ module.exports = {
|
|
|
8
8
|
|
|
9
9
|
suppressDebugLog: true,
|
|
10
10
|
|
|
11
|
-
// db: {
|
|
12
|
-
// uri: 'mongodb://ffvc:ytnn5m7A9snIJSOLRqwo7Bpo2QkyYx6q@ds043952.mongolab.com:43952/ff-platform-test'
|
|
13
|
-
// },
|
|
14
|
-
|
|
15
11
|
db: {
|
|
16
|
-
uri:
|
|
12
|
+
uri: process.env.DB_URI || ''
|
|
17
13
|
},
|
|
18
14
|
|
|
19
15
|
crypto: {
|
package/index.js
CHANGED
|
@@ -21,7 +21,6 @@ var Merge = function(mongoose, config) {
|
|
|
21
21
|
this.Organization = mongoose.model('Organization');
|
|
22
22
|
this.Person = mongoose.model('Person');
|
|
23
23
|
this.Round = mongoose.model('Round');
|
|
24
|
-
this.Snapshot = mongoose.model('Snapshot');
|
|
25
24
|
|
|
26
25
|
// turn on debug if in development and not explicitly turned off
|
|
27
26
|
mongoose.set('debug', (!config.suppressDebugLog && this.env == 'development'));
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const vm = require('node:vm');
|
|
6
|
+
const { test } = require('node:test');
|
|
7
|
+
const root = path.resolve(__dirname, '../..');
|
|
8
|
+
const files = fs.existsSync(path.join(root, 'lib/runtimeSecrets.js'))
|
|
9
|
+
? ['lib/runtimeSecrets.js']
|
|
10
|
+
: fs.readdirSync(root).flatMap(dir => ['runtimeSecrets.js', 'runtime_secrets.js']
|
|
11
|
+
.map(name => path.join(dir, 'src/libs', name)).filter(file => fs.existsSync(path.join(root, file))));
|
|
12
|
+
assert(files.length > 0, 'No runtime loaders found');
|
|
13
|
+
|
|
14
|
+
function fixture(file, env = {}) {
|
|
15
|
+
const process = { env: { NODE_ENV: 'test', ...env } };
|
|
16
|
+
const module = { exports: {} };
|
|
17
|
+
const warnings = [];
|
|
18
|
+
class Client { constructor(options) { this.options = options; } }
|
|
19
|
+
class Command { constructor(input) { this.input = input; } }
|
|
20
|
+
const sdk = { SecretsManager: Client, SecretsManagerClient: Client, GetSecretValueCommand: Command };
|
|
21
|
+
vm.runInNewContext(fs.readFileSync(path.join(root, file), 'utf8'), {
|
|
22
|
+
module, exports: module.exports, process, Buffer,
|
|
23
|
+
console: { warn: (...args) => warnings.push(args), info: () => {} },
|
|
24
|
+
require: name => {
|
|
25
|
+
assert(['aws-sdk', '@aws-sdk/client-secrets-manager'].includes(name));
|
|
26
|
+
return sdk;
|
|
27
|
+
}
|
|
28
|
+
}, { filename: file });
|
|
29
|
+
const loader = module.exports;
|
|
30
|
+
const values = Object.fromEntries((loader.REQUIRED_KEYS || ['DB_URI']).map(key => [key, 'synthetic-' + key]));
|
|
31
|
+
const response = () => ({ SecretString: JSON.stringify(values) });
|
|
32
|
+
const client = fn => ({ send: fn, getSecretValue: () => ({ promise: fn }) });
|
|
33
|
+
return { loader, env: process.env, values, response, client, warnings };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const file of files) {
|
|
37
|
+
test(file + ': explicit secret ID required in deployed runtimes', async () => {
|
|
38
|
+
for (const env of [
|
|
39
|
+
{ NODE_ENV: 'production', RUNTIME_SECRET_REQUIRED: 'false' },
|
|
40
|
+
{ AWS_LAMBDA_FUNCTION_NAME: 'synthetic-function', RUNTIME_SECRET_REQUIRED: 'false' },
|
|
41
|
+
{ RUNTIME_SECRET_REQUIRED: 'true' },
|
|
42
|
+
{ NODE_ENV: 'production', RUNTIME_SECRET_ID: ' ' }
|
|
43
|
+
]) {
|
|
44
|
+
const f = fixture(file, env);
|
|
45
|
+
let called = false;
|
|
46
|
+
await assert.rejects(f.loader.loadRuntimeSecrets({ client: f.client(async () => { called = true; }) }), /RUNTIME_SECRET_ID is required/);
|
|
47
|
+
assert.equal(called, false);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
test(file + ': failed production fetch can recover without restarting', async () => {
|
|
51
|
+
const f = fixture(file, { NODE_ENV: 'production', RUNTIME_SECRET_ID: '/synthetic/secret', RUNTIME_SECRET_REQUIRED: 'false' });
|
|
52
|
+
let calls = 0;
|
|
53
|
+
const client = f.client(async () => { if (++calls === 1) throw new Error('synthetic transient failure'); return f.response(); });
|
|
54
|
+
await assert.rejects(f.loader.loadRuntimeSecrets({ client }), /synthetic transient failure/);
|
|
55
|
+
assert.equal((await f.loader.loadRuntimeSecrets({ client })).source, 'secrets_manager');
|
|
56
|
+
assert.equal(calls, 2);
|
|
57
|
+
assert.equal(f.warnings.length, 0);
|
|
58
|
+
});
|
|
59
|
+
test(file + ': concurrent calls share one fetch and successful values stay cached', async () => {
|
|
60
|
+
const f = fixture(file, { RUNTIME_SECRET_ID: '/synthetic/secret' });
|
|
61
|
+
let calls = 0, resolve;
|
|
62
|
+
const client = f.client(() => { calls++; return new Promise(done => { resolve = done; }); });
|
|
63
|
+
const first = f.loader.loadRuntimeSecrets({ client });
|
|
64
|
+
const second = f.loader.loadRuntimeSecrets({ client });
|
|
65
|
+
assert.equal(first, second);
|
|
66
|
+
resolve(f.response());
|
|
67
|
+
await Promise.all([first, second]);
|
|
68
|
+
await f.loader.loadRuntimeSecrets({ client });
|
|
69
|
+
assert.equal(calls, 1);
|
|
70
|
+
for (const key of Object.keys(f.values)) assert.equal(f.env[key], f.values[key]);
|
|
71
|
+
});
|
|
72
|
+
test(file + ': invalid secret never partially overwrites configuration and can retry', async () => {
|
|
73
|
+
const f = fixture(file, { NODE_ENV: 'production', RUNTIME_SECRET_ID: '/synthetic/secret' });
|
|
74
|
+
const keys = Object.keys(f.values);
|
|
75
|
+
for (const key of keys) f.env[key] = 'old-synthetic-value';
|
|
76
|
+
let calls = 0;
|
|
77
|
+
const incomplete = { ...f.values, [keys.at(-1)]: '' };
|
|
78
|
+
const client = f.client(async () => ++calls === 1 ? { SecretString: JSON.stringify(incomplete) } : f.response());
|
|
79
|
+
await assert.rejects(f.loader.loadRuntimeSecrets({ client }));
|
|
80
|
+
for (const key of keys) assert.equal(f.env[key], 'old-synthetic-value');
|
|
81
|
+
assert.equal((await f.loader.loadRuntimeSecrets({ client })).source, 'secrets_manager');
|
|
82
|
+
});
|
|
83
|
+
test(file + ': development fallback does not prevent a later successful fetch', async () => {
|
|
84
|
+
const f = fixture(file, { RUNTIME_SECRET_ID: '/synthetic/secret' });
|
|
85
|
+
let calls = 0;
|
|
86
|
+
const client = f.client(async () => { if (++calls === 1) throw new Error('synthetic denied'); return f.response(); });
|
|
87
|
+
try { await f.loader.loadRuntimeSecrets({ client }); } catch (error) { assert.match(error.message, /synthetic denied/); }
|
|
88
|
+
assert.equal((await f.loader.loadRuntimeSecrets({ client })).source, 'secrets_manager');
|
|
89
|
+
assert.equal(calls, 2);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Runtime secret loading and credential cleanup
|
|
2
|
+
|
|
3
|
+
Successful runtime secrets are loaded once, before configuration, clients, and
|
|
4
|
+
handlers initialize. Values remain cached until process replacement. Failed
|
|
5
|
+
fetches and development fallback results are not cached, so a later call can
|
|
6
|
+
retry; concurrent calls share one pending fetch. AWS SDK retries are bounded
|
|
7
|
+
(three attempts). Invalid secret contents never partially update configuration.
|
|
8
|
+
|
|
9
|
+
Production (`NODE_ENV=production`), deployed Lambda runtimes, and any runtime
|
|
10
|
+
with `RUNTIME_SECRET_REQUIRED=true` require an explicit, nonblank
|
|
11
|
+
`RUNTIME_SECRET_ID`. Setting the required flag to false cannot enable legacy
|
|
12
|
+
fallback in production or Lambda. Explicit development defaults remain unchanged.
|
|
13
|
+
|
|
14
|
+
See the [shared restart and cleanup runbook](https://github.com/Totem-VC/totem-services/blob/main/infrastructure/security-checks/runtime-secrets.md) for release verification, credential cleanup, and remaining limitations.
|
|
15
|
+
|
|
16
|
+
Run `node --test infrastructure/security-checks/runtime-secret-contract.cjs` for isolated loader checks. Loader changes require a normal application release and process replacement to affect running processes.
|
package/lib/helpers.js
CHANGED
|
@@ -4,20 +4,6 @@ var _ = require('underscore');
|
|
|
4
4
|
var utilities = require('@dhyasama/totem-utilities');
|
|
5
5
|
var phoneFormatter = require('phone-formatter');
|
|
6
6
|
|
|
7
|
-
var createSnapshot = module.exports.createSnapshot = function createSnapshot(context, collectionName, doc, createdBy, cb) {
|
|
8
|
-
|
|
9
|
-
context.logger.writeToInfoLog('Creating snapshot - ' + collectionName + ' - ' + createdBy + ' - ' + doc._id);
|
|
10
|
-
|
|
11
|
-
var snapshot = new context.Snapshot({
|
|
12
|
-
createdBy: createdBy,
|
|
13
|
-
collectionName: collectionName,
|
|
14
|
-
doc: doc
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
context.Snapshot.upsert(snapshot, cb);
|
|
18
|
-
|
|
19
|
-
};
|
|
20
|
-
|
|
21
7
|
// Generic, recursive object merging
|
|
22
8
|
var mergeObjects = function mergeObjects(target, source, doNotMergeKeys) {
|
|
23
9
|
|
|
@@ -43,6 +29,10 @@ var mergeObjects = function mergeObjects(target, source, doNotMergeKeys) {
|
|
|
43
29
|
|
|
44
30
|
for (var key in jsonSource) {
|
|
45
31
|
|
|
32
|
+
// Never follow prototype paths or merge inherited source properties.
|
|
33
|
+
// Check before reading target[key], which may resolve to a prototype.
|
|
34
|
+
if (!Object.prototype.hasOwnProperty.call(jsonSource, key)) continue;
|
|
35
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
|
|
46
36
|
if (doNotMergeKeys.indexOf(key) >= 0) continue;
|
|
47
37
|
|
|
48
38
|
var sourceItem = jsonSource[key];
|
package/lib/organizations.js
CHANGED
|
@@ -40,12 +40,6 @@ var merge = function merge(context, target, source, mergedBy, customerId, cb) {
|
|
|
40
40
|
|
|
41
41
|
verifyOkToMerge.bind(null, target, source),
|
|
42
42
|
|
|
43
|
-
// Create a snapshot of the target
|
|
44
|
-
helpers.createSnapshot.bind(null, context, 'Organization', target, username),
|
|
45
|
-
|
|
46
|
-
// Create a snapshot of the source
|
|
47
|
-
helpers.createSnapshot.bind(null, context, 'Organization', source, username),
|
|
48
|
-
|
|
49
43
|
// Merge the source into the target
|
|
50
44
|
mergeSourceIntoTarget.bind(null, target, source),
|
|
51
45
|
|
|
@@ -64,10 +58,10 @@ var merge = function merge(context, target, source, mergedBy, customerId, cb) {
|
|
|
64
58
|
if (err) { return cb(err, null); }
|
|
65
59
|
|
|
66
60
|
// result from saveOrganization
|
|
67
|
-
var updatedTarget = results[
|
|
61
|
+
var updatedTarget = results[3];
|
|
68
62
|
|
|
69
63
|
// result from updateOrganizations within updateReferences
|
|
70
|
-
var updatedReferencesOrgs = results[
|
|
64
|
+
var updatedReferencesOrgs = results[4][5];
|
|
71
65
|
|
|
72
66
|
// Check if target had reference(s) updated
|
|
73
67
|
var targetIdStr = target.id && target.id.toString();
|
package/lib/people.js
CHANGED
|
@@ -33,12 +33,6 @@ var merge = function merge(context, target, source, mergedBy, customerId, cb) {
|
|
|
33
33
|
|
|
34
34
|
async.series([
|
|
35
35
|
|
|
36
|
-
// Create a snapshot of the target
|
|
37
|
-
helpers.createSnapshot.bind(null, context, 'Person', target, username),
|
|
38
|
-
|
|
39
|
-
// Create a snapshot of the source
|
|
40
|
-
helpers.createSnapshot.bind(null, context, 'Person', source, username),
|
|
41
|
-
|
|
42
36
|
// Merge the source into the target
|
|
43
37
|
mergeSourceIntoTarget.bind(null, target, source),
|
|
44
38
|
|
|
@@ -57,7 +51,7 @@ var merge = function merge(context, target, source, mergedBy, customerId, cb) {
|
|
|
57
51
|
if (err) return cb(err, null);
|
|
58
52
|
|
|
59
53
|
// result from savePerson
|
|
60
|
-
var updatedTarget = results[
|
|
54
|
+
var updatedTarget = results[2];
|
|
61
55
|
|
|
62
56
|
// return updated target
|
|
63
57
|
return cb(null, updatedTarget);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var SecretsManager = require('@aws-sdk/client-secrets-manager');
|
|
4
|
+
var SECRET_ID = '/totem/dev/totem-merge';
|
|
5
|
+
var loadPromise;
|
|
6
|
+
|
|
7
|
+
// Production and deployed Lambda runtimes must never use legacy credentials.
|
|
8
|
+
const secretsRequired = () => process.env.RUNTIME_SECRET_REQUIRED === 'true' ||
|
|
9
|
+
process.env.NODE_ENV === 'production' || Boolean(process.env.AWS_LAMBDA_FUNCTION_NAME);
|
|
10
|
+
|
|
11
|
+
function applySecret(response) {
|
|
12
|
+
var values = JSON.parse(response.SecretString || Buffer.from(response.SecretBinary).toString('utf8'));
|
|
13
|
+
if (!values.DB_URI || typeof values.DB_URI !== 'string') throw new Error('Runtime secret missing DB_URI');
|
|
14
|
+
process.env.DB_URI = values.DB_URI;
|
|
15
|
+
return { source: 'secrets_manager' };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function load(client) {
|
|
19
|
+
var secretId = (process.env.RUNTIME_SECRET_ID || '').trim();
|
|
20
|
+
if (!secretId && secretsRequired()) throw new Error('RUNTIME_SECRET_ID is required for this runtime');
|
|
21
|
+
secretId = secretId || SECRET_ID;
|
|
22
|
+
return applySecret(await client.send(new SecretsManager.GetSecretValueCommand({ SecretId: secretId })));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function loadRuntimeSecrets(options) {
|
|
26
|
+
if (!loadPromise) {
|
|
27
|
+
const client = (options && options.client) || new SecretsManager.SecretsManagerClient({ region: process.env.AWS_REGION || 'us-east-1', maxAttempts: 3 });
|
|
28
|
+
// Cache successful loads until process replacement. Rejected/legacy loads
|
|
29
|
+
// are retried on the next call; concurrent callers share this attempt.
|
|
30
|
+
loadPromise = load(client).then(result => {
|
|
31
|
+
if (result.source !== 'secrets_manager') loadPromise = undefined;
|
|
32
|
+
return result;
|
|
33
|
+
}, error => {
|
|
34
|
+
loadPromise = undefined;
|
|
35
|
+
throw error;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return loadPromise;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resetForTest() { loadPromise = undefined; }
|
|
42
|
+
|
|
43
|
+
module.exports = { SECRET_ID: SECRET_ID, applySecret: applySecret,
|
|
44
|
+
loadRuntimeSecrets: loadRuntimeSecrets, resetForTest: resetForTest };
|
package/package.json
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dhyasama/totem-merge",
|
|
3
|
-
"version": "7.1.
|
|
3
|
+
"version": "7.1.2",
|
|
4
4
|
"description": "Org and person merging package",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"
|
|
8
|
-
"start": "NODE_ENV=test node ./cli",
|
|
7
|
+
"start": "node ./bin/bootstrap",
|
|
9
8
|
"test": "NODE_ENV=test mocha -R spec -t 10000"
|
|
10
9
|
},
|
|
11
10
|
"publishConfig": {
|
|
@@ -16,7 +15,8 @@
|
|
|
16
15
|
"node": ">=24 <25"
|
|
17
16
|
},
|
|
18
17
|
"dependencies": {
|
|
19
|
-
"@
|
|
18
|
+
"@aws-sdk/client-secrets-manager": "^3.883.0",
|
|
19
|
+
"@dhyasama/totem-logger": "3.0.0",
|
|
20
20
|
"@dhyasama/totem-utilities": "^4.0.0",
|
|
21
21
|
"async": "^2.6.4",
|
|
22
22
|
"commander": "^2.20.3",
|
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
"underscore": "^1.13.6"
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
|
-
"mongoose": "^8.
|
|
27
|
+
"mongoose": "^8.24.3"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@dhyasama/totem-models": "
|
|
31
|
-
"mocha": "^
|
|
30
|
+
"@dhyasama/totem-models": "14.0.0",
|
|
31
|
+
"mocha": "^12.0.2",
|
|
32
32
|
"mocha-mongoose": "^1.1.1",
|
|
33
33
|
"should": "^8.2.2"
|
|
34
34
|
}
|
package/test/config.js
CHANGED
|
@@ -7,7 +7,7 @@ module.exports = {
|
|
|
7
7
|
LPS_ENABLED: false,
|
|
8
8
|
|
|
9
9
|
db: {
|
|
10
|
-
uri: 'mongodb://
|
|
10
|
+
uri: process.env.TEST_DB_URI || 'mongodb://127.0.0.1:27017/totem-merge-test'
|
|
11
11
|
},
|
|
12
12
|
|
|
13
13
|
crypto: {
|
package/test/index.js
CHANGED
|
@@ -2,11 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
var config = require('./config');
|
|
4
4
|
var dbUri = config.db.uri;
|
|
5
|
-
|
|
6
|
-
if (dbUri.indexOf('@test-shard-') === -1) {
|
|
7
|
-
console.log('Not a test db. Quitting...');
|
|
8
|
-
process.exit();
|
|
9
|
-
}
|
|
5
|
+
var describeMerge = process.env.TEST_DB_URI ? describe : describe.skip;
|
|
10
6
|
|
|
11
7
|
var should = require("should");
|
|
12
8
|
var mongoose = require('mongoose');
|
|
@@ -26,7 +22,7 @@ var customer;
|
|
|
26
22
|
var org1, org2, org3, org4, org5, org6;
|
|
27
23
|
var person1, person2, person3;
|
|
28
24
|
|
|
29
|
-
|
|
25
|
+
describeMerge('Merge', function() {
|
|
30
26
|
|
|
31
27
|
// connect to test db
|
|
32
28
|
before(async function() {
|
|
@@ -179,7 +175,7 @@ describe('Merge', function() {
|
|
|
179
175
|
should.not.exist(err);
|
|
180
176
|
should.exist(newOrg1);
|
|
181
177
|
|
|
182
|
-
Organization.getById(newOrg1.id, { CUSTOMER_ID: customer._id
|
|
178
|
+
Organization.getById(newOrg1.id, { CUSTOMER_ID: customer._id }, function(err, result1) {
|
|
183
179
|
|
|
184
180
|
should.not.exist(err);
|
|
185
181
|
should.exist(result1);
|
|
@@ -191,7 +187,7 @@ describe('Merge', function() {
|
|
|
191
187
|
should.not.exist(err);
|
|
192
188
|
should.exist(newOrg2);
|
|
193
189
|
|
|
194
|
-
Organization.getById(newOrg2.id, { CUSTOMER_ID: customer._id
|
|
190
|
+
Organization.getById(newOrg2.id, { CUSTOMER_ID: customer._id }, function(err, result2) {
|
|
195
191
|
|
|
196
192
|
should.not.exist(err);
|
|
197
193
|
should.exist(result2);
|
|
@@ -203,7 +199,7 @@ describe('Merge', function() {
|
|
|
203
199
|
should.not.exist(err);
|
|
204
200
|
should.exist(newOrg3);
|
|
205
201
|
|
|
206
|
-
Organization.getById(newOrg3.id, { CUSTOMER_ID: customer._id
|
|
202
|
+
Organization.getById(newOrg3.id, { CUSTOMER_ID: customer._id }, function(err, result3) {
|
|
207
203
|
|
|
208
204
|
should.not.exist(err);
|
|
209
205
|
should.exist(result3);
|
|
@@ -215,7 +211,7 @@ describe('Merge', function() {
|
|
|
215
211
|
should.not.exist(err);
|
|
216
212
|
should.exist(newOrg4);
|
|
217
213
|
|
|
218
|
-
Organization.getById(newOrg4.id, { CUSTOMER_ID: customer._id
|
|
214
|
+
Organization.getById(newOrg4.id, { CUSTOMER_ID: customer._id }, function(err, result4) {
|
|
219
215
|
|
|
220
216
|
should.not.exist(err);
|
|
221
217
|
should.exist(result4);
|
|
@@ -227,7 +223,7 @@ describe('Merge', function() {
|
|
|
227
223
|
should.not.exist(err);
|
|
228
224
|
should.exist(newOrg5);
|
|
229
225
|
|
|
230
|
-
Organization.getById(newOrg5.id, { CUSTOMER_ID: customer._id
|
|
226
|
+
Organization.getById(newOrg5.id, { CUSTOMER_ID: customer._id }, function(err, result5) {
|
|
231
227
|
|
|
232
228
|
should.not.exist(err);
|
|
233
229
|
should.exist(result5);
|
|
@@ -239,7 +235,7 @@ describe('Merge', function() {
|
|
|
239
235
|
should.not.exist(err);
|
|
240
236
|
should.exist(newOrg6);
|
|
241
237
|
|
|
242
|
-
Organization.getById(newOrg6.id, { CUSTOMER_ID: customer._id
|
|
238
|
+
Organization.getById(newOrg6.id, { CUSTOMER_ID: customer._id }, function(err, result6) {
|
|
243
239
|
|
|
244
240
|
should.not.exist(err);
|
|
245
241
|
should.exist(result6);
|
|
@@ -826,7 +822,7 @@ describe('Merge', function() {
|
|
|
826
822
|
should.not.exist(err);
|
|
827
823
|
should.exist(merged);
|
|
828
824
|
|
|
829
|
-
Organization.getById(result.id, { CUSTOMER_ID: customer._id
|
|
825
|
+
Organization.getById(result.id, { CUSTOMER_ID: customer._id }, function(err, finalResult) {
|
|
830
826
|
|
|
831
827
|
should.not.exist(err);
|
|
832
828
|
should.exist(finalResult);
|
|
@@ -887,7 +883,7 @@ describe('Merge', function() {
|
|
|
887
883
|
should.not.exist(err);
|
|
888
884
|
should.exist(merged);
|
|
889
885
|
|
|
890
|
-
Organization.getById(result.id, { CUSTOMER_ID: customer._id
|
|
886
|
+
Organization.getById(result.id, { CUSTOMER_ID: customer._id }, function(err, finalResult) {
|
|
891
887
|
|
|
892
888
|
should.not.exist(err);
|
|
893
889
|
should.exist(finalResult);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var assert = require('node:assert/strict');
|
|
4
|
+
var mongoose = require('mongoose');
|
|
5
|
+
var mergeObjects = require('../lib/helpers').mergeObjects;
|
|
6
|
+
|
|
7
|
+
describe('mergeObjects security and compatibility', function() {
|
|
8
|
+
it('does not traverse __proto__ at the root or inside nested objects', function() {
|
|
9
|
+
[false, true].forEach(function(nested) {
|
|
10
|
+
var prototype = { mergeSecurityMarker: null };
|
|
11
|
+
var object = Object.create(prototype);
|
|
12
|
+
var payload = JSON.parse('{"__proto__":{"mergeSecurityMarker":"changed"}}');
|
|
13
|
+
mergeObjects(nested ? { profile: object } : object,
|
|
14
|
+
nested ? { profile: payload } : payload, []);
|
|
15
|
+
assert.equal(prototype.mergeSecurityMarker, null);
|
|
16
|
+
assert.equal(Object.getPrototypeOf(object), prototype);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('does not write through Object.prototype', function() {
|
|
21
|
+
var key = '__totemMergeSecurityTest';
|
|
22
|
+
assert.equal(Object.hasOwn(Object.prototype, key), false);
|
|
23
|
+
Object.defineProperty(Object.prototype, key, {
|
|
24
|
+
value: null, writable: true, configurable: true
|
|
25
|
+
});
|
|
26
|
+
try {
|
|
27
|
+
mergeObjects({}, JSON.parse('{"__proto__":{"__totemMergeSecurityTest":true}}'), []);
|
|
28
|
+
assert.equal(Object.prototype[key], null);
|
|
29
|
+
assert.equal(({})[key], null);
|
|
30
|
+
} finally {
|
|
31
|
+
delete Object.prototype[key];
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('ignores constructor and prototype keys at each recursive level', function() {
|
|
36
|
+
var target = {
|
|
37
|
+
constructor: { prototype: { marker: null } },
|
|
38
|
+
prototype: { marker: null },
|
|
39
|
+
profile: { constructor: null, prototype: null, label: null }
|
|
40
|
+
};
|
|
41
|
+
var source = JSON.parse('{"constructor":{"prototype":{"marker":"bad"}},' +
|
|
42
|
+
'"prototype":{"marker":"bad"},' +
|
|
43
|
+
'"profile":{"constructor":"bad","prototype":"bad","label":"allowed"}}');
|
|
44
|
+
mergeObjects(target, source, []);
|
|
45
|
+
assert.equal(target.constructor.prototype.marker, null);
|
|
46
|
+
assert.equal(target.prototype.marker, null);
|
|
47
|
+
assert.deepEqual(target.profile, { constructor: null, prototype: null, label: 'allowed' });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('ignores inherited source properties returned by a document conversion', function() {
|
|
51
|
+
var plain = Object.create({ inherited: 'not source data' });
|
|
52
|
+
plain.name = 'Allowed';
|
|
53
|
+
var target = { inherited: null, name: null };
|
|
54
|
+
mergeObjects(target, { toObject: function() { return plain; } }, []);
|
|
55
|
+
assert.deepEqual(target, { inherited: null, name: 'Allowed' });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('preserves target precedence, exclusions, falsy values and source data', function() {
|
|
59
|
+
var target = {
|
|
60
|
+
name: 'Keep', description: null, enabled: false, count: 0,
|
|
61
|
+
empty: '', omitted: null, zero: null, disabled: null,
|
|
62
|
+
profile: { label: null, retained: 'Keep nested', omitted: null }
|
|
63
|
+
};
|
|
64
|
+
var source = {
|
|
65
|
+
name: 'Replace', description: 'Fill', enabled: true, count: 10,
|
|
66
|
+
empty: 'Replace', omitted: 'Excluded', zero: 0, disabled: false,
|
|
67
|
+
missing: 'Do not introduce',
|
|
68
|
+
profile: { label: 'Fill nested', retained: 'Replace', omitted: 'Excluded' }
|
|
69
|
+
};
|
|
70
|
+
var before = JSON.stringify(source);
|
|
71
|
+
assert.equal(mergeObjects(target, source, ['omitted']), target);
|
|
72
|
+
assert.deepEqual(target, {
|
|
73
|
+
name: 'Keep', description: 'Fill', enabled: false, count: 0,
|
|
74
|
+
empty: '', omitted: null, zero: null, disabled: null,
|
|
75
|
+
profile: { label: 'Fill nested', retained: 'Keep nested', omitted: null }
|
|
76
|
+
});
|
|
77
|
+
assert.equal(JSON.stringify(source), before);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('preserves array ordering, ObjectId deduplication and people/phone keys', function() {
|
|
81
|
+
var first = new mongoose.Types.ObjectId();
|
|
82
|
+
var second = new mongoose.Types.ObjectId();
|
|
83
|
+
var target = {
|
|
84
|
+
aliases: ['First'], refs: [first],
|
|
85
|
+
people: [{ person: first, title: 'Keep' }],
|
|
86
|
+
phone: [{ number: '(212) 555-0100', label: 'Keep' }]
|
|
87
|
+
};
|
|
88
|
+
mergeObjects(target, {
|
|
89
|
+
aliases: ['First', 'Second'], refs: [first, second],
|
|
90
|
+
people: [{ person: first, title: 'Replace' }, { person: second, title: 'New' }],
|
|
91
|
+
phone: [{ number: '2125550100', label: 'Replace' }, { number: '2125550101', label: 'New' }]
|
|
92
|
+
}, []);
|
|
93
|
+
assert.deepEqual(target.aliases, ['First', 'Second']);
|
|
94
|
+
assert.deepEqual(target.refs.map(String), [String(first), String(second)]);
|
|
95
|
+
assert.equal(target.people.length, 2);
|
|
96
|
+
assert.equal(target.people[0].title, 'Keep');
|
|
97
|
+
assert.equal(target.people[1].title, 'New');
|
|
98
|
+
assert.equal(target.phone.length, 2);
|
|
99
|
+
assert.equal(target.phone[0].label, 'Keep');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('merges real Mongoose documents without invoking virtual getters', function() {
|
|
103
|
+
var isolated = new mongoose.Mongoose();
|
|
104
|
+
var schema = new isolated.Schema({
|
|
105
|
+
name: String, description: { type: String, default: null }, aliases: [String],
|
|
106
|
+
profile: { label: { type: String, default: null } }
|
|
107
|
+
});
|
|
108
|
+
schema.virtual('unsafeVirtual').get(function() { throw new Error('Virtual invoked'); });
|
|
109
|
+
var Model = isolated.model('MergeSecurityFixture', schema);
|
|
110
|
+
var target = new Model({ name: 'Keep', aliases: ['First'] });
|
|
111
|
+
var source = new Model({ name: 'Replace', description: 'Fill', aliases: ['First', 'Second'], profile: { label: 'Nested' } });
|
|
112
|
+
mergeObjects(target, source, ['_id', '__v']);
|
|
113
|
+
assert.equal(target.name, 'Keep');
|
|
114
|
+
assert.equal(target.description, 'Fill');
|
|
115
|
+
assert.deepEqual(Array.from(target.aliases), ['First', 'Second']);
|
|
116
|
+
assert.equal(target.profile.label, 'Nested');
|
|
117
|
+
assert.equal(source.name, 'Replace');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var assert = require('node:assert/strict');
|
|
4
|
+
var fs = require('node:fs');
|
|
5
|
+
var path = require('node:path');
|
|
6
|
+
var runtimeSecrets = require('../lib/runtimeSecrets');
|
|
7
|
+
|
|
8
|
+
describe('totem-merge runtime secrets', function() {
|
|
9
|
+
afterEach(function() { runtimeSecrets.resetForTest(); });
|
|
10
|
+
|
|
11
|
+
it('loads DB_URI from the dedicated development secret', async function() {
|
|
12
|
+
var client = { send: async function(command) {
|
|
13
|
+
assert.equal(command.input.SecretId, runtimeSecrets.SECRET_ID);
|
|
14
|
+
return { SecretString: JSON.stringify({ DB_URI: 'mongodb://test-host/test' }) };
|
|
15
|
+
} };
|
|
16
|
+
delete process.env.RUNTIME_SECRET_ID;
|
|
17
|
+
await runtimeSecrets.loadRuntimeSecrets({ client: client });
|
|
18
|
+
assert.equal(process.env.DB_URI, 'mongodb://test-host/test');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('contains no credentialed MongoDB URI in source configuration', function() {
|
|
22
|
+
var root = path.join(__dirname, '..');
|
|
23
|
+
['config.js', 'test/config.js'].forEach(function(file) {
|
|
24
|
+
assert.doesNotMatch(fs.readFileSync(path.join(root, file), 'utf8'), /mongodb(?:\+srv)?:\/\/[^\s]+:[^\s]+@/);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
});
|
package/lib/changeBSON.js
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
var fs = require('fs');
|
|
4
|
-
var filePath = './node_modules/mongoose-post-find/node_modules/bson/ext/index.js';
|
|
5
|
-
var config = require('./../config');
|
|
6
|
-
var Logger = require('@dhyasama/totem-logger');
|
|
7
|
-
var logger = new Logger(config.logentries);
|
|
8
|
-
|
|
9
|
-
fs.readFile(filePath, 'utf8', function (err, data) {
|
|
10
|
-
|
|
11
|
-
if (err) { logger.handleErr(err); }
|
|
12
|
-
else {
|
|
13
|
-
|
|
14
|
-
var result = data.replace("bson = require('../build/Release/bson');", "bson = require('bson');");
|
|
15
|
-
|
|
16
|
-
fs.writeFile(filePath, result, 'utf8', function (err) {
|
|
17
|
-
if (err) { logger.handleErr(err); }
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
});
|