-
Notifications
You must be signed in to change notification settings - Fork 42
Expand file tree
/
Copy pathconfig.mts
More file actions
431 lines (395 loc) · 14 KB
/
config.mts
File metadata and controls
431 lines (395 loc) · 14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
/**
* Configuration utilities for Socket CLI.
* Manages CLI configuration including API tokens, org settings, and preferences.
*
* Configuration Hierarchy (highest priority first):
* 1. Environment variables (SOCKET_CLI_*)
* 2. Command-line --config flag
* 3. Persisted config file (base64 encoded JSON)
*
* Supported Config Keys:
* - apiBaseUrl: Socket API endpoint URL
* - apiProxy: Proxy for API requests
* - apiToken: Authentication token for Socket API
* - defaultOrg/org: Default organization slug
* - enforcedOrgs: Organizations with enforced security policies
*
* Key Functions:
* - findSocketYmlSync: Locate socket.yml configuration file
* - getConfigValue: Retrieve configuration value by key
* - overrideCachedConfig: Apply temporary config overrides
* - updateConfigValue: Persist configuration changes
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import config from '@socketsecurity/config'
import { debugDir, debugFn } from '@socketsecurity/registry/lib/debug'
import { safeReadFileSync } from '@socketsecurity/registry/lib/fs'
import { logger } from '@socketsecurity/registry/lib/logger'
import { naturalCompare } from '@socketsecurity/registry/lib/sorts'
import { debugConfig } from './debug.mts'
import { getEditableJsonClass } from './editable-json.mts'
import constants, {
CONFIG_KEY_API_BASE_URL,
CONFIG_KEY_API_PROXY,
CONFIG_KEY_API_TOKEN,
CONFIG_KEY_DEFAULT_ORG,
CONFIG_KEY_ENFORCED_ORGS,
CONFIG_KEY_ORG,
SOCKET_YAML,
SOCKET_YML,
} from '../constants.mts'
import { getErrorCause } from './errors.mts'
import type { CResult } from '../types.mts'
import type { SocketYml } from '@socketsecurity/config'
export interface LocalConfig {
apiBaseUrl?: string | null | undefined
// @deprecated ; use apiToken. when loading a config, if this prop exists it
// is deleted and set to apiToken instead, and then persisted.
// should only happen once for legacy users.
apiKey?: string | null | undefined
apiProxy?: string | null | undefined
apiToken?: string | null | undefined
defaultOrg?: string | undefined
enforcedOrgs?: string[] | readonly string[] | null | undefined
skipAskToPersistDefaultOrg?: boolean | undefined
// Convenience alias for defaultOrg.
org?: string | undefined
}
const sensitiveConfigKeyLookup: Set<keyof LocalConfig> = new Set([
CONFIG_KEY_API_TOKEN,
])
const supportedConfig: Map<keyof LocalConfig, string> = new Map([
[CONFIG_KEY_API_BASE_URL, 'Base URL of the Socket API endpoint'],
[CONFIG_KEY_API_PROXY, 'A proxy through which to access the Socket API'],
[
CONFIG_KEY_API_TOKEN,
'The Socket API token required to access most Socket API endpoints',
],
[
CONFIG_KEY_DEFAULT_ORG,
'The default org slug to use; usually the org your Socket API token has access to. When set, all orgSlug arguments are implied to be this value.',
],
[
CONFIG_KEY_ENFORCED_ORGS,
'Orgs in this list have their security policies enforced on this machine',
],
[
'skipAskToPersistDefaultOrg',
'This flag prevents the Socket CLI from asking you to persist the org slug when you selected one interactively',
],
[CONFIG_KEY_ORG, 'Alias for defaultOrg'],
])
const supportedConfigEntries = [...supportedConfig.entries()].sort((a, b) =>
naturalCompare(a[0], b[0]),
)
const supportedConfigKeys = supportedConfigEntries.map(p => p[0])
function getConfigValues(): LocalConfig {
if (_cachedConfig === undefined) {
// Order: env var > --config flag > file
_cachedConfig = {} as LocalConfig
const { socketAppDataPath } = constants
if (socketAppDataPath) {
const configFilePath = path.join(socketAppDataPath, 'config.json')
const raw = safeReadFileSync(configFilePath)
if (raw) {
try {
Object.assign(
_cachedConfig,
JSON.parse(Buffer.from(raw, 'base64').toString()),
)
debugConfig(configFilePath, true)
} catch (e) {
logger.warn(`Failed to parse config at ${configFilePath}`)
debugConfig(configFilePath, false, e)
}
// Normalize apiKey to apiToken and persist it.
// This is a one time migration per user.
if (_cachedConfig['apiKey']) {
const token = _cachedConfig['apiKey']
delete _cachedConfig['apiKey']
updateConfigValue(CONFIG_KEY_API_TOKEN, token)
}
} else {
mkdirSync(socketAppDataPath, { recursive: true })
}
}
}
return _cachedConfig
}
function normalizeConfigKey(
key: keyof LocalConfig,
): CResult<keyof LocalConfig> {
// Note: apiKey was the old name of the token. When we load a config with
// property apiKey, we'll copy that to apiToken and delete the old property.
// We added `org` as a convenience alias for `defaultOrg`
const normalizedKey =
key === 'apiKey'
? CONFIG_KEY_API_TOKEN
: key === CONFIG_KEY_ORG
? CONFIG_KEY_DEFAULT_ORG
: key
if (!isSupportedConfigKey(normalizedKey)) {
return {
ok: false,
message: `Invalid config key: ${normalizedKey}`,
data: undefined,
}
}
return { ok: true, data: normalizedKey }
}
export type FoundSocketYml = {
path: string
parsed: SocketYml
}
export function findSocketYmlSync(
dir = process.cwd(),
): CResult<FoundSocketYml | undefined> {
let prevDir = null
while (dir !== prevDir) {
let ymlPath = path.join(dir, SOCKET_YML)
let yml = safeReadFileSync(ymlPath)
if (yml === undefined) {
ymlPath = path.join(dir, SOCKET_YAML)
yml = safeReadFileSync(ymlPath)
}
if (typeof yml === 'string') {
try {
return {
ok: true,
data: {
path: ymlPath,
parsed: config.parseSocketConfig(yml),
},
}
} catch (e) {
debugFn('error', `Failed to parse config file: ${ymlPath}`)
debugDir('error', e)
return {
ok: false,
message: `Found file but was unable to parse ${ymlPath}`,
cause: getErrorCause(e),
}
}
}
prevDir = dir
dir = path.join(dir, '..')
}
return { ok: true, data: undefined }
}
export function getConfigValue<Key extends keyof LocalConfig>(
key: Key,
): CResult<LocalConfig[Key]> {
const localConfig = getConfigValues()
const keyResult = normalizeConfigKey(key)
if (!keyResult.ok) {
return keyResult
}
return { ok: true, data: localConfig[keyResult.data as Key] }
}
// This version squashes errors, returning undefined instead.
// Should be used when we can reasonably predict the call can't fail.
export function getConfigValueOrUndef<Key extends keyof LocalConfig>(
key: Key,
): LocalConfig[Key] | undefined {
const localConfig = getConfigValues()
const keyResult = normalizeConfigKey(key)
if (!keyResult.ok) {
return undefined
}
return localConfig[keyResult.data as Key]
}
// Ensure export because dist/utils.js is required in src/constants.mts.
// eslint-disable-next-line n/exports-style
if (typeof exports === 'object' && exports !== null) {
// eslint-disable-next-line n/exports-style
exports.getConfigValueOrUndef = getConfigValueOrUndef
}
export function getSupportedConfigEntries() {
return [...supportedConfigEntries]
}
export function getSupportedConfigKeys() {
return [...supportedConfigKeys]
}
export function isConfigFromFlag() {
return _configFromFlag
}
export function isSensitiveConfigKey(key: string): key is keyof LocalConfig {
return sensitiveConfigKeyLookup.has(key as keyof LocalConfig)
}
export function isSupportedConfigKey(key: string): key is keyof LocalConfig {
return supportedConfig.has(key as keyof LocalConfig)
}
let _cachedConfig: LocalConfig | undefined
// When using --config or SOCKET_CLI_CONFIG, do not persist the config.
let _configFromFlag = false
/**
* Reset config cache for testing purposes.
* This allows tests to start with a fresh config state.
* @internal
*/
export function resetConfigForTesting(): void {
_cachedConfig = undefined
_configFromFlag = false
}
export function overrideCachedConfig(jsonConfig: unknown): CResult<undefined> {
debugFn('notice', 'override: full config (not stored)')
let config
try {
config = JSON.parse(String(jsonConfig))
if (!config || typeof config !== 'object') {
// `null` is valid json, so are primitive values.
// They're not valid config objects :)
return {
ok: false,
message: 'Could not parse Config as JSON',
cause:
"Could not JSON parse the config override. Make sure it's a proper JSON object (double-quoted keys and strings, no unquoted `undefined`) and try again.",
}
}
} catch {
// Force set an empty config to prevent accidentally using system settings.
_cachedConfig = {} as LocalConfig
_configFromFlag = true
return {
ok: false,
message: 'Could not parse Config as JSON',
cause:
"Could not JSON parse the config override. Make sure it's a proper JSON object (double-quoted keys and strings, no unquoted `undefined`) and try again.",
}
}
// @ts-ignore Override an illegal object.
_cachedConfig = config as LocalConfig
_configFromFlag = true
// Normalize apiKey to apiToken.
if (_cachedConfig['apiKey']) {
if (_cachedConfig['apiToken']) {
logger.warn(
'Note: The config override had both apiToken and apiKey. Using the apiToken value. Remove the apiKey to get rid of this message.',
)
}
_cachedConfig['apiToken'] = _cachedConfig['apiKey']
delete _cachedConfig['apiKey']
}
return { ok: true, data: undefined }
}
export function overrideConfigApiToken(apiToken: unknown) {
debugFn('notice', 'override: Socket API token (not stored)')
// Set token to the local cached config and mark it read-only so it doesn't persist.
_cachedConfig = {
...config,
...(apiToken === undefined ? {} : { apiToken: String(apiToken) }),
} as LocalConfig
_configFromFlag = true
}
let _pendingSave = false
export function updateConfigValue<Key extends keyof LocalConfig>(
configKey: keyof LocalConfig,
value: LocalConfig[Key],
): CResult<undefined | string> {
const localConfig = getConfigValues()
const keyResult = normalizeConfigKey(configKey)
if (!keyResult.ok) {
return keyResult
}
const key: Key = keyResult.data as Key
// Implicitly deleting when serializing.
let wasDeleted = value === undefined
if (key === 'skipAskToPersistDefaultOrg') {
if (value === 'true' || value === 'false') {
localConfig['skipAskToPersistDefaultOrg'] = value === 'true'
} else {
delete localConfig['skipAskToPersistDefaultOrg']
wasDeleted = true
}
} else {
if (value === 'undefined' || value === 'true' || value === 'false') {
logger.warn(
`Note: The value is set to "${value}", as a string (!). Use \`socket config unset\` to reset a key.`,
)
}
localConfig[key] = value
}
if (_configFromFlag) {
return {
ok: true,
message: `Config key '${key}' was ${wasDeleted ? 'deleted' : `updated`}`,
data: 'Change applied but not persisted; current config is overridden through env var or flag',
}
}
if (!_pendingSave) {
_pendingSave = true
process.nextTick(() => {
_pendingSave = false
// Capture the config state at write time, not at schedule time.
// This ensures all updates in the same tick are included.
const configToSave = { ...localConfig }
const { socketAppDataPath } = constants
if (socketAppDataPath) {
mkdirSync(socketAppDataPath, { recursive: true })
const configFilePath = path.join(socketAppDataPath, 'config.json')
// Read existing file to preserve formatting, then update with new values.
const existingRaw = safeReadFileSync(configFilePath)
const EditableJson = getEditableJsonClass<LocalConfig>()
const editor = new EditableJson()
if (existingRaw !== undefined) {
const rawString = Buffer.isBuffer(existingRaw)
? existingRaw.toString('utf8')
: existingRaw
try {
const decoded = Buffer.from(rawString, 'base64').toString('utf8')
editor.fromJSON(decoded)
} catch {
// If decoding fails, start fresh.
}
} else {
// Initialize empty editor for new file.
editor.create(configFilePath)
}
// Update with the captured config state.
// Note: We need to handle deletions explicitly since editor.update() only merges.
// First, get all keys from the existing content.
const existingKeys = new Set(
Object.keys(editor.content).filter(k => typeof k === 'string'),
)
const newKeys = new Set(Object.keys(configToSave))
// Delete keys that are in existing but not in new config.
for (const key of existingKeys) {
if (!newKeys.has(key)) {
delete (editor.content as any)[key]
}
}
// Now update with new values.
editor.update(configToSave)
// Use the editor's internal stringify which preserves formatting.
// Extract the formatting symbols from the content.
const INDENT_SYMBOL = Symbol.for('indent')
const NEWLINE_SYMBOL = Symbol.for('newline')
const indent = (editor.content as any)[INDENT_SYMBOL] ?? 2
const newline = (editor.content as any)[NEWLINE_SYMBOL] ?? '\n'
// Strip formatting symbols from content.
const contentToSave: Record<string, unknown> = {}
for (const [key, val] of Object.entries(editor.content)) {
if (typeof key === 'string') {
contentToSave[key] = val
}
}
// Stringify with formatting preserved.
const jsonContent = JSON.stringify(
contentToSave,
undefined,
indent,
).replace(/\n/g, newline)
writeFileSync(
configFilePath,
Buffer.from(jsonContent + newline).toString('base64'),
)
}
})
}
return {
ok: true,
message: `Config key '${key}' was ${wasDeleted ? 'deleted' : `updated`}`,
data: undefined,
}
}