@contrast/config 1.32.0 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/common.js CHANGED
@@ -39,8 +39,18 @@ const {
39
39
  UNTRUSTED_DESERIALIZATION,
40
40
  XXE,
41
41
  },
42
+ primordials: { StringPrototypeToLowerCase },
43
+ get,
44
+ isString,
42
45
  } = require('@contrast/common');
43
46
 
47
+ function coerceLowerCase(path) {
48
+ return function(remoteData) {
49
+ const value = get(remoteData, path);
50
+ if (value && isString(value)) return StringPrototypeToLowerCase.call(value);
51
+ };
52
+ }
53
+
44
54
  function protectModeReader(ruleId) {
45
55
  return function (msg) {
46
56
  const remoteSetting = msg?.protect?.rules?.[ruleId];
@@ -62,20 +72,11 @@ const ConfigSource = {
62
72
  USER_CONFIGURATION_FILE: 'USER_CONFIGURATION_FILE',
63
73
  };
64
74
 
75
+ // these should return `undefined` if there is no remote value corresponding to the effective config name.
65
76
  const mappings = {
66
- // server features
67
- 'agent.logger.level': (remoteData) => remoteData.logger?.level?.toLowerCase?.(),
68
- 'agent.logger.path': (remoteData) => remoteData.logger?.path,
69
- 'application.session_id': (remoteData) => remoteData?.settings?.assessment?.session_id,
70
- 'agent.security_logger.syslog.enable': (remoteData) => remoteData.security_logger?.syslog?.enable,
71
- 'agent.security_logger.syslog.ip': (remoteData) => remoteData.security_logger?.syslog?.ip,
72
- 'agent.security_logger.syslog.port': (remoteData) => remoteData.security_logger?.syslog?.port,
73
- 'agent.security_logger.syslog.facility': (remoteData) => remoteData.security_logger?.syslog?.facility,
74
- 'agent.security_logger.syslog.severity_exploited': (remoteData) => remoteData.security_logger?.syslog?.severity_exploited?.toLowerCase(),
75
- 'agent.security_logger.syslog.severity_blocked': (remoteData) => remoteData.security_logger?.syslog?.severity_blocked?.toLowerCase(),
76
- 'agent.security_logger.syslog.severity_probed': (remoteData) => remoteData.security_logger?.syslog?.severity_probed?.toLowerCase(),
77
+ // application-create
78
+ 'application.session_id': (remoteData) => remoteData.settings?.assessment?.session_id,
77
79
  // application settings
78
- 'assess.enable': (remoteData) => remoteData.assess?.enable,
79
80
  'protect.enable': (remoteData) => remoteData.protect?.enable,
80
81
  'protect.rules.cmd-injection.mode': protectModeReader(CMD_INJECTION),
81
82
  'protect.rules.cmd-injection-command-backdoors.mode': protectModeReader(CMD_INJECTION_COMMAND_BACKDOORS),
@@ -92,6 +93,27 @@ const mappings = {
92
93
  'protect.rules.unsafe-file-upload.mode': protectModeReader(UNSAFE_FILE_UPLOAD),
93
94
  'protect.rules.untrusted-deserialization.mode': protectModeReader(UNTRUSTED_DESERIALIZATION),
94
95
  'protect.rules.xxe.mode': protectModeReader(XXE),
96
+ // server features
97
+ 'assess.enable': (remoteData) => remoteData.assess?.enable,
98
+ 'assess.probabilistic_sampling.enable': (remoteData) => remoteData.assess?.sampling?.enable,
99
+ 'assess.probabilistic_sampling.base_probability': (remoteData) => {
100
+ const request_frequency = remoteData.assess?.sampling?.request_frequency;
101
+ if (request_frequency > 0) {
102
+ const baseProbability = 1 / request_frequency;
103
+ if (!isNaN(baseProbability)) return baseProbability;
104
+ }
105
+ },
106
+ 'agent.logger.level': coerceLowerCase('logger.level'),
107
+ 'agent.logger.path': (remoteData) => remoteData.logger?.path,
108
+ 'agent.security_logger.syslog.enable': (remoteData) => remoteData.security_logger?.syslog?.enable,
109
+ 'agent.security_logger.syslog.ip': (remoteData) => remoteData.security_logger?.syslog?.ip,
110
+ 'agent.security_logger.syslog.port': (remoteData) => remoteData.security_logger?.syslog?.port,
111
+ 'agent.security_logger.syslog.facility': (remoteData) => remoteData.security_logger?.syslog?.facility,
112
+ 'agent.security_logger.syslog.severity_exploited': coerceLowerCase('security_logger.syslog.severity_exploited'),
113
+ 'agent.security_logger.syslog.severity_blocked': coerceLowerCase('security_logger.syslog.severity_blocked'),
114
+ 'agent.security_logger.syslog.severity_probed': coerceLowerCase('security_logger.syslog.severity_probed'),
115
+ 'server.environment': (remoteData) => remoteData.environment,
116
+
95
117
  };
96
118
 
97
119
  /*
@@ -0,0 +1,183 @@
1
+ 'use strict';
2
+
3
+ const { expect } = require('chai');
4
+ const { ProtectRuleMode } = require('@contrast/common');
5
+ const { mappings } = require('./common');
6
+
7
+ describe('config effective value mappings', function () {
8
+ describe('"simple" mappings', function() {
9
+ const simplyMappedNames = [
10
+ 'assess.enable',
11
+ 'agent.logger.path',
12
+ 'agent.security_logger.syslog.enable',
13
+ 'agent.security_logger.syslog.ip',
14
+ 'agent.security_logger.syslog.port',
15
+ 'agent.security_logger.syslog.facility',
16
+ 'server.environment',
17
+ 'protect.enable',
18
+ ];
19
+
20
+ it('"simple" mappings will return any value for corresponding remote setting (todo: validation for each)', function () {
21
+ ['', 'foo', null, 0.3, 3, {}, [], true, false].forEach((val) => {
22
+ const remoteData = {
23
+ // server
24
+ assess: {
25
+ enable: val,
26
+ sampling: { enable: val }
27
+ },
28
+ logger: {
29
+ path: val,
30
+ },
31
+ security_logger: {
32
+ syslog: {
33
+ enable: val,
34
+ ip: val,
35
+ port: val,
36
+ facility: val,
37
+ serverity_exploited: val,
38
+ serverity_blocked: val,
39
+ exploited_probed: val,
40
+ }
41
+ },
42
+ environment: val,
43
+ // app
44
+ protect: {
45
+ enable: val,
46
+ },
47
+ };
48
+
49
+ for (const name of simplyMappedNames) {
50
+ const result = mappings[name](remoteData);
51
+ expect(result).to.equal(val);
52
+ }
53
+ });
54
+ });
55
+
56
+ it('"simple" mappings will return `undefined` if there is no corresponding remote setting', function () {
57
+ for (const name of simplyMappedNames) {
58
+ expect(mappings[name]({})).to.equal(undefined);
59
+ }
60
+ });
61
+ });
62
+
63
+ describe('maps \'assess.probabilistic_sampling.base_probability\'', function() {
64
+ [
65
+ {
66
+ desc: 'sets value by inverting request_frequency',
67
+ remoteData: {
68
+ assess: { sampling: { request_frequency: 20 } }
69
+ },
70
+ expected: 1 / 20,
71
+ },
72
+ {
73
+ desc: 'ignores and returns undefined if request_frequency is 0',
74
+ remoteData: {
75
+ assess: { sampling: { request_frequency: 0 } }
76
+ },
77
+ expected: undefined,
78
+ },
79
+ {
80
+ desc: 'ignores and returns undefined if calculating probability gives NaN',
81
+ remoteData: {
82
+ assess: { sampling: { request_frequency: 'forty five' } }
83
+ },
84
+ expected: undefined,
85
+ },
86
+ {
87
+ desc: 'ignores and returns undefined if value is negative',
88
+ remoteData: {
89
+ assess: { sampling: { request_frequency: -12 } }
90
+ },
91
+ expected: undefined,
92
+ },
93
+ ].forEach(({ desc, remoteData, expected }) => {
94
+ it(desc, function() {
95
+ expect(mappings['assess.probabilistic_sampling.base_probability'](remoteData)).to.equal(expected);
96
+ });
97
+ });
98
+ });
99
+
100
+ describe('maps Protect rule modes', function() {
101
+ [
102
+ {
103
+ tsValue: 'OFF',
104
+ expected: ProtectRuleMode.OFF,
105
+ },
106
+ {
107
+ tsValue: 'MONITORING',
108
+ expected: ProtectRuleMode.MONITOR,
109
+ },
110
+ {
111
+ tsValue: 'BLOCKING',
112
+ expected: ProtectRuleMode.BLOCK,
113
+ },
114
+ {
115
+ tsValue: 'BLOCK_AT_PERIMETER',
116
+ expected: ProtectRuleMode.BLOCK_AT_PERIMETER,
117
+ },
118
+ {
119
+ tsValue: 'FOO',
120
+ expected: undefined
121
+ },
122
+ ].forEach(({ desc, tsValue, expected }) => {
123
+ [
124
+ 'protect.rules.cmd-injection.mode',
125
+ 'protect.rules.cmd-injection-command-backdoors.mode',
126
+ 'protect.rules.cmd-injection-semantic-chained-commands.mode',
127
+ 'protect.rules.cmd-injection-semantic-dangerous-paths.mode',
128
+ 'protect.rules.method-tampering.mode',
129
+ 'protect.rules.nosql-injection.mode',
130
+ 'protect.rules.nosql-injection-mongo.mode',
131
+ 'protect.rules.path-traversal.mode',
132
+ 'protect.rules.path-traversal-semantic-file-security-bypass.mode',
133
+ 'protect.rules.reflected-xss.mode',
134
+ 'protect.rules.sql-injection.mode',
135
+ 'protect.rules.ssjs-injection.mode',
136
+ 'protect.rules.unsafe-file-upload.mode',
137
+ 'protect.rules.untrusted-deserialization.mode',
138
+ 'protect.rules.xxe.mode',
139
+ ].forEach((fullName) => {
140
+ const ruleName = /rules\.(.*)\.mode/.exec(fullName)[1];
141
+ it(`sets ${ruleName} to ${expected} when value is ${tsValue}`, function() {
142
+ expect(mappings[fullName]({
143
+ protect: {
144
+ rules: {
145
+ [ruleName]: { mode: tsValue },
146
+ }
147
+ }
148
+ })).to.equal(expected);
149
+ });
150
+ });
151
+ });
152
+ });
153
+
154
+ describe('maps security logger severity levels (lowercase)', function() {
155
+ [
156
+ {
157
+ tsValue: 'FOO',
158
+ expected: 'foo',
159
+ },
160
+ {
161
+ tsValue: '',
162
+ expected: undefined,
163
+ }
164
+ ].forEach(({ tsValue, expected }) => {
165
+ [
166
+ 'agent.security_logger.syslog.severity_exploited',
167
+ 'agent.security_logger.syslog.severity_blocked',
168
+ 'agent.security_logger.syslog.severity_probed',
169
+ ].forEach((fullName) => {
170
+ const severity = fullName.substr(fullName.lastIndexOf('.') + 1);
171
+ it(`maps to ${expected} when tsValue is ${tsValue}`, function() {
172
+ expect(mappings[fullName]({
173
+ security_logger: {
174
+ syslog: {
175
+ [severity]: tsValue,
176
+ },
177
+ }
178
+ })).to.equal(expected);
179
+ });
180
+ });
181
+ });
182
+ });
183
+ });
package/lib/config.js CHANGED
@@ -20,7 +20,7 @@ const path = require('path');
20
20
  const fs = require('fs');
21
21
  const os = require('os');
22
22
  const yaml = require('yaml');
23
- const { Event, get, set } = require('@contrast/common');
23
+ const { Event, get, set, primordials: { ArrayPrototypeJoin, BufferPrototypeToString, StringPrototypeToUpperCase, JSONParse } } = require('@contrast/common');
24
24
  const options = require('./options');
25
25
  const {
26
26
  ConfigSource: {
@@ -38,7 +38,7 @@ const HOME_CONFIG_DIR = path.resolve(os.homedir(), '.config', 'contrast');
38
38
  const OS_CONFIG_DIR = os.platform() === 'win32'
39
39
  ? path.resolve(process.env.ProgramData || '', 'contrast')
40
40
  : '/etc/contrast';
41
- const REDACTED_KEYS = ['api.api_key', 'api.service_key'];
41
+ const REDACTED_KEYS = ['api.api_key', 'api.service_key', 'api.token'];
42
42
  const OVERRIDABLE_SOURCES = [DEFAULT_VALUE, CONTRAST_UI];
43
43
 
44
44
  module.exports = class Config {
@@ -48,6 +48,7 @@ module.exports = class Config {
48
48
  this._errors = [];
49
49
  this._effectiveMap = new Map();
50
50
  this._status = '';
51
+ this._logs = [];
51
52
 
52
53
  // config object
53
54
  this.api = {};
@@ -73,7 +74,7 @@ module.exports = class Config {
73
74
 
74
75
  // report all the errors found during initialization.
75
76
  if (this._errors.length) {
76
- const errors = this._errors.map((e, ix) => `${ix + 1}) ${e.message}`).join('; ');
77
+ const errors = ArrayPrototypeJoin.call(this._errors.map((e, ix) => `${ix + 1}) ${e.message}`), '; ');
77
78
  throw new Error(`Errors found in configuration ${errors}`);
78
79
  }
79
80
 
@@ -103,7 +104,7 @@ module.exports = class Config {
103
104
  let parsedEnv;
104
105
 
105
106
  try {
106
- parsedEnv = JSON.parse(env.pm2_env);
107
+ parsedEnv = JSONParse(env.pm2_env);
107
108
  } catch (_err) {
108
109
  const err = new Error(`Unable to parse pm2 environment variable: '${env.pm2_env}'`);
109
110
  err.cause = _err;
@@ -121,7 +122,7 @@ module.exports = class Config {
121
122
  }
122
123
 
123
124
  return Object.entries(env).reduce((acc, [key, val]) => {
124
- const name = key.toUpperCase();
125
+ const name = StringPrototypeToUpperCase.call(key);
125
126
  if (name.startsWith('CONTRAST_')) {
126
127
  acc[name] = val;
127
128
  }
@@ -164,7 +165,7 @@ module.exports = class Config {
164
165
  let fileContents;
165
166
 
166
167
  try {
167
- fileContents = fs.readFileSync(_filepath).toString('utf-8');
168
+ fileContents = BufferPrototypeToString.call(fs.readFileSync(_filepath), 'utf-8');
168
169
  } catch (e) {
169
170
  const err = new Error(`Unable to read Contrast configuration file: '${_filepath}'`);
170
171
  err.cause = e;
@@ -207,7 +208,7 @@ module.exports = class Config {
207
208
  source = DEFAULT_VALUE;
208
209
  }
209
210
 
210
- if (opt.fn) value = opt.fn(value);
211
+ if (opt.fn) value = opt.fn(value, this, source);
211
212
  if (opt.enum && !opt.enum.includes(value)) value = opt.default;
212
213
 
213
214
  set(this, opt.name, value);
@@ -221,7 +222,7 @@ module.exports = class Config {
221
222
  }
222
223
 
223
224
  _redact(name, value) {
224
- if (value == null) return value;
225
+ if (value === null) return value;
225
226
  return REDACTED_KEYS.includes(name) ? `contrast-redacted-${name}` : value;
226
227
  }
227
228
 
@@ -239,12 +240,14 @@ module.exports = class Config {
239
240
  const effective_config = [], environment_variable = [], contrast_ui = [];
240
241
 
241
242
  Array.from(this._effectiveMap.values()).forEach((v) => {
242
- let value = redact ? this._redact(v.name, v.value) : v.value;
243
+ let { value } = v;
244
+ if (redact) value = this._redact(v.name, v.value);
243
245
  if (value === undefined) value = null;
244
246
 
245
- effective_config.push({ ...v, value: String(value) });
246
- if (v.source === ENVIRONMENT_VARIABLE) environment_variable.push(v);
247
- if (v.source === CONTRAST_UI) contrast_ui.push(v);
247
+ const redacted = { ...v, value: String(value) };
248
+ effective_config.push(redacted);
249
+ if (v.source === ENVIRONMENT_VARIABLE) environment_variable.push(redacted);
250
+ if (v.source === CONTRAST_UI) contrast_ui.push(redacted);
248
251
  });
249
252
 
250
253
  return {
package/lib/index.d.ts CHANGED
@@ -15,13 +15,13 @@
15
15
 
16
16
  import { ProtectRuleMode, Rule } from '@contrast/common';
17
17
  import { LevelWithSilent } from 'pino';
18
- export { ConfigSource } from './common.js';
18
+ export { ConfigSource } from './common';
19
19
 
20
20
  export interface EffectiveEntry<T> {
21
21
  canonical_name: string;
22
22
  name: string;
23
23
  value: T;
24
- source: ConfigSource;
24
+ source: string;
25
25
  }
26
26
 
27
27
  export type Level =
@@ -47,7 +47,7 @@ export interface ConfigOption<T> {
47
47
  arg: string;
48
48
  enum?: T[];
49
49
  default?: T;
50
- fn?: (arg: any) => T;
50
+ fn?: (arg: any, cfg: Config, source: string) => T;
51
51
  desc: string;
52
52
  }
53
53
 
@@ -56,6 +56,13 @@ export interface Config {
56
56
  _effectiveMap: Map<string, EffectiveEntry<any>>;
57
57
  _errors: Error[];
58
58
  _status: string,
59
+ _logs: {
60
+ level: import('pino').LevelWithSilentOrString;
61
+ obj?: any;
62
+ msg: string;
63
+ args?: any[];
64
+ }[];
65
+
59
66
 
60
67
  api: {
61
68
  /** Default: `true` */
@@ -296,10 +303,10 @@ export interface Config {
296
303
  /** Default: `true` */
297
304
  discover_cloud_resource: boolean;
298
305
  };
299
- getEffectiveSource(cannonicalName: string): any;
300
- getEffectiveValue(cannonicalName: string): any;
306
+ getEffectiveSource(cannonicalName: string): string;
307
+ getEffectiveValue<T = any>(cannonicalName: string): T;
301
308
  getReport({ redact: boolean }): any;
302
- setValue(name: string, value: any, source: ConfigSource): void;
309
+ setValue<T = any>(name: string, value: T, source: string): void;
303
310
  }
304
311
 
305
312
  declare function init(core: { config?: Config }): Config;
package/lib/index.test.js CHANGED
@@ -186,6 +186,49 @@ describe('config', function () {
186
186
  });
187
187
  });
