@contrast/protect 1.5.0 → 1.6.1
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/error-handlers/install/hapi.js +0 -1
- package/lib/index.js +1 -1
- package/lib/input-analysis/handlers.js +63 -5
- package/lib/input-analysis/install/fastify.js +35 -37
- package/lib/input-analysis/install/hapi.js +3 -3
- package/lib/input-analysis/install/http.js +5 -3
- package/lib/input-analysis/virtual-patches.js +6 -2
- package/lib/input-tracing/install/child-process.js +1 -0
- package/lib/input-tracing/install/fs.js +1 -0
- package/lib/input-tracing/install/function.js +19 -17
- package/lib/policy.js +1 -1
- package/lib/semantic-analysis/handlers.js +39 -22
- package/package.json +5 -5
|
@@ -40,7 +40,6 @@ module.exports = function (core) {
|
|
|
40
40
|
|
|
41
41
|
if (isSecurityException && sourceContext && err.output.statusCode !== 403) {
|
|
42
42
|
const [mode, ruleId] = sourceContext.findings.securityException;
|
|
43
|
-
sourceContext.block('block', 'cmd-injection');
|
|
44
43
|
|
|
45
44
|
err.output.statusCode = 403;
|
|
46
45
|
err.reformat();
|
package/lib/index.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
const { BLOCKING_MODES, simpleTraverse } = require('@contrast/common');
|
|
19
19
|
const address = require('ipaddr.js');
|
|
20
|
+
const { Rule } = require('@contrast/common');
|
|
20
21
|
|
|
21
22
|
//
|
|
22
23
|
// these rules are not implemented by agent-lib, but are being considered for
|
|
@@ -54,6 +55,7 @@ module.exports = function(core) {
|
|
|
54
55
|
agentLib,
|
|
55
56
|
inputAnalysis,
|
|
56
57
|
},
|
|
58
|
+
config,
|
|
57
59
|
} = core;
|
|
58
60
|
|
|
59
61
|
// all handlers will be invoked with two arguments:
|
|
@@ -127,12 +129,68 @@ module.exports = function(core) {
|
|
|
127
129
|
return block;
|
|
128
130
|
};
|
|
129
131
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
/**
|
|
133
|
+
* handleRequestEnd()
|
|
134
|
+
*
|
|
135
|
+
* Invoked when the request is complete.
|
|
136
|
+
*
|
|
137
|
+
* @param {Object} sourceContext
|
|
138
|
+
*/
|
|
134
139
|
inputAnalysis.handleRequestEnd = function handleRequestEnd(sourceContext) {
|
|
135
|
-
|
|
140
|
+
if (!config.protect.probe_analysis.enable) return;
|
|
141
|
+
|
|
142
|
+
const { resultsMap } = sourceContext.findings;
|
|
143
|
+
const probesRules = [Rule.CMD_INJECTION, Rule.PATH_TRAVERSAL, Rule.SQL_INJECTION, Rule.XXE];
|
|
144
|
+
const props = {};
|
|
145
|
+
|
|
146
|
+
// Detecting probes
|
|
147
|
+
Object.values(resultsMap).forEach(resultsByRuleId => {
|
|
148
|
+
resultsByRuleId.forEach((resultByRuleId) => {
|
|
149
|
+
const {
|
|
150
|
+
ruleId,
|
|
151
|
+
blocked,
|
|
152
|
+
details,
|
|
153
|
+
value,
|
|
154
|
+
inputType
|
|
155
|
+
} = resultByRuleId;
|
|
156
|
+
if (blocked || !blocked && details.length > 0 || !probesRules.some(rule => rule === ruleId)) return;
|
|
157
|
+
|
|
158
|
+
const { policy: { rulesMask } } = sourceContext;
|
|
159
|
+
|
|
160
|
+
const results = (agentLib.scoreAtom(
|
|
161
|
+
rulesMask,
|
|
162
|
+
value,
|
|
163
|
+
agentLib.InputType[inputType],
|
|
164
|
+
{
|
|
165
|
+
preferWorthWatching: false
|
|
166
|
+
}
|
|
167
|
+
) || []).filter(({ score }) => score >= 90);
|
|
168
|
+
|
|
169
|
+
if (!results.length) return;
|
|
170
|
+
|
|
171
|
+
results.forEach(result => {
|
|
172
|
+
const isAlreadyBlocked = (resultsMap[result.ruleId] || []).some(element =>
|
|
173
|
+
element.blocked && element.inputType === inputType && element.value === value
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
if (isAlreadyBlocked) return;
|
|
177
|
+
|
|
178
|
+
const probe = Object.assign({}, resultByRuleId, result, {
|
|
179
|
+
mappedId: result.ruleId
|
|
180
|
+
});
|
|
181
|
+
const key = [probe.ruleId, probe.inputType, ...probe.path, probe.value].join('|');
|
|
182
|
+
props[key] = probe;
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
Object.values(props).forEach(prop => {
|
|
188
|
+
if (!resultsMap[prop.ruleId]) {
|
|
189
|
+
resultsMap[prop.ruleId] = [];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
resultsMap[prop.ruleId].push(prop);
|
|
193
|
+
});
|
|
136
194
|
};
|
|
137
195
|
|
|
138
196
|
const jsonInputTypes = {
|
|
@@ -36,48 +36,46 @@ module.exports = (core) => {
|
|
|
36
36
|
* registers a depHook for fastify module instrumentation
|
|
37
37
|
*/
|
|
38
38
|
function install() {
|
|
39
|
-
depHooks.resolve({ name: 'fastify', version: '>=3 <5' }, (fastify) => {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const sourceContext = protect.getSourceContext('Fastify.preValidationHook');
|
|
39
|
+
depHooks.resolve({ name: 'fastify', version: '>=3 <5' }, (fastify) => patcher.patch(fastify, {
|
|
40
|
+
name: 'fastify.build',
|
|
41
|
+
patchType,
|
|
42
|
+
post({ result: server }) {
|
|
43
|
+
server.addHook('preValidation', function(request, reply, done) {
|
|
44
|
+
let securityException;
|
|
45
|
+
const sourceContext = protect.getSourceContext('Fastify.preValidationHook');
|
|
47
46
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
47
|
+
if (sourceContext) {
|
|
48
|
+
try {
|
|
49
|
+
if (request.params) {
|
|
50
|
+
sourceContext.parsedParams = request.params;
|
|
51
|
+
inputAnalysis.handleUrlParams(sourceContext, request.params);
|
|
52
|
+
}
|
|
53
|
+
if (request.cookies) {
|
|
54
|
+
sourceContext.parsedCookies = request.cookies;
|
|
55
|
+
inputAnalysis.handleCookies(sourceContext, request.cookies);
|
|
56
|
+
}
|
|
57
|
+
if (request.body) {
|
|
58
|
+
sourceContext.parsedBody = request.body;
|
|
59
|
+
inputAnalysis.handleParsedBody(sourceContext, request.body);
|
|
60
|
+
}
|
|
62
61
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
62
|
+
if (request.query) {
|
|
63
|
+
sourceContext.parsedQuery = request.query;
|
|
64
|
+
inputAnalysis.handleQueryParams(sourceContext, request.query);
|
|
65
|
+
}
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (isSecurityException(err)) {
|
|
68
|
+
securityException = err;
|
|
69
|
+
} else {
|
|
70
|
+
logger.error({ err }, 'Unexpected error during input analysis');
|
|
73
71
|
}
|
|
74
72
|
}
|
|
73
|
+
}
|
|
75
74
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
});
|
|
75
|
+
done(securityException);
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
}));
|
|
81
79
|
}
|
|
82
80
|
|
|
83
81
|
return inputAnalysis.fastifyInstrumentation = {
|
|
@@ -71,9 +71,9 @@ module.exports = (core) => {
|
|
|
71
71
|
inputAnalysis.handleUrlParams(sourceContext, req.params);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
if (req.
|
|
75
|
-
sourceContext.parsedCookies = req.
|
|
76
|
-
inputAnalysis.handleCookies(sourceContext, req.
|
|
74
|
+
if (req.state && Object.keys(req.state).length) {
|
|
75
|
+
sourceContext.parsedCookies = req.state;
|
|
76
|
+
inputAnalysis.handleCookies(sourceContext, req.state);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
if (req.payload && Object.keys(req.payload).length) {
|
|
@@ -27,11 +27,10 @@ module.exports = function(core) {
|
|
|
27
27
|
};
|
|
28
28
|
class HttpInstrumentation {
|
|
29
29
|
constructor(core) {
|
|
30
|
-
const { logger } = core;
|
|
31
30
|
this.messages = core.messages;
|
|
32
31
|
this.scope = core.scopes.sources;
|
|
33
32
|
this.config = core.config;
|
|
34
|
-
this.logger = logger.child(
|
|
33
|
+
this.logger = core.logger.child('contrast:protect:input-analysis');
|
|
35
34
|
this.depHooks = core.depHooks;
|
|
36
35
|
this.protect = core.protect;
|
|
37
36
|
this.patcher = core.patcher;
|
|
@@ -181,7 +180,10 @@ class HttpInstrumentation {
|
|
|
181
180
|
store.protect = this.makeSourceContext(req, res);
|
|
182
181
|
const { reqData } = store.protect;
|
|
183
182
|
|
|
184
|
-
res.on('finish', () =>
|
|
183
|
+
res.on('finish', () => {
|
|
184
|
+
this.protect.inputAnalysis.handleRequestEnd(store.protect);
|
|
185
|
+
messages.emit(Event.PROTECT, store);
|
|
186
|
+
});
|
|
185
187
|
|
|
186
188
|
// don't put inputs in the store; they are a param to each handler. findings
|
|
187
189
|
// associated with inputs do go into the store. why not put the inputs
|
|
@@ -43,9 +43,13 @@ function buildVPEvaluators(virtualPatches, evaluatorsArray) {
|
|
|
43
43
|
let result;
|
|
44
44
|
for (const { evaluation, name, value } of headers) {
|
|
45
45
|
const evalCheck = buildEvaluationCheck(evaluation);
|
|
46
|
-
const
|
|
46
|
+
const headersArray = Array.isArray(reqHeaders) ? reqHeaders : Object.entries(reqHeaders).reduce((acc, entry) => {
|
|
47
|
+
acc.push(...entry);
|
|
48
|
+
return acc;
|
|
49
|
+
}, []);
|
|
50
|
+
const keyIndex = headersArray.indexOf(name.toLowerCase());
|
|
47
51
|
|
|
48
|
-
result = keyIndex !== -1 && evalCheck(
|
|
52
|
+
result = keyIndex !== -1 && evalCheck(headersArray[keyIndex + 1], value);
|
|
49
53
|
if (!result) break;
|
|
50
54
|
}
|
|
51
55
|
|
|
@@ -54,6 +54,7 @@ module.exports = function(core) {
|
|
|
54
54
|
core.protect.semanticAnalysis.handleCommandInjectionCommandBackdoors(sourceContext, sinkContext);
|
|
55
55
|
core.protect.semanticAnalysis.handleCmdInjectionSemanticChainedCommands(sourceContext, sinkContext);
|
|
56
56
|
core.protect.semanticAnalysis.handleCmdInjectionSemanticDangerous(sourceContext, sinkContext);
|
|
57
|
+
core.protect.semanticAnalysis.handlePathTraversalFileSecurityBypass(sourceContext, sinkContext);
|
|
57
58
|
}
|
|
58
59
|
});
|
|
59
60
|
});
|
|
@@ -106,6 +106,7 @@ module.exports = function(core) {
|
|
|
106
106
|
{ constructorOpt: hooked, prependFrames: [orig] }
|
|
107
107
|
);
|
|
108
108
|
inputTracing.handlePathTraversal(sourceContext, sinkContext);
|
|
109
|
+
core.protect.semanticAnalysis.handlePathTraversalFileSecurityBypass(sourceContext, sinkContext);
|
|
109
110
|
}
|
|
110
111
|
}
|
|
111
112
|
});
|
|
@@ -34,23 +34,25 @@ module.exports = function(core) {
|
|
|
34
34
|
return;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
37
|
+
Object.assign(global.ContrastMethods, {
|
|
38
|
+
Function: patcher.patch(global.ContrastMethods.Function, {
|
|
39
|
+
name: 'global.ContrastMethods.Function',
|
|
40
|
+
patchType,
|
|
41
|
+
pre: ({ args, hooked, orig }) => {
|
|
42
|
+
if (instrumentation.isLocked()) return;
|
|
43
|
+
|
|
44
|
+
const sourceContext = protect.getSourceContext('Function');
|
|
45
|
+
const fnBody = args[args.length - 1];
|
|
46
|
+
|
|
47
|
+
if (!sourceContext || !fnBody || !isString(fnBody)) return;
|
|
48
|
+
|
|
49
|
+
const sinkContext = captureStacktrace(
|
|
50
|
+
{ name: 'Function', value: fnBody },
|
|
51
|
+
{ constructorOpt: hooked, prependFrames: [orig] }
|
|
52
|
+
);
|
|
53
|
+
inputTracing.ssjsInjection(sourceContext, sinkContext);
|
|
54
|
+
}
|
|
55
|
+
})
|
|
54
56
|
});
|
|
55
57
|
}
|
|
56
58
|
|
package/lib/policy.js
CHANGED
|
@@ -105,7 +105,7 @@ module.exports = function(core) {
|
|
|
105
105
|
messages.on(SERVER_SETTINGS_UPDATE, (remoteSettings) => {
|
|
106
106
|
let update;
|
|
107
107
|
|
|
108
|
-
const protectionRules = remoteSettings?.settings?.defend?.protectionRules
|
|
108
|
+
const protectionRules = remoteSettings?.settings?.defend?.protectionRules;
|
|
109
109
|
if (protectionRules) {
|
|
110
110
|
updateFromProtectionRules(protectionRules);
|
|
111
111
|
update = 'application-settings';
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
'use strict';
|
|
17
17
|
|
|
18
18
|
const {
|
|
19
|
+
Rule,
|
|
19
20
|
BLOCKING_MODES,
|
|
20
21
|
ProtectRuleMode: { OFF },
|
|
21
22
|
InputType,
|
|
@@ -26,12 +27,17 @@ const {
|
|
|
26
27
|
const SINK_EXPLOIT_PATTERN_START = /(?:^|\\|\/)(?:sh|bash|zsh|ksh|tcsh|csh|fish|cmd)/;
|
|
27
28
|
const stripWhiteSpace = (str) => str.replace(/\s/g, '');
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
const getRuleResults = function(obj, prop) {
|
|
31
|
+
return obj[prop] || (obj[prop] = []);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Semantic analysis currently shares instrumentation with the input-tracing sinks.
|
|
35
|
+
// See files in protect/lib/input-tracing/install/.
|
|
36
|
+
|
|
30
37
|
module.exports = function(core) {
|
|
31
38
|
const { protect: { agentLib, semanticAnalysis, throwSecurityException } } = core;
|
|
32
39
|
|
|
33
40
|
function handleResult(sourceContext, sinkContext, ruleId, mode, finding) {
|
|
34
|
-
const sinkResults = sourceContext.findings.semanticResultsMap[ruleId];
|
|
35
41
|
const result = {
|
|
36
42
|
blocked: false,
|
|
37
43
|
findings: { command: sinkContext.value },
|
|
@@ -39,7 +45,7 @@ module.exports = function(core) {
|
|
|
39
45
|
...finding
|
|
40
46
|
};
|
|
41
47
|
|
|
42
|
-
sourceContext.findings.semanticResultsMap
|
|
48
|
+
getRuleResults(sourceContext.findings.semanticResultsMap, ruleId).push(result);
|
|
43
49
|
|
|
44
50
|
if (BLOCKING_MODES.includes(mode)) {
|
|
45
51
|
result.blocked = true;
|
|
@@ -50,41 +56,50 @@ module.exports = function(core) {
|
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
semanticAnalysis.handleCmdInjectionSemanticDangerous = function(sourceContext, sinkContext) {
|
|
53
|
-
const
|
|
54
|
-
const mode = sourceContext.policy[ruleId];
|
|
59
|
+
const mode = sourceContext.policy[Rule.CMD_INJECTION_SEMANTIC_DANGEROUS_PATHS];
|
|
55
60
|
|
|
56
61
|
if (mode == OFF) return;
|
|
57
62
|
|
|
58
63
|
const result = agentLib.containsDangerousPath(sinkContext.value);
|
|
59
64
|
|
|
60
65
|
if (result) {
|
|
61
|
-
handleResult(sourceContext, sinkContext,
|
|
66
|
+
handleResult(sourceContext, sinkContext, Rule.CMD_INJECTION_SEMANTIC_DANGEROUS_PATHS, mode);
|
|
62
67
|
}
|
|
63
68
|
};
|
|
64
69
|
|
|
65
70
|
semanticAnalysis.handleCmdInjectionSemanticChainedCommands = function(sourceContext, sinkContext) {
|
|
66
|
-
const
|
|
67
|
-
const mode = sourceContext.policy[ruleId];
|
|
71
|
+
const mode = sourceContext.policy[Rule.CMD_INJECTION_SEMANTIC_CHAINED_COMMANDS];
|
|
68
72
|
|
|
69
73
|
if (mode == OFF) return;
|
|
70
74
|
|
|
71
75
|
const indexOfChaining = agentLib.indexOfChaining(sinkContext.value);
|
|
72
76
|
|
|
73
77
|
if (indexOfChaining != -1) {
|
|
74
|
-
handleResult(sourceContext, sinkContext,
|
|
78
|
+
handleResult(sourceContext, sinkContext, Rule.CMD_INJECTION_SEMANTIC_CHAINED_COMMANDS, mode);
|
|
75
79
|
}
|
|
76
80
|
};
|
|
77
81
|
|
|
78
82
|
semanticAnalysis.handleCommandInjectionCommandBackdoors = function(sourceContext, sinkContext) {
|
|
79
|
-
const
|
|
80
|
-
const mode = sourceContext.policy[ruleId];
|
|
83
|
+
const mode = sourceContext.policy[Rule.CMD_INJECTION_COMMAND_BACKDOORS];
|
|
81
84
|
|
|
82
85
|
if (mode == OFF) return;
|
|
83
86
|
|
|
84
87
|
const finding = findBackdoorInjection(sourceContext, sinkContext.value);
|
|
85
88
|
|
|
86
89
|
if (finding) {
|
|
87
|
-
handleResult(sourceContext, sinkContext,
|
|
90
|
+
handleResult(sourceContext, sinkContext, Rule.CMD_INJECTION_COMMAND_BACKDOORS, mode, finding);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
semanticAnalysis.handlePathTraversalFileSecurityBypass = function(sourceContext, sinkContext) {
|
|
95
|
+
const mode = sourceContext.policy[Rule.PATH_TRAVERSAL_SEMANTIC_FILE_SECURITY_BYPASS];
|
|
96
|
+
|
|
97
|
+
if (mode == OFF) return;
|
|
98
|
+
|
|
99
|
+
if (agentLib.isDangerousPath(sinkContext.value, true)) {
|
|
100
|
+
handleResult(sourceContext, sinkContext, Rule.PATH_TRAVERSAL_SEMANTIC_FILE_SECURITY_BYPASS, mode, {
|
|
101
|
+
findings: { path: sinkContext.value }
|
|
102
|
+
});
|
|
88
103
|
}
|
|
89
104
|
};
|
|
90
105
|
|
|
@@ -119,20 +134,22 @@ function findBackdoorInjection(sourceContext, command) {
|
|
|
119
134
|
simpleTraverse(values, (path, type, value, obj) => {
|
|
120
135
|
if (
|
|
121
136
|
!found &&
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
isString(value) &&
|
|
125
|
-
isBackdoorDetected(value, command)
|
|
137
|
+
type === 'Value' &&
|
|
138
|
+
isBackdoorDetected(value, command)
|
|
126
139
|
) {
|
|
127
140
|
let key;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
141
|
+
if (inputType === InputType.HEADER) {
|
|
142
|
+
key = obj[path[0] - 1]
|
|
143
|
+
} else {
|
|
144
|
+
key = path[path.length - 1];
|
|
131
145
|
}
|
|
132
|
-
path = path.length === 1 ? [] : Array.from(path).slice(0, path.length - 1);
|
|
133
|
-
inputType = path.length > 1 ? InputType.JSON_VALUE : inputType;
|
|
134
146
|
|
|
135
|
-
found = {
|
|
147
|
+
found = {
|
|
148
|
+
key,
|
|
149
|
+
inputType: path.length > 1 ? InputType.JSON_VALUE : inputType,
|
|
150
|
+
path: path.slice(0, -1),
|
|
151
|
+
value: command
|
|
152
|
+
};
|
|
136
153
|
}
|
|
137
154
|
});
|
|
138
155
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contrast/protect",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"description": "Contrast service providing framework-agnostic Protect support",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"author": "Contrast Security <nodejs@contrastsecurity.com> (https://www.contrastsecurity.com)",
|
|
@@ -20,10 +20,10 @@
|
|
|
20
20
|
"@babel/template": "^7.16.7",
|
|
21
21
|
"@babel/types": "^7.16.8",
|
|
22
22
|
"@contrast/agent-lib": "^5.1.0",
|
|
23
|
-
"@contrast/common": "1.1.
|
|
24
|
-
"@contrast/core": "1.
|
|
25
|
-
"@contrast/esm-hooks": "1.1.
|
|
26
|
-
"@contrast/scopes": "1.1.
|
|
23
|
+
"@contrast/common": "1.1.2",
|
|
24
|
+
"@contrast/core": "1.5.1",
|
|
25
|
+
"@contrast/esm-hooks": "1.1.7",
|
|
26
|
+
"@contrast/scopes": "1.1.2",
|
|
27
27
|
"builtin-modules": "^3.2.0",
|
|
28
28
|
"ipaddr.js": "^2.0.1",
|
|
29
29
|
"semver": "^7.3.7"
|