← All KB Checks
CRITICALLinterCheck

setRoles() Escalation Detector

nowisor-set-roles-detector··Source on GitHub →

setRoles() Escalation Detector

This page describes the nowisor-set-roles-detector check from the nowisor Instance Scan Pack v1.0.0. The check is a LinterCheck — it walks every server-side script and emits one finding per call to setRoles(). A typical instance produces low volume — setRoles() is rare in custom code and almost never appears in OOB ServiceNow code outside internal identity management.

What this finding means

Every finding identifies one line of server-side code that calls setRoles(). The check matches the AST shape NAME 'setRoles' whose parent is CALL OR whose grandparent is CALL — covering both the (rare) function-call form and the (universal) method-call form (gs.getUser().setRoles('admin')).

setRoles() mutates the current session's role membership for the duration of the script's execution. After the call, the platform treats the session as if the listed roles are held by the authenticated user — for ACL evaluation, role-based query filters, and any subsequent server-side decision that consults role state. The mutation lasts until the script completes; it does not persist as a role grant in sys_user_has_role.

Despite being session-scoped, the implications are severe. Within a single request, setRoles('admin') gives the script ACL-bypassing privileges. Code executing after the call has admin-level read/write access to every table in the instance — sys_user_has_role, sys_security_acl, sys_properties, anything. If that subsequent code performs a write, the platform records the write — but the role grant that made the write possible left no record.

The check fires on every setRoles() reachable in server-side script content. Each finding deserves individual review: legitimate use cases exist (initial system bootstrap, identity federation flows, controlled impersonation), but they should be small in number, well-documented, and protected from external invocation.

Why it matters for your compliance

setRoles() is the privilege-mutation primitive in the platform. Every compliance framework that addresses identity and access management treats it as a high-attention surface.

NIS2 Article 21§2(j) — identity and access management. NIS2 requires that entities manage authorization decisions consistently across the technical surface. A setRoles() call reachable from user-influenced input is a path where authorization is mutated outside the entity's IAM controls — the IdP grants the user role X, the script silently grants role admin, the regulated authorization model is no longer authoritative. NIS2 supervisors check for this pattern in onsite reviews when they have ServiceNow expertise.

ISO 27001 A.5.18 — access rights. A.5.18 covers the provisioning and revocation of access rights. Provisioning that happens via setRoles() in a script — even temporarily — bypasses the documented provisioning process. Auditors increasingly understand the platform-specific call and ask for inventory and justification.

Severity 1 (critical), because the call is a one-line privilege-escalation primitive. The asymmetry is the same as for eval(): low cost to refactor (most use cases have a safer alternative), unbounded cost to leave in place if external input can influence the call site.

The attack path

The path runs through code-reachability. The attacker doesn't directly call setRoles() — they call something that calls something that calls setRoles(), with the role list under their influence.

Step 1 — Find a setRoles() reachable from input. The attacker enumerates server-side scripts for the call. The high-value pattern is a client_callable=true Script Include that calls setRoles() with a role list derived from a parameter:

// Vulnerable script include — client_callable=true
var ImpersonationHelper = Class.create();
ImpersonationHelper.prototype = {
    elevate: function(targetRole) {
        var user = gs.getUser();
        user.setRoles(targetRole);   // <-- attacker controls targetRole
        // ... operation that requires the role
    },
    type: 'ImpersonationHelper'
};

This pattern appears in legacy code that intended to provide "temporary debug elevation" for support engineers. The intent was that targetRole would be a limited debugging role; the implementation never constrained the parameter.

Step 2 — Call the script. Because the include is client_callable, the attacker can invoke it from a Service Portal context or a UI action. They pass 'admin' (or 'security_admin') as the parameter.

Step 3 — Privilege escalation, in-request. The platform calls setRoles('admin') on the user's session. For the remainder of the current request, the user has admin privileges. The same script — or any code reachable from the same request — can now perform admin-level operations.

Step 4 — Persistence. The attacker's payload uses the in-request admin privileges to insert a persistent role grant into sys_user_has_role:

