@auth0/auth0-checkmate 1.6.13 → 1.6.15
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/analyzer/lib/actions/checkPasswordResetMFA.js +128 -0
- package/analyzer/lib/actions/checkUserEnumeration.js +102 -0
- package/analyzer/lib/clients/checkAllowedLogoutUrl.js +5 -0
- package/analyzer/report.js +2 -0
- package/locales/en.json +65 -2
- package/package.json +1 -1
- package/tests/actions/checkPasswordResetMFA.test.js +122 -0
- package/tests/actions/checkUserEnumeration.test.js +102 -0
- package/tests/clients/checkAllowedLogoutUrl.test.js +43 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
const _ = require("lodash");
|
|
2
|
+
const executeCheck = require("../executeCheck");
|
|
3
|
+
const CONSTANTS = require("../constants");
|
|
4
|
+
const acorn = require("acorn");
|
|
5
|
+
const walk = require("estree-walker").walk;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Scans the Action code for calls to MFA challenge methods.
|
|
9
|
+
* Returns boolean if challenge is found.
|
|
10
|
+
*/
|
|
11
|
+
function hasMFAChallenge(code, scriptName) {
|
|
12
|
+
let challengeFound = false;
|
|
13
|
+
let ast;
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
ast = acorn.parse(code || "", {
|
|
17
|
+
ecmaVersion: "latest",
|
|
18
|
+
locations: true,
|
|
19
|
+
});
|
|
20
|
+
} catch (e) {
|
|
21
|
+
if (e instanceof SyntaxError) {
|
|
22
|
+
console.error(`[ACORN PARSE ERROR] Skipping script "${scriptName}" due to malformed code`);
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
throw e;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
walk(ast, {
|
|
29
|
+
enter(node) {
|
|
30
|
+
if (node.type === "CallExpression") {
|
|
31
|
+
const callee = node.callee;
|
|
32
|
+
|
|
33
|
+
function getMemberExpressionPath(expr) {
|
|
34
|
+
if (expr.type === "Identifier") return expr.name;
|
|
35
|
+
if (expr.type === "MemberExpression") {
|
|
36
|
+
const obj = getMemberExpressionPath(expr.object);
|
|
37
|
+
const prop = expr.property.name;
|
|
38
|
+
return obj ? `${obj}.${prop}` : prop;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const path = getMemberExpressionPath(callee);
|
|
44
|
+
if (path === "api.authentication.challengeWith" || path === "api.authentication.challengeWithAny") {
|
|
45
|
+
challengeFound = true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return challengeFound;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Main validator for Actions targeting the password-reset trigger.
|
|
56
|
+
*/
|
|
57
|
+
function checkPasswordResetMFA(options) {
|
|
58
|
+
const { actions, databases } = options || {};
|
|
59
|
+
|
|
60
|
+
return executeCheck("checkPasswordResetMFA", (callback) => {
|
|
61
|
+
// 1. Check for Active Password DBs (Same as before)
|
|
62
|
+
const hasActivePasswordDb = _.some(databases, (db) => {
|
|
63
|
+
const authMethods = db.options?.authentication_methods;
|
|
64
|
+
return db.strategy === "auth0" && (!authMethods || authMethods.password?.enabled !== false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (!hasActivePasswordDb) return callback([]);
|
|
68
|
+
|
|
69
|
+
const actionsList = _.isArray(actions) ? actions : actions.actions;
|
|
70
|
+
if (_.isEmpty(actionsList)) {
|
|
71
|
+
return callback([{ name: "Actions", report: [{ field: "no_actions_configured", status: CONSTANTS.WARN }] }]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 2. Aggregate scan across ALL relevant actions
|
|
75
|
+
let passwordResetActionsFound = [];
|
|
76
|
+
let anyActionHasMFA = false;
|
|
77
|
+
|
|
78
|
+
for (const action of actionsList) {
|
|
79
|
+
const triggers = action.supported_triggers || [];
|
|
80
|
+
const isPassReset = triggers.some(t => t.id === "password-reset-post-challenge");
|
|
81
|
+
if (isPassReset) { // deployed_version.deployed
|
|
82
|
+
passwordResetActionsFound.push(action.name);
|
|
83
|
+
if (hasMFAChallenge(action.code, action.name)) {
|
|
84
|
+
anyActionHasMFA = true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const reports = [];
|
|
90
|
+
const flowName = "Password Reset Flow";
|
|
91
|
+
|
|
92
|
+
// 3. Logic for the single finding report
|
|
93
|
+
if (passwordResetActionsFound.length === 0) {
|
|
94
|
+
reports.push({
|
|
95
|
+
name: "Password Reset Flow",
|
|
96
|
+
report: [{
|
|
97
|
+
scriptName: flowName,
|
|
98
|
+
name: flowName,
|
|
99
|
+
status: CONSTANTS.WARN,
|
|
100
|
+
variableName: "api.authentication.challengeWith",
|
|
101
|
+
line: "N/A",
|
|
102
|
+
column: "N/A",
|
|
103
|
+
field: "no_password_reset_action",
|
|
104
|
+
}]
|
|
105
|
+
});
|
|
106
|
+
} else if (!anyActionHasMFA) {
|
|
107
|
+
// NONE of the password reset actions had MFA
|
|
108
|
+
reports.push({
|
|
109
|
+
name: "Password Reset Flow",
|
|
110
|
+
report: [{
|
|
111
|
+
scriptName: `${passwordResetActionsFound.join(", ")}`,
|
|
112
|
+
status: CONSTANTS.WARN,
|
|
113
|
+
name: flowName,
|
|
114
|
+
variableName: "api.authentication.challengeWith",
|
|
115
|
+
line: "N/A",
|
|
116
|
+
column: "N/A",
|
|
117
|
+
field: "missing_mfa_step",
|
|
118
|
+
}]
|
|
119
|
+
});
|
|
120
|
+
} else {
|
|
121
|
+
// found an MFA challenge on password reset
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return callback(reports);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = checkPasswordResetMFA;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const _ = require("lodash");
|
|
2
|
+
const executeCheck = require("../executeCheck");
|
|
3
|
+
const CONSTANTS = require("../constants");
|
|
4
|
+
const acorn = require("acorn");
|
|
5
|
+
const walk = require("estree-walker").walk;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Scans the Action code for calls to api.access.deny()
|
|
9
|
+
* to warn about potential user enumeration vulnerabilities.
|
|
10
|
+
*/
|
|
11
|
+
function detectAccessDeny(code, scriptName) {
|
|
12
|
+
const findings = [];
|
|
13
|
+
|
|
14
|
+
let ast;
|
|
15
|
+
try {
|
|
16
|
+
ast = acorn.parse(code || "", {
|
|
17
|
+
ecmaVersion: "latest",
|
|
18
|
+
locations: true,
|
|
19
|
+
});
|
|
20
|
+
} catch (e) {
|
|
21
|
+
if (e instanceof SyntaxError) {
|
|
22
|
+
console.error(`[ACORN PARSE ERROR] Skipping script "${scriptName}" due to malformed code`);
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
throw e;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
walk(ast, {
|
|
29
|
+
enter(node) {
|
|
30
|
+
if (node.type === "CallExpression") {
|
|
31
|
+
const callee = node.callee;
|
|
32
|
+
|
|
33
|
+
// Helper function to reconstruct the property chain (e.g., "api.access.deny")
|
|
34
|
+
function getMemberExpressionPath(expr) {
|
|
35
|
+
if (expr.type === "Identifier") return expr.name;
|
|
36
|
+
if (expr.type === "MemberExpression") {
|
|
37
|
+
const obj = getMemberExpressionPath(expr.object);
|
|
38
|
+
const prop = expr.property.name;
|
|
39
|
+
return obj ? `${obj}.${prop}` : prop;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const path = getMemberExpressionPath(callee);
|
|
45
|
+
|
|
46
|
+
// This will now catch api.access.deny regardless of how Acorn nests the objects
|
|
47
|
+
if (path === "api.access.deny") {
|
|
48
|
+
findings.push({
|
|
49
|
+
scriptName: scriptName,
|
|
50
|
+
field: "user_enumeration_vulnerability",
|
|
51
|
+
status: CONSTANTS.WARN,
|
|
52
|
+
line: node.loc?.start?.line || "N/A",
|
|
53
|
+
column: node.loc?.start?.column || "N/A",
|
|
54
|
+
// We add this to match the grouping logic in report.js
|
|
55
|
+
variableName: "api.access.deny"
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return findings;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Main validator for Actions targeting the pre-user-registration trigger.
|
|
67
|
+
*/
|
|
68
|
+
function checkPreRegistrationUserEnumeration(options) {
|
|
69
|
+
const { actions } = options || [];
|
|
70
|
+
|
|
71
|
+
return executeCheck("checkPreRegistrationUserEnumeration", (callback) => {
|
|
72
|
+
const actionsList = _.isArray(actions) ? actions : actions.actions;
|
|
73
|
+
const reports = [];
|
|
74
|
+
|
|
75
|
+
if (_.isEmpty(actionsList)) {
|
|
76
|
+
return callback(reports);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const action of actionsList) {
|
|
80
|
+
const triggers = action.supported_triggers || [];
|
|
81
|
+
|
|
82
|
+
const isPreReg = triggers.some(t => t.id === "pre-user-registration");
|
|
83
|
+
// Only scan if the action is part of the pre-user-registration trigger
|
|
84
|
+
if (!isPreReg) continue;
|
|
85
|
+
|
|
86
|
+
const actionName = `${action.name} (pre-user-registration)`;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const findings = detectAccessDeny(action.code, actionName);
|
|
90
|
+
if (findings.length > 0) {
|
|
91
|
+
reports.push({ name: actionName, report: findings });
|
|
92
|
+
}
|
|
93
|
+
} catch (e) {
|
|
94
|
+
console.error(`[CHECK ERROR] Skipping Actions "${actionName}" due to error: ${e.message}`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return callback(reports);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = checkPreRegistrationUserEnumeration;
|
|
@@ -77,6 +77,11 @@ function checkURLsForApp(app) {
|
|
|
77
77
|
return report;
|
|
78
78
|
}
|
|
79
79
|
allowed_logout_urls.forEach((url) => {
|
|
80
|
+
if (!url) {
|
|
81
|
+
// Skip null/undefined/empty URLs and log warning
|
|
82
|
+
console.warn(`[WARNING] App "${app.name}" (${app.client_id}) has null/undefined URL in allowed_logout_urls`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
80
85
|
const subArr = insecurePatterns.filter((str) => url.includes(str));
|
|
81
86
|
if (subArr.length > 0) {
|
|
82
87
|
report.push({
|
package/analyzer/report.js
CHANGED
|
@@ -307,6 +307,8 @@ async function generateReport(locale, tenantConfig, config) {
|
|
|
307
307
|
});
|
|
308
308
|
});
|
|
309
309
|
break;
|
|
310
|
+
case "checkPasswordResetMFA":
|
|
311
|
+
case "checkPreRegistrationUserEnumeration":
|
|
310
312
|
case "checkActionsHardCodedValues":
|
|
311
313
|
case "checkDASHardCodedValues":
|
|
312
314
|
report.disclaimer = i18n.__(`${report.name}.disclaimer`);
|
package/locales/en.json
CHANGED
|
@@ -135,7 +135,9 @@
|
|
|
135
135
|
"items": [
|
|
136
136
|
"NPM Dependencies",
|
|
137
137
|
"Actions Runtime",
|
|
138
|
-
"Hardcoded Artifacts"
|
|
138
|
+
"Hardcoded Artifacts",
|
|
139
|
+
"Information Leakage in Signup Flow",
|
|
140
|
+
"MFA for Password Reset"
|
|
139
141
|
]
|
|
140
142
|
},
|
|
141
143
|
{
|
|
@@ -1369,6 +1371,67 @@
|
|
|
1369
1371
|
"hard_coded_value_detected": "Variable name <b>%s</b> at line <b>%d</b> and column <b>%d</b>.",
|
|
1370
1372
|
"action_script_title": "Identified potential hardcoded credentials in <b>\"%s\"</b> script at:"
|
|
1371
1373
|
},
|
|
1374
|
+
"checkPreRegistrationUserEnumeration": {
|
|
1375
|
+
"title": "Information Leakage in Signup Flow",
|
|
1376
|
+
"category": "Actions",
|
|
1377
|
+
"advisory": {
|
|
1378
|
+
"issue": "Exposing sensitive information during a signup attempt",
|
|
1379
|
+
"description": {
|
|
1380
|
+
"what_it_is": "User enumeration occurs when an application reveals whether a user exists in the system based on the response provided during actions like registration.",
|
|
1381
|
+
"why_its_risky": [
|
|
1382
|
+
"Using 'api.access.deny' in a Pre-User Registration Action to block signups from existing accounts may allow attackers to programmatically 'scrape' or verify which of your customers have accounts.",
|
|
1383
|
+
"If a Pre-User Registration Action is rejecting a signup attempt based on internal risk scoring then threat actors may leverage this information to tune their methods to evade detection.",
|
|
1384
|
+
"Disclosing account existance or or exposing risk scoring approaches in the Action, enables threat actors to more easily perform targeted attacks like phishing and credential stuffing, "
|
|
1385
|
+
]
|
|
1386
|
+
},
|
|
1387
|
+
"how_to_fix": [
|
|
1388
|
+
"Avoid using 'api.access.deny' in Pre-Registration Actions to notify users that an account is already registered or to notify them that risk level of the request was assessed to be too great.",
|
|
1389
|
+
"All registration attempts should receive a generic success response. If an account already exists, consider triggering a secure, automated email to the registered address informing the user of the attempt. This maintains a seamless user experience while protecting account privacy."
|
|
1390
|
+
]
|
|
1391
|
+
},
|
|
1392
|
+
"description": "Analyzes Pre-User Registration Actions for the use of 'api.access.deny', which can leak information or enable user enumeration.",
|
|
1393
|
+
"docsPath": [
|
|
1394
|
+
"https://auth0.com/docs/customize/actions/action-coding-guidelines"
|
|
1395
|
+
],
|
|
1396
|
+
"severity": "Low",
|
|
1397
|
+
"status": "green",
|
|
1398
|
+
"severity_message": "Potential user enumeration detected in a Pre User Registration Action.",
|
|
1399
|
+
"user_enumeration_vulnerability": "Found <b>%s</b> at line <b>%d</b>, column <b>%d</b> which may leak information.",
|
|
1400
|
+
"action_script_title": "Identified usage of api.access.deny in <b>\"%s\"</b> Action script:",
|
|
1401
|
+
"disclaimer": "Please review the Action to determine if any information is exposed that may be useful to a threat actor."
|
|
1402
|
+
},
|
|
1403
|
+
"checkPasswordResetMFA": {
|
|
1404
|
+
"title": "MFA for Password Reset",
|
|
1405
|
+
"category": "Actions",
|
|
1406
|
+
"advisory": {
|
|
1407
|
+
"issue": "Missing MFA Challenge during Password Reset",
|
|
1408
|
+
"description": {
|
|
1409
|
+
"what_it_is": "This check verifies if your Password Reset Post Challenge Action requires a second factor before allowing a user to change their password.",
|
|
1410
|
+
"why_its_risky": [
|
|
1411
|
+
"If an attacker gains access to a user's email account, they can trigger a password reset and take over the identity without any further verification.",
|
|
1412
|
+
"MFA during password reset ensures that even with a compromised email, the attacker has to overcome the secondary factor challenge."
|
|
1413
|
+
]
|
|
1414
|
+
},
|
|
1415
|
+
"how_to_fix": [
|
|
1416
|
+
"Create or update an Action in the Password Reset Post Challenge flow.",
|
|
1417
|
+
"Use 'api.authentication.challengeWith()' to trigger an MFA challenge for users who have enrolled factors.",
|
|
1418
|
+
"Ensure the Action is 'Deployed' and attached to the Password Reset trigger in the Auth0 Pipeline."
|
|
1419
|
+
]
|
|
1420
|
+
},
|
|
1421
|
+
"description": "Analyzes Password Reset Actions to ensure MFA is being enforced as an additional security layer.",
|
|
1422
|
+
"docsPath": [
|
|
1423
|
+
"https://auth0.com/docs/customize/actions/explore-triggers/password-reset-triggers"
|
|
1424
|
+
],
|
|
1425
|
+
"severity": "Moderate",
|
|
1426
|
+
"status": "yellow",
|
|
1427
|
+
"severity_message": "an MFA Challenge is not detected in the Password Reset flow.",
|
|
1428
|
+
"mfa_challenge_detected": "MFA Challenge (<b>%s</b>) correctly implemented at line <b>%d</b>.",
|
|
1429
|
+
"missing_mfa_step": "Action script exists but does not appear to trigger an MFA challenge.",
|
|
1430
|
+
"no_password_reset_action": "No Action is currently configured for the Password Reset trigger. Password resets are only protected by email access.",
|
|
1431
|
+
"no_actions_configured": "No Actions found in tenant.",
|
|
1432
|
+
"disclaimer": "This finding may be a false positive if Identity Proofing or similar mitigation is enforced elsewhere in the authentication or password reset workflow. Verify any alternative security controls are active before dismissing this recommendation.",
|
|
1433
|
+
"action_script_title": "A deployed Password Reset Action that triggers an MFA challenge was not found based on searching for the 'api.authentication.challengeWith()' or equivalent method."
|
|
1434
|
+
},
|
|
1372
1435
|
"checkCanonicalDomain": {
|
|
1373
1436
|
"title": "Auth0 Domain Check",
|
|
1374
1437
|
"category": "Canonical Domain",
|
|
@@ -1414,4 +1477,4 @@
|
|
|
1414
1477
|
"https://auth0.com/docs/customize/events/event-testing-observability-and-failure-recovery"
|
|
1415
1478
|
]
|
|
1416
1479
|
}
|
|
1417
|
-
}
|
|
1480
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
const chai = require("chai");
|
|
2
|
+
const expect = chai.expect;
|
|
3
|
+
const checkPasswordResetMFA = require("../../analyzer/lib/actions/checkPasswordResetMFA");
|
|
4
|
+
|
|
5
|
+
describe("checkPasswordResetMFA", function () {
|
|
6
|
+
// Mock Database Connection to trigger the validator's logic
|
|
7
|
+
const mockDbConnections = [{ id: "con_1", strategy: "auth0", name: "Username-Password-Authentication" }];
|
|
8
|
+
// Mock Social Connection only
|
|
9
|
+
const mockSocialConnections = [{ id: "con_2", strategy: "google-oauth2", name: "google" }];
|
|
10
|
+
|
|
11
|
+
it("should return SUCCESS when a password-reset-post-challenge action correctly implements MFA challenges", async function () {
|
|
12
|
+
const input = {
|
|
13
|
+
databases: mockDbConnections,
|
|
14
|
+
actions: {
|
|
15
|
+
actions: [
|
|
16
|
+
{
|
|
17
|
+
id: "pw-reset-mfa-ok",
|
|
18
|
+
name: "Secure Reset",
|
|
19
|
+
supported_triggers: [{ id: "password-reset-post-challenge", version: "v1" }],
|
|
20
|
+
code: `exports.onExecutePostChallenge = async (event, api) => {
|
|
21
|
+
api.authentication.challengeWithAny();
|
|
22
|
+
};`,
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const reports = await checkPasswordResetMFA(input);
|
|
29
|
+
expect(reports.details).to.have.lengthOf(0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should return WARN when a password-reset-post-challenge action exists but is missing the MFA challenge", async function () {
|
|
33
|
+
const input = {
|
|
34
|
+
databases: mockDbConnections,
|
|
35
|
+
actions: {
|
|
36
|
+
actions: [
|
|
37
|
+
{
|
|
38
|
+
id: "pw-reset-weak",
|
|
39
|
+
name: "Weak Reset",
|
|
40
|
+
supported_triggers: [{ id: "password-reset-post-challenge", version: "v1" }],
|
|
41
|
+
code: `exports.onExecutePostChallenge = async (event, api) => { return; };`,
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const reports = await checkPasswordResetMFA(input);
|
|
48
|
+
expect(reports.details).to.have.lengthOf(1);
|
|
49
|
+
expect(reports.details[0].report[0].field).to.equal("missing_mfa_step");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should return NO FINDINGS for a Social-Only tenant (Passwordless)", async function () {
|
|
53
|
+
const input = {
|
|
54
|
+
databases: mockSocialConnections, // Only Social
|
|
55
|
+
actions: {
|
|
56
|
+
actions: [
|
|
57
|
+
{
|
|
58
|
+
id: "action-1",
|
|
59
|
+
name: "Some Action",
|
|
60
|
+
supported_triggers: [{ id: "password-reset-post-challenge", version: "v1" }],
|
|
61
|
+
code: `exports.onExecutePostChallenge = async (event, api) => { return; };`,
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const reports = await checkPasswordResetMFA(input);
|
|
68
|
+
// Should be empty because password reset doesn't apply to Social-only tenants
|
|
69
|
+
expect(reports.details).to.be.an("array").that.is.empty;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("should ignore MFA challenges in actions that are NOT part of the password-reset flow", async function () {
|
|
73
|
+
const input = {
|
|
74
|
+
databases: mockDbConnections,
|
|
75
|
+
actions: {
|
|
76
|
+
actions: [
|
|
77
|
+
{
|
|
78
|
+
id: "login-mfa",
|
|
79
|
+
name: "Login MFA",
|
|
80
|
+
supported_triggers: [{ id: "post-login", version: "v3" }],
|
|
81
|
+
code: 'exports.onExecutePostLogin = async (event, api) => { api.authentication.challengeWithAny(); };',
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const reports = await checkPasswordResetMFA(input);
|
|
88
|
+
expect(reports.details).to.have.lengthOf(1);
|
|
89
|
+
expect(reports.details[0].report[0].field).to.equal("no_password_reset_action");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should detect alternate challenge methods like challengeWith", async function () {
|
|
93
|
+
const input = {
|
|
94
|
+
databases: mockDbConnections,
|
|
95
|
+
actions: {
|
|
96
|
+
actions: [
|
|
97
|
+
{
|
|
98
|
+
id: "pw-reset-otp",
|
|
99
|
+
name: "OTP Reset",
|
|
100
|
+
supported_triggers: [{ id: "password-reset-post-challenge", version: "v1" }],
|
|
101
|
+
code: `exports.onExecutePostChallenge = async (event, api) => {
|
|
102
|
+
api.authentication.challengeWith({ type: 'otp' });
|
|
103
|
+
};`,
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const reports = await checkPasswordResetMFA(input);
|
|
110
|
+
expect(reports.details).to.have.lengthOf(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("should return a warning if the actions list is entirely empty but DB connections exist", async function () {
|
|
114
|
+
const input = {
|
|
115
|
+
databases: mockDbConnections,
|
|
116
|
+
actions: { actions: [] }
|
|
117
|
+
};
|
|
118
|
+
const reports = await checkPasswordResetMFA(input);
|
|
119
|
+
|
|
120
|
+
expect(reports.details[0].report[0].field).to.equal("no_actions_configured");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const chai = require("chai");
|
|
2
|
+
const expect = chai.expect;
|
|
3
|
+
const checkPreRegistrationUserEnumeration = require("../../analyzer/lib/actions/checkUserEnumeration");
|
|
4
|
+
const CONSTANTS = require("../../analyzer/lib/constants");
|
|
5
|
+
|
|
6
|
+
describe("checkPreRegistrationUserEnumeration", function () {
|
|
7
|
+
it("should return an empty array when no pre-registration actions use api.access.deny", async function () {
|
|
8
|
+
const input = {
|
|
9
|
+
actions: {
|
|
10
|
+
actions: [
|
|
11
|
+
{
|
|
12
|
+
id: "12345",
|
|
13
|
+
name: "Safe Action",
|
|
14
|
+
supported_triggers: [{ id: "pre-user-registration", version: "v1" }],
|
|
15
|
+
code: "exports.onExecutePreUserRegistration = async (event, api) => { return; };",
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const reports = await checkPreRegistrationUserEnumeration(input);
|
|
22
|
+
expect(reports.details).to.be.an("array").that.is.empty;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should ignore api.access.deny in actions that are NOT pre-user-registration", async function () {
|
|
26
|
+
const input = {
|
|
27
|
+
actions: {
|
|
28
|
+
actions: [
|
|
29
|
+
{
|
|
30
|
+
id: "67890",
|
|
31
|
+
name: "Post Login Deny",
|
|
32
|
+
supported_triggers: [{ id: "post-login", version: "v3" }],
|
|
33
|
+
code: 'exports.onExecutePostLogin = async (event, api) => { api.access.deny("Access Denied"); };',
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const reports = await checkPreRegistrationUserEnumeration(input);
|
|
40
|
+
expect(reports.details).to.be.an("array").that.is.empty;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should detect api.access.deny in pre-user-registration action code", async function () {
|
|
44
|
+
const input = {
|
|
45
|
+
actions: {
|
|
46
|
+
actions: [
|
|
47
|
+
{
|
|
48
|
+
id: "auth0-vulnerability-test",
|
|
49
|
+
name: "User Check",
|
|
50
|
+
supported_triggers: [{ id: "pre-user-registration", version: "v1" }],
|
|
51
|
+
code: `exports.onExecutePreUserRegistration = async (event, api) => {
|
|
52
|
+
if(event.user.email === "test@example.com") {
|
|
53
|
+
api.access.deny("User already exists", "Testing user enumeration vuln");
|
|
54
|
+
}
|
|
55
|
+
};`,
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const reports = await checkPreRegistrationUserEnumeration(input);
|
|
62
|
+
|
|
63
|
+
expect(reports.details).to.have.lengthOf(1);
|
|
64
|
+
const detail = reports.details[0];
|
|
65
|
+
|
|
66
|
+
expect(detail.name).to.equal("User Check (pre-user-registration)");
|
|
67
|
+
expect(detail.report).to.have.lengthOf(1);
|
|
68
|
+
|
|
69
|
+
const finding = detail.report[0];
|
|
70
|
+
expect(finding.variableName).to.equal("api.access.deny");
|
|
71
|
+
expect(finding.field).to.equal("user_enumeration_vulnerability");
|
|
72
|
+
expect(finding.status).to.equal(CONSTANTS.WARN);
|
|
73
|
+
// Line 3 is where the call starts in the template string above
|
|
74
|
+
expect(finding.line).to.equal(3);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("should flag multiple occurrences of api.access.deny in the same action", async function () {
|
|
78
|
+
const input = {
|
|
79
|
+
actions: {
|
|
80
|
+
actions: [
|
|
81
|
+
{
|
|
82
|
+
id: "multi-deny",
|
|
83
|
+
name: "Strict Validation",
|
|
84
|
+
supported_triggers: [{ id: "pre-user-registration", version: "v1" }],
|
|
85
|
+
code: `exports.onExecutePreUserRegistration = async (event, api) => {
|
|
86
|
+
if (!event.user.email) api.access.deny("duplicate emaill");
|
|
87
|
+
if (event.user.name === 'admin') api.access.deny("high risk");
|
|
88
|
+
};`,
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const reports = await checkPreRegistrationUserEnumeration(input);
|
|
95
|
+
|
|
96
|
+
expect(reports.details).to.have.lengthOf(1);
|
|
97
|
+
const reportList = reports.details[0].report;
|
|
98
|
+
expect(reportList).to.have.lengthOf(2);
|
|
99
|
+
expect(reportList[0].variableName).to.equal("api.access.deny");
|
|
100
|
+
expect(reportList[1].variableName).to.equal("api.access.deny");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -146,4 +146,47 @@ describe("checkAllowedLogoutUrl", function () {
|
|
|
146
146
|
]);
|
|
147
147
|
});
|
|
148
148
|
});
|
|
149
|
+
|
|
150
|
+
it("should handle null/undefined URLs in allowed_logout_urls array without crashing", function () {
|
|
151
|
+
const options = {
|
|
152
|
+
clients: [
|
|
153
|
+
{
|
|
154
|
+
name: "Test App with Null URLs",
|
|
155
|
+
client_id: "client_with_null",
|
|
156
|
+
allowed_logout_urls: ["https://contoso.com", null, "http://localhost:3000", undefined], // Contains null and undefined
|
|
157
|
+
app_type: "spa",
|
|
158
|
+
is_first_party: false,
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
checkAllowedLogoutUrl(options, (reports) => {
|
|
164
|
+
// Should only process valid URLs and skip null/undefined
|
|
165
|
+
expect(reports).to.deep.equal([
|
|
166
|
+
{
|
|
167
|
+
name: "Test App with Null URLs (client_with_null)",
|
|
168
|
+
report: [
|
|
169
|
+
{
|
|
170
|
+
name: "Test App with Null URLs (client_with_null)",
|
|
171
|
+
client_id: "client_with_null",
|
|
172
|
+
field: "insecure_allowed_logout_urls",
|
|
173
|
+
value: "http://localhost:3000",
|
|
174
|
+
status: CONSTANTS.FAIL,
|
|
175
|
+
app_type: "spa",
|
|
176
|
+
is_first_party: false,
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: "Test App with Null URLs (client_with_null)",
|
|
180
|
+
client_id: "client_with_null",
|
|
181
|
+
field: "secure_allowed_logout_urls",
|
|
182
|
+
status: CONSTANTS.SUCCESS,
|
|
183
|
+
value: "https://contoso.com",
|
|
184
|
+
app_type: "spa",
|
|
185
|
+
is_first_party: false,
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
},
|
|
189
|
+
]);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
149
192
|
});
|