188
188
 
189
+ describe('api.token handling', function () {
190
+ it('sets api config vars when they are not present', function () {
191
+ env['CONTRAST_CONFIG_PATH'] = getGoodConfig('token');
192
+ const cfg = config();
193
+
194
+ expect(cfg.api).to.include({
195
+ url: 'http://localhost:12345/Contrast',
196
+ api_key: 'secret',
197
+ service_key: 'secret',
198
+ user_name: 'tokenuser'
199
+ });
200
+ });
201
+
202
+ it('does not override api config vars when they are explicitly set in config', function () {
203
+ // url: 'http://localhost:12345/Contrast',
204
+ // api_key: 'secret',
205
+ // service_key: 'secret',
206
+ // user_name: 'tokenuser'
207
+ env['CONTRAST__API__TOKEN'] = 'eyJ1cmwiOiJodHRwOi8vbG9jYWxob3N0OjEyMzQ1L0NvbnRyYXN0IiwiYXBpX2tleSI6InNlY3JldCIsInNlcnZpY2Vfa2V5Ijoic2VjcmV0IiwidXNlcl9uYW1lIjoidG9rZW51c2VyIn0=';
208
+ const cfg = config();
209
+
210
+ expect(cfg.api).to.include({
211
+ url: 'http://localhost:19080/Contrast',
212
+ api_key: 'demo',
213
+ service_key: 'demo',
214
+ user_name: 'contrast_admin'
215
+ });
216
+ });
217
+
218
+ it('observes the correct precedence', function () {
219
+ env['CONTRAST_CONFIG_PATH'] = getGoodConfig('token');
220
+ env['CONTRAST__API__API_KEY'] = 'something else';
221
+ const cfg = config();
222
+
223
+ expect(cfg.api).to.include({
224
+ url: 'http://localhost:12345/Contrast',
225
+ api_key: 'something else',
226
+ service_key: 'secret',
227
+ user_name: 'tokenuser'
228
+ });
229
+ });
230
+ });
231
+
189
232
  describe('multiple errors can be thrown in an exception', function() {
190
233
  it('should report multiple errors', function() {
191
234
  const options = getDefaultConfig();
@@ -328,11 +371,12 @@ describe('config', function () {
328
371
  // there needs to be a valid config file or env var set so config doesn't throw
329
372
  // an error.
330
373
  process.env.CONTRAST_CONFIG_PATH = path.resolve(__dirname, '../../test/fixtures/protect_contrast_security.yaml');
374
+ //process.env.CONTRAST__PROTECT__RULES__DISABLED_RULES = 'cmd-injection,sql-injection';
331
375
  config = require('.')(core);
332
376
 
333
377
  config.setValue('contrast_config_path', path.resolve(__dirname, '../../test/fixtures/protect_contrast_security.yaml'), ENVIRONMENT_VARIABLE);
334
378
  config.setValue('contrast__agent__stack_trace_limit', 20, ENVIRONMENT_VARIABLE);
335
- config.setValue('contrast.protect.rules.disabled_rules', ['cmd-injection', 'sql-injection'], ENVIRONMENT_VARIABLE);
379
+ config.setValue('protect.rules.disabled_rules', ['cmd-injection', 'sql-injection'], ENVIRONMENT_VARIABLE);
336
380
  config.setValue('api.enable', true, ENVIRONMENT_VARIABLE);
337
381
  config.setValue('api.api_key', 'ABCDEFGHIJ', ENVIRONMENT_VARIABLE);
338
382
  config.setValue('api.service_key', 'KLMNOPQRST', ENVIRONMENT_VARIABLE);
@@ -364,11 +408,12 @@ describe('config', function () {
364
408
  const {
365
409
  config: {
366
410
  effective_config,
411
+ environment_variable,
367
412
  }
368
413
  } = result;
369
414
 
370
415
  expect(result.config).to.have.property('status', 'Success');
371
- expect(effective_config).to.deep.include(
416
+ const expectedResults = [
372
417
  {
373
418
  canonical_name: 'api.enable',
374
419
  name: 'api.enable',
@@ -379,13 +424,13 @@ describe('config', function () {
379
424
  canonical_name: 'api.api_key',
380
425
  name: 'api.api_key',
381
426
  source: 'ENVIRONMENT_VARIABLE',
382
- value: 'contrast-redacted-api.api_key',
427
+ value: 'ABCDEFGHIJ',
383
428
  },
384
429
  {
385
430
  canonical_name: 'api.service_key',
386
431
  name: 'api.service_key',
387
432
  source: 'ENVIRONMENT_VARIABLE',
388
- value: 'contrast-redacted-api.service_key',
433
+ value: 'KLMNOPQRST',
389
434
  },
390
435
  {
391
436
  canonical_name: 'api.proxy.enable',
@@ -453,7 +498,37 @@ describe('config', function () {
453
498
  value: '150',
454
499
  source: 'DEFAULT_VALUE'
455
500
  }
456
- );
501
+ ];
502
+
503
+ const failures = [];
504
+ let effective;
505
+ let envs;
506
+ const envFailures = [];
507
+
508
+ for (const expected of expectedResults) {
509
+ effective = effective_config.filter(i => i.canonical_name === expected.canonical_name);
510
+ expect(effective).an('array').lengthOf(1); // eslint-disable-line
511
+ effective = effective[0];
512
+ try {
513
+ expect(expected).to.deep.equal(effective);
514
+ } catch (e) {
515
+ failures.push({ expected, actual: effective });
516
+ }
517
+ if (expected.source !== 'ENVIRONMENT_VARIABLE') continue;
518
+
519
+ envs = environment_variable.filter(i => i.canonical_name === expected.canonical_name);
520
+ expect(envs).an('array')
521
+ .lengthOf(1, `Expected to find ${expected.name} in environment_variable`);
522
+ envs = envs[0];
523
+ try {
524
+ expect(expected).to.deep.equal(envs);
525
+ } catch (e) {
526
+ envFailures.push({ expected, actual: envs });
527
+ }
528
+ }
529
+ // this provides a useful error message
530
+ expect(failures).eql([]);
531
+ expect(envFailures).eql([]);
457
532
  });
458
533
 
459
534
  it('stringifies values and redacts api keys when `redact` is set to true', function () {
@@ -461,11 +536,13 @@ describe('config', function () {
461
536
  const {
462
537
  config: {
463
538
  effective_config,
539
+ environment_variable,
464
540
  }
465
541
  } = result;
466
542
 
467
543
  expect(result.config).to.have.property('status', 'Success');
468
- expect(effective_config).to.deep.include(
544
+
545
+ const expectedResults = [
469
546
  {
470
547
  canonical_name: 'api.enable',
471
548
  name: 'api.enable',
@@ -550,7 +627,37 @@ describe('config', function () {
550
627
  value: '150',
551
628
  source: 'DEFAULT_VALUE'
552
629
  }
553
- );
630
+ ];
631
+
632
+ const failures = [];
633
+ let effective;
634
+ let envs;
635
+ const envFailures = [];
636
+
637
+ for (const expected of expectedResults) {
638
+ effective = effective_config.filter(i => i.canonical_name === expected.canonical_name);
639
+ expect(effective).an('array').lengthOf(1); // eslint-disable-line
640
+ effective = effective[0];
641
+ try {
642
+ expect(expected).to.deep.equal(effective);
643
+ } catch (e) {
644
+ failures.push({ expected, actual: effective });
645
+ }
646
+ if (expected.source !== 'ENVIRONMENT_VARIABLE') continue;
647
+
648
+ envs = environment_variable.filter(i => i.canonical_name === expected.canonical_name);
649
+ expect(envs).an('array')
650
+ .lengthOf(1, `Expected to find ${expected.name} in environment_variable`);
651
+ envs = envs[0];
652
+ try {
653
+ expect(expected).to.deep.equal(envs);
654
+ } catch (e) {
655
+ envFailures.push({ expected, actual: envs });
656
+ }
657
+ }
658
+ // this provides a useful error message
659
+ expect(failures).eql([]);
660
+ expect(envFailures).eql([]);
554
661
  });
555
662
  });
556
663
  });
package/lib/options.js CHANGED
@@ -20,7 +20,8 @@
20
20
  const os = require('os');
21
21
  const url = require('url');
22
22
  const path = require('path');
23
- const { Rule } = require('@contrast/common');
23
+ const { Rule, primordials: { BufferFrom, BufferPrototypeToString, StringPrototypeReplace, StringPrototypeReplaceAll, StringPrototypeSplit, StringPrototypeToLowerCase, StringPrototypeToUpperCase, JSONParse } } = require('@contrast/common');
24
+ const { ConfigSource: { DEFAULT_VALUE } } = require('./common');
24
25
 
25
26
  /**
26
27
  * Takes strings "true"|"t" or "false"|"f" (case insensitive) and return the appropriate boolean.
@@ -32,7 +33,7 @@ function castBoolean(value) {
32
33
  if (type !== 'string' && type !== 'boolean') {
33
34
  return;
34
35
  }
35
- value = value.toString().toLowerCase();
36
+ value = StringPrototypeToLowerCase.call(value.toString());
36
37
  return value === 'true' || value === 't'
37
38
  ? true
38
39
  : value === 'false' || value === 'f'
@@ -60,11 +61,11 @@ function clearBaseCase(val) {
60
61
  const split = (val) => {
61
62
  if (val === '') return [];
62
63
  if (val === undefined) return val;
63
- return val.split(',');
64
+ return StringPrototypeSplit.call(val, ',');
64
65
  };
65
66
 
66
- const uppercase = (val = '') => clearBaseCase(`${val}`.toUpperCase());
67
- const lowercase = (val = '') => clearBaseCase(`${val}`.toLowerCase());
67
+ const uppercase = (val = '') => clearBaseCase(`${StringPrototypeToUpperCase.call(val)}`);
68
+ const lowercase = (val = '') => clearBaseCase(`${StringPrototypeToLowerCase.call(val)}`);
68
69
  const parseNum = (val) => {
69
70
  const float = parseFloat(val);
70
71
  return clearBaseCase(Math.ceil(float));
@@ -128,7 +129,7 @@ const options = [
128
129
 
129
130
  if (uri.pathname) {
130
131
  // kill trailling /
131
- uri.pathname = uri.pathname.replace(/\/+$/, '');
132
+ uri.pathname = StringPrototypeReplace.call(uri.pathname, /\/+$/, '');
132
133
 
133
134
  if (!uri.pathname.endsWith('Contrast')) {
134
135
  uri.pathname += 'Contrast';
@@ -155,6 +156,35 @@ const options = [
155
156
  arg: '<name>',
156
157
  desc: 'Set the user name used to communicate with the Contrast UI. It is used to calculate the Authorization header.',
157
158
  },
159
+ {
160
+ name: 'api.token',
161
+ arg: '<token>',
162
+ desc: 'base64 encoded JSON object containing the `url`, `api_key`, `service_key`, and `user_name` config options, allowing them all to be set in a single variable.',
163
+ fn(value, cfg, source) {
164
+ try {
165
+ // parse the base64 encoded value
166
+ const parsed = JSONParse(BufferPrototypeToString.call(BufferFrom(value, 'base64'), 'utf8'));
167
+ // set the top level `api` keys only if they aren't already present.
168
+ // since this value comes after the others, they should be set first if present in the config file or environment.
169
+ ['url', 'api_key', 'service_key', 'user_name'].forEach(key => {
170
+ const canonicalName = `api.${key}`;
171
+ const existingSource = cfg.getEffectiveSource(canonicalName);
172
+ if (existingSource !== DEFAULT_VALUE) {
173
+ cfg._logs.push({
174
+ level: 'info',
175
+ msg: 'Using configured value for `%s` (set by %s) instead of `api.token`.',
176
+ args: [canonicalName, existingSource]
177
+ });
178
+ } else {
179
+ cfg.setValue(canonicalName, parsed[key], source);
180
+ }
181
+ });
182
+ return value;
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+ },
158
188
  // api.proxy
159
189
  {
160
190
  name: 'api.proxy.enable',
@@ -409,7 +439,7 @@ Example - \`/opt/Contrast/contrast.log\` creates a log in the \`/opt/Contrast\`
409
439
  name: 'agent.node.cmd_ignore_list',
410
440
  arg: '<commands>',
411
441
  default: '',
412
- fn: (arg) => arg.split(',').filter((v) => v),
442
+ fn: (arg) => StringPrototypeSplit.call(arg, ',').filter((v) => v),
413
443
  desc: 'comma-separated list of commands that will not startup the agent if agent is required; npm* will ignore all npm executables but not your application\'s scripts'
414
444
  },
415
445
  {
@@ -503,6 +533,29 @@ Example - \`/opt/Contrast/contrast.log\` creates a log in the \`/opt/Contrast\`
503
533
  fn: castBoolean,
504
534
  desc: 'Include this property to determine if the Assess feature should be enabled. If this property is not present, the decision is delegated to the Contrast UI.',
505
535
  },
536
+ {
537
+ name: 'assess.probabilistic_sampling.enable',
538
+ arg: '[true]',
539
+ default: false,
540
+ fn: castBoolean,
541
+ desc: 'Set to true to enable sampling of requests for dataflow and other Assess features',
542
+ },
543
+ {
544
+ name: 'assess.probabilistic_sampling.base_probability',
545
+ arg: '<probability>',
546
+ fn: (val) => {
547
+ const p = parseFloat(val);
548
+ if (p >= 0 && p <= 1) return p;
549
+
550
+ if (val && val != 'undefined') {
551
+ throw new Error('Invalid option: assess.probabilistic_sampling.base_probability', {
552
+ cause: `${val} is not not in interval 0 <= p <= 1. value as float: ${p}`
553
+ });
554
+ }
555
+ },
556
+ default: 0.01,
557
+ desc: 'A value p within the interval [0, 1]. Each request will share same probability p of being sampled.',
558
+ },
506
559
  {
507
560
  name: 'assess.tags',
508
561
  arg: '<tags>',
@@ -512,10 +565,10 @@ Example - \`label1, label2, label3\``,
512
565
  {
513
566
  name: 'assess.stacktraces',
514
567
  arg: '<level>',
515
- enum: ['ALL', 'SOME', 'NONE'],
568
+ enum: ['ALL', 'SOME', 'SINK', 'NONE'],
516
569
  default: 'ALL',
517
570
  fn: uppercase,
518
- desc: 'Select the level of collected stacktraces. ALL - for all assess events, SOME - for Source and Sink events, NONE - no stacktraces collected',
571
+ desc: 'Select the level of collected stacktraces. ALL - for all assess events, SOME - for Source and Sink events, SINK - for Sink events, NONE - no stacktraces collected',
519
572
  },
520
573
  {
521
574
  name: 'assess.max_context_source_events',
@@ -656,10 +709,11 @@ Example - \`label1, label2, label3\``,
656
709
  fn: castBoolean,
657
710
  desc: 'Set to `false` to disable detection of cloud provider metadata such as resource identifiers.'
658
711
  },
659
- ].map((opt) => Object.assign(opt, {
660
- env: `CONTRAST__${opt.name.toUpperCase().replaceAll('.', '__')
661
- .replaceAll('-', '_')}`
662
- }));
712
+ ].map((opt) => {
713
+ let env = StringPrototypeReplaceAll.call(StringPrototypeToUpperCase.call(opt.name), '.', '__');
714
+ env = StringPrototypeReplaceAll.call(env, '-', '_');
715
+ return Object.assign(opt, { env: `CONTRAST__${env}` });
716
+ });
663
717
 
664
718
  module.exports = options;
665
719
  module.exports.clearBaseCase = clearBaseCase;
@@ -0,0 +1,23 @@
1
+ /*
2
+ * Copyright: 2024 Contrast Security, Inc
3
+ * Contact: support@contrastsecurity.com
4
+ * License: Commercial
5
+
6
+ * NOTICE: This Software and the patented inventions embodied within may only be
7
+ * used as part of Contrast Security’s commercial offerings. Even though it is
8
+ * made available through public repositories, use of this Software is subject to
9
+ * the applicable End User Licensing Agreement found at
10
+ * https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
11
+ * between Contrast Security and the End User. The Software may not be reverse
12
+ * engineered, modified, repackaged, sold, redistributed or otherwise used in a
13
+ * way not consistent with the End User License Agreement.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ // Abusing the `validators` pattern to allow us to log after core has been set up.
19
+ module.exports.config = function config(core) {
20
+ core.config._logs.forEach(({ level, obj, msg, args = [] }) => {
21
+ core.logger[level](obj, msg, ...args);
22
+ });
23
+ };
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+
3
+ const { expect } = require('chai');
4
+ const mocks = require('@contrast/test/mocks');
5
+ const validators = require('./validators');
6
+
7
+ describe('config validators', function () {
8
+ let core;
9
+
10
+ beforeEach(function () {
11
+ core = { logger: mocks.logger() };
12
+ });
13
+
14
+ describe('config', function () {
15
+ it('calls `logger` for each log message provided', function () {
16
+ core.config = {
17
+ _logs: [
18
+ { level: 'info', msg: 'some info level message' },
19
+ {
20
+ level: 'debug',
21
+ obj: { foo: 'bar' },
22
+ msg: 'some debug level message with %d %s',
23
+ args: [2, 'args'],
24
+ },
25
+ ],
26
+ };
27
+
28
+ validators.config(core);
29
+
30
+ expect(core.logger.info).to.have.been.calledWith(
31
+ undefined,
32
+ 'some info level message'
33
+ );
34
+ expect(core.logger.debug).to.have.been.calledWith(
35
+ { foo: 'bar' },
36
+ 'some debug level message with %d %s',
37
+ 2,
38
+ 'args',
39
+ );
40
+ });
41
+ });
42
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contrast/config",
3
- "version": "1.32.0",
3
+ "version": "1.34.0",
4
4
  "description": "An API for discovering Contrast agent configuration data",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Contrast Security <nodejs@contrastsecurity.com> (https://www.contrastsecurity.com)",
@@ -17,7 +17,7 @@
17
17
  "test": "../scripts/test.sh"
18
18
  },
19
19
  "dependencies": {
20
- "@contrast/common": "1.25.0",
20
+ "@contrast/common": "1.26.0",
21
21
  "yaml": "^2.2.2"
22
22
  }
23
23
  }