// Payload runs inside the elevated request
var grant = new GlideRecord('sys_user_has_role');
grant.initialize();
grant.user = '<ATTACKER_SYSID>';
grant.role = '<ADMIN_ROLE_SYSID>';
grant.setWorkflow(false);
grant.insert();

The role grant persists. The attacker logs out, logs back in, and now has admin role at the persistent level — no longer dependent on the in-request mutation.

The defense is twofold: remove setRoles() from any client-callable or user-reachable code, and ensure that the role list passed to legitimate setRoles() calls is a static literal or derived only from internal state.

How to fix it

This is a triage check. Findings are inventory; remediation is per-call.

For each finding, classify:

  1. Client-callable script include or UI ActionCritical priority. The call is directly reachable from user input. Remove setRoles() from the script entirely. If the use case is legitimate impersonation, replace with GlideImpersonate, which provides audit logging, explicit impersonation scope, and platform-managed lifecycle.

  2. Scripted REST API — Critical. Same as above. Remove the call or replace with GlideImpersonate. If the API needs to operate with elevated privileges for legitimate reasons, change its run-as user to a dedicated service account with the required role — don't mutate session roles at runtime.

  3. Business Rule on a user-facing table — High priority. The BR runs in the context of whatever user triggered the underlying write. If setRoles() runs in a BR, it elevates that user for the rest of the request, including downstream BRs. Refactor to avoid the elevation; if it's truly needed, document the BR's exact reachability and the role list source.

  4. System bootstrap or scheduled job (admin-only context) — Likely legitimate. Verify the script runs only via the platform's internal scheduler or a UI Action restricted to admin users. The role list passed should be a static literal. Add inline documentation.

  5. Identity federation flow — Legitimate (this is what the call exists for). Verify that the role list comes from a trusted source (the SSO assertion's role claims, mapped via a server-side role-mapping table that the SSO admin controls) and not from any user-supplied parameter.

For all legitimate cases, prefer GlideImpersonate over setRoles(). The impersonation API provides the audit and lifecycle that setRoles() lacks.

How to verify the fix

/*
 * Verify nowisor-set-roles-detector remediation progress
 * Read-only, safe for production.
 * Lists open findings grouped by source script.
 */
(function pullSetRolesFindings() {
    var byScript = {};
    var f = new GlideRecord('scan_finding');
    f.addQuery('check.sys_scope.scope', 'x_nowisor_isp');
    f.addQuery('check.name', 'setRoles() Escalation Detector');
    f.query();

    while (f.next()) {
        var sourceName = f.getDisplayValue('source') || '(unknown)';
        if (!byScript[sourceName]) byScript[sourceName] = 0;
        byScript[sourceName]++;
    }

    var names = Object.keys(byScript).sort();
    gs.print('=== setRoles() findings by source ===');
    gs.print('Total scripts with findings: ' + names.length);
    gs.print('');
    for (var i = 0; i < names.length; i++) {
        gs.print('  ' + names[i] + ': ' + byScript[names[i]] + ' call(s)');
    }
    if (names.length === 0) {
        gs.print('Clean: no setRoles() calls detected in current scan.');
    }
})();

The expected end state is zero or a small number of well-documented findings. Every remaining call should be in a script that is not client-callable, not reachable from user input, and accompanied by inline documentation of why the elevation is necessary and how the role list is constrained.

A note on the v1.0.0 build of this check: the original predicate required parent === CALL, which only matched function-call AST shapes. Because setRoles is universally a method call (gs.getUser().setRoles(...)), the original predicate matched nothing. The predicate was rewritten in May 2026 to also accept grandparent === CALL, which catches the method-call shape. The fix is documented in V1_RETROSPECTIVE_TIER2.md. Upgrading to v1.0.0 GA is required to get correct findings.

What to do next

setRoles() is the role-mutation primitive. The companion checks address related primitives in the code-discipline cluster:

For organizations under NIS2 identity-and-access-management scrutiny, the nowisor advisor product is being built to surface setRoles() findings alongside the persistent-role audit (admin role concentration, elevated role co-assignments, inactive users with roles) so the privilege-mutation surface and the privilege-persistence surface are reviewed together as one cluster. Get the IAM evidence flow →