@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.
@@ -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, { role: 'none' }),
89
- Organization.getById.bind(Organization, source, { role: 'none' })
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: 'mongodb://jason:T576G150HPXLA5q4BJ2o2zj747B5030x@development-shard-00-00-gv0f3.mongodb.net:27017,development-shard-00-01-gv0f3.mongodb.net:27017,development-shard-00-02-gv0f3.mongodb.net:27017/development?ssl=true&replicaSet=Development-shard-0&authSource=admin'
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];
@@ -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[5];
61
+ var updatedTarget = results[3];
68
62
 
69
63
  // result from updateOrganizations within updateReferences
70
- var updatedReferencesOrgs = results[6][5];
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[4];
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.0",
3
+ "version": "7.1.2",
4
4
  "description": "Org and person merging package",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
- "prestart": "node ./lib/changeBSON",
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
- "@dhyasama/totem-logger": "^2.1.0",
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.23.0"
27
+ "mongoose": "^8.24.3"
28
28
  },
29
29
  "devDependencies": {
30
- "@dhyasama/totem-models": "^12.14.0",
31
- "mocha": "^5.2.0",
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://gZvwpF5aLFvdc7KFa7qLLiZYv6CTLdfs:TKfdi3LD4583f8EL11k5Y0uukZ151d9n@test-shard-00-00-gv0f3.mongodb.net:27017,test-shard-00-01-gv0f3.mongodb.net:27017,test-shard-00-02-gv0f3.mongodb.net:27017/test?ssl=true&replicaSet=Test-shard-0&authSource=admin'
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
- describe('Merge', function() {
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, role: 'none' }, function(err, result1) {
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, role: 'none' }, function(err, result2) {
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, role: 'none' }, function(err, result3) {
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, role: 'none' }, function(err, result4) {
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, role: 'none' }, function(err, result5) {
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, role: 'none' }, function(err, result6) {
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, role: 'none' }, function(err, finalResult) {
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, role: 'none' }, function(err, finalResult) {
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
- });