> For clean Markdown of any page, append .md to the page URL. > For a complete documentation index, see https://docs.pensar.dev/integrations/service-now/llms.txt. > For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.pensar.dev/_mcp/server. # ServiceNow > Connect ServiceNow to file Pensar findings as incidents and keep their state in sync # ServiceNow Integration Pensar Console integrates with ServiceNow to file security findings as records on your own instance — `incident` or, where the Security Incident Response plugin is installed, `sn_si_incident` — and to keep their state in sync with the finding in Pensar. Unlike Linear or Slack, there is no Pensar-wide ServiceNow application to approve. A ServiceNow OAuth client lives inside a single instance, in that instance's **Application Registry**, so the client ID and secret can only be created by someone with admin access to your instance. Everything below that requires work in ServiceNow — registering the OAuth application and, for inbound status sync, writing a Business Rule — exists for that reason. > **Note** > > Records created by Pensar are attributed to whichever identity you connect with: the ServiceNow user who approves the OAuth authorization, or the service account behind basic authentication. Pensar inherits that user's roles — it can only read and write what they can. ## Prerequisites Before connecting ServiceNow, ensure you have: 1. **Admin access to your ServiceNow instance** — enough to open **System OAuth → Application Registry**, and (for inbound status sync) to create a Script Include and a Business Rule 2. **Admin or owner access** to the Pensar Console workspace you want to connect 3. Your **instance URL**, in the form `https://.service-now.com` > **Note** > > Pensar accepts the instance URL as an origin only — `https://acme.service-now.com`, not `https://acme.service-now.com/now/nav/ui`. It must use HTTPS, and any path, query string or trailing slash is rejected rather than silently trimmed. ## Connecting ServiceNow Both options start the same way: 1. Navigate to **Settings** → **Integrations** → **Issue tracking** in your Pensar Console workspace 2. Find the **Issue sync** card, locate the **ServiceNow** row and click **Manage** 3. Enter your **instance URL**, then choose an authentication method ### Option A — OAuth 2.0 (recommended) OAuth gives Pensar a short-lived access token that it refreshes on its own, and a token you can revoke from inside ServiceNow at any time. Prefer it over basic authentication. **1. Register the OAuth application in ServiceNow** 1. In your ServiceNow instance, navigate to **All** → **System OAuth** → **Application Registry** 2. Click **New** 3. Choose **Create an OAuth API endpoint for external clients** 4. Fill in: * **Name**: `Pensar` (or your preferred name) * **Client ID**: leave the generated value * **Client Secret**: leave the generated value, or set your own * **Redirect URL**: `https://console.pensar.dev/api/auth/servicenow-callback` 5. Click **Submit**, then reopen the record and copy the **Client ID** and **Client Secret** > **Note** > > If your workspace is on a Console host other than `console.pensar.dev`, substitute that host — the path `/api/auth/servicenow-callback` is the same. The redirect URL must match exactly, including the scheme; ServiceNow rejects the authorization request otherwise. **2. Connect from Pensar** 1. Back in the **ServiceNow** section of the **Issue sync** card, paste the **Client ID** and **Client Secret** alongside your instance URL and submit 2. You'll be redirected to your instance's authorization screen (`/oauth_auth.do`) 3. Sign in as the user Pensar should act as, and approve the request 4. You'll be redirected back to Pensar Console with ServiceNow connected There is no scope list to review on that screen. A ServiceNow OAuth client has no scopes of its own — it inherits the roles of the user who approves it. Approve as an account that holds the roles Pensar needs (see [Roles](#roles)), and no more. Pensar refreshes the access token automatically before it expires. Two ServiceNow settings on the Application Registry record govern how long that can continue: | Application Registry field | ServiceNow default | Effect | | -------------------------- | ---------------------------- | ---------------------------------------------- | | **Access Token Lifespan** | 1,800 seconds | How often Pensar silently refreshes | | **Refresh Token Lifespan** | 8,640,000 seconds (100 days) | How long before you must reconnect from Pensar | > **Note** > > Some instances are configured to rotate the refresh token on every use and some are not. Pensar handles both — it stores a rotated token when ServiceNow returns one and keeps the existing one when it doesn't. ### Option B — Basic authentication Where OAuth is not an option, Pensar can authenticate as a dedicated ServiceNow service account. 1. In ServiceNow, navigate to **All** → **User Administration** → **Users** and click **New** 2. Create a dedicated account — for example `pensar.integration` — and set a strong password 3. Check **Web service access only** so the account cannot sign in to the UI, and ensure multi-factor authentication is not enforced for it (basic authentication cannot satisfy an MFA challenge) 4. Grant it the [roles](#roles) below 5. Enter the username and password in Pensar's ServiceNow setup form > **Warning** > > Basic authentication stores a long-lived shared credential. Pensar encrypts it at rest, but there is no rotation and no revocation path from Pensar's side: **Disconnect** clears Pensar's copy of the credential, it does not disable the account. Changing the password in ServiceNow breaks the integration until you reconnect with the new one, and any leak has to be handled by deactivating the account in ServiceNow. Use OAuth unless something prevents it. ### Roles Whichever method you use, the connected identity needs to: * **Create and update records on the table you file into** — `itil` covers `incident`; Security Incident Response tables such as `sn_si_incident` need the corresponding `sn_si` roles (`sn_si.basic` at minimum, plus an analyst role to write) * **Read `sys_db_object`** — Pensar verifies the credential against it and uses it to discover which filing tables exist on your instance * **Read `sys_choice`** — to resolve your table's own `state` choices when mapping status * **Read `sys_dictionary`** — to check whether the target table declares `close_code` and `close_notes` before writing them The last three are dictionary tables that most authenticated ServiceNow users can already read. If your instance restricts them, Pensar will connect but the table picker will come up empty. ## What Gets Synced Once connected and enabled, Pensar inserts a record on the routed table for each matching finding, setting four fields: | ServiceNow field | Value | | ------------------- | ------------------------------------------------------ | | `short_description` | The finding's title, prefixed with `[Pensar Security]` | | `description` | A plain-text summary of the finding | | `impact` | Derived from the finding's severity | | `urgency` | Derived from the finding's severity | Nothing else is set. Pensar does not populate `assignment_group`, `caller_id`, `category` or `configuration_item` — your own assignment rules and Business Rules run on the new record exactly as they would on any other insert. The `description` field renders literally in ServiceNow, so Pensar writes it as plain text rather than the Markdown it sends to other trackers. It contains: * The severity * The finding's description * A **Details** block: the Pensar reference, the affected location, the line number(s), the branch, and the CWE classification (or `Not classified`) * A link back to the finding in the Pensar Console * A link to the related pull request, when there is one ### Severity Mapping ServiceNow derives `priority` from the `impact`/`urgency` pair through your instance's priority lookup rules, so those two are what Pensar sets: | Pensar severity | `impact` | `urgency` | Resulting priority (out-of-box matrix) | | --------------- | ---------- | ---------- | -------------------------------------- | | Critical | 1 — High | 1 — High | 1 — Critical | | High | 2 — Medium | 1 — High | 2 — High | | Medium | 2 — Medium | 2 — Medium | 3 — Moderate | | Low | 3 — Low | 3 — Low | 5 — Planning | > **Note** > > The priority column shows what the out-of-box priority matrix produces. If your instance customises the lookup rules, your priorities will differ — Pensar sets `impact` and `urgency` and lets ServiceNow decide the rest. ### Opening a finding in ServiceNow When a ServiceNow record already exists for a finding, the finding's actions menu includes **View in ServiceNow**. Otherwise you can file one from the same menu with **Open as ServiceNow record**. That does not require auto-sync to be enabled, only that ServiceNow is connected and a table is selected (the workspace default, or a per-repository override). If a record is already linked, Pensar opens it instead of creating a duplicate. ## Choosing the Table Pensar files into one table per repository, with a workspace default plus optional per-repository overrides — the same model as Linear teams. The picker offers the filing tables your instance actually has: | Table | When it appears | | ---------------- | ------------------------------------------------------------ | | `incident` | Present on every standard instance | | `sn_si_incident` | Only when the Security Incident Response plugin is installed | Pensar reads this from the instance rather than offering both blind, so an instance without Security Incident Response will only show **Incident**. ### Default Table 1. In the **ServiceNow** section, set the workspace-level **Table** 2. This is the fallback Pensar files into for any repository without an override, and for findings that aren't tied to a specific repository ### Per-Repository Overrides Each repository can override the default routing: | Setting | Description | | ---------------------- | ------------------------------------------------------------------------------------------------- | | **Sync to ServiceNow** | Toggle whether findings from this repository are filed as ServiceNow records | | **Table** | The ServiceNow table findings from this repository are filed in (falls back to the default table) | | **Severity Filter** | The minimum severity required before a finding is filed | > **Note** > > Select a table before enabling sync for a repository. Repositories without an override inherit the workspace default. Because the table is part of the routing, two repositories can file into different tables — and Pensar remembers which table each record lives on, so status sync keeps working after you change the routing. ## Status Mapping `incident` and `sn_si_incident` number their `state` field differently, and most instances customise both. A hardcoded number would file into the wrong state silently, so Pensar does not use one. Instead it reads the **active `state` choices for the table** out of your instance and matches them **on their label**: | Pensar status | Matches a state label containing (case-insensitive) | | -------------- | ----------------------------------------------------------------------------- | | False positive | `cancel`, `false positive`, `not reproducible`, `duplicate`, `not applicable` | | Closed | `closed`, `resolved`, `complete` | | In review | `progress`, `analysis`, `review`, `assess`, `work` | | Open | `new`, `draft`, `open` | The order matters, and it is the order in the table: the dismissal patterns are tried first, so a state labelled `Closed - Duplicate` reads as a **false positive** rather than a fix. The first pattern that matches wins. The same matcher runs in both directions, so an outbound transition and an inbound webhook can never disagree about what a label means. > **Note** > > If no active state on your table matches — a fully renamed state list, for example — Pensar logs a warning and **leaves the record alone**. It will never guess a numeric state. If a Pensar status isn't reaching ServiceNow, check that your table has an active state whose label matches one of the patterns above, and rename or add one if not. ### Closure Fields When Pensar closes a record, it also writes `close_code` and `close_notes` — but only on tables that declare those columns, checked against the table and its parents before writing: | Pensar status | `close_code` | `close_notes` | | -------------- | ------------------------------- | ------------------------------------------------------------ | | Closed | `Solved (Permanently)` | `Closed in Pensar: the finding was remediated.` | | False positive | `Not Solved (Not Reproducible)` | `Closed in Pensar: the finding was marked a false positive.` | ## Two-Way Status Sync Outbound sync (Pensar → ServiceNow) needs no setup: closing or reopening a finding in Pensar transitions the linked record. Inbound sync (ServiceNow → Pensar) does. ServiceNow has no application-level webhook Pensar can subscribe to, so your instance has to tell Pensar when a state changes. That takes four steps: copy the endpoint and secret out of Pensar, store them as system properties, add a Script Include that signs the payload, and add a Business Rule that sends it. Make sure **Reflect ServiceNow status changes** is enabled in the **ServiceNow** section of the **Issue sync** card — that toggle is what allows an inbound change to move a Pensar finding. ### 1. Copy the webhook URL and signing secret The **ServiceNow** section of the **Issue sync** card shows both. The URL is specific to your workspace and looks like: ```text https://api.pensar.dev/webhooks/servicenow/ ``` The signing secret is generated when you connect. Treat it like a password. ### 2. Store them as system properties Keeping both values in `sys_properties` means rotating the secret is a one-field edit rather than a script change. 1. In ServiceNow, navigate to **All** → **System Properties** → **All Properties** and click **New** 2. Create: | Name | Type | Value | | ----------------------- | ------ | ------------------------------ | | `pensar.webhook.url` | string | The webhook URL from step 1 | | `pensar.webhook.secret` | string | The signing secret from step 1 | 3. On `pensar.webhook.secret`, set **Read roles** to `admin` and check **Private** so the value isn't captured in update sets > **Warning** > > Paste both values with no leading or trailing whitespace. A stray newline in the secret changes the signature and every delivery will be rejected. ### 3. Add the signing Script Include Pensar authenticates the webhook with an HMAC-SHA256 signature over the raw request body, sent as a hex digest. ServiceNow's built-in `GlideDigest` produces plain hashes rather than HMACs, and `Packages.javax.crypto` is blocked by the script sandbox on current releases, so the signature is computed in script. Create a Script Include: * **Name**: `PensarSignature` * **Application**: Global * **Accessible from**: All application scopes * **Client callable**: unchecked * **Active**: checked ```javascript var PensarSignature = Class.create(); PensarSignature.prototype = { initialize: function() {}, /** Hex HMAC-SHA256 of `message` keyed with `secret`. */ hmacSha256Hex: function(secret, message) { var key = this._utf8(secret), ipad = [], opad = [], i; if (key.length > 64) key = this._sha256(key); while (key.length < 64) key.push(0); for (i = 0; i < 64; i++) { ipad.push(key[i] ^ 0x36); opad.push(key[i] ^ 0x5c); } var inner = this._sha256(ipad.concat(this._utf8(message))); return this._hex(this._sha256(opad.concat(inner))); }, _K: [ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 ], _rotr: function(x, n) { return ((x >>> n) | (x << (32 - n))) >>> 0; }, /** UTF-8 encode a string to a byte array. */ _utf8: function(str) { var out = [], i, c, c2; for (i = 0; i < str.length; i++) { c = str.charCodeAt(i); if (c >= 0xd800 && c <= 0xdbff && i + 1 < str.length) { c2 = str.charCodeAt(i + 1); if (c2 >= 0xdc00 && c2 <= 0xdfff) { c = 0x10000 + ((c - 0xd800) << 10) + (c2 - 0xdc00); i++; } } if (c >= 0xd800 && c <= 0xdfff) c = 0xfffd; if (c < 0x80) out.push(c); else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); else if (c < 0x10000) out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); else out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); } return out; }, _sha256: function(bytes) { var H = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 ]; var m = bytes.slice(), bitLen = bytes.length * 8, w = [], i, t; m.push(0x80); while (m.length % 64 !== 56) m.push(0); m.push(0, 0, 0, 0, (bitLen >>> 24) & 0xff, (bitLen >>> 16) & 0xff, (bitLen >>> 8) & 0xff, bitLen & 0xff); for (i = 0; i < m.length; i += 64) { for (t = 0; t < 16; t++) { w[t] = ((m[i + t * 4] << 24) | (m[i + t * 4 + 1] << 16) | (m[i + t * 4 + 2] << 8) | m[i + t * 4 + 3]) >>> 0; } for (t = 16; t < 64; t++) { var s0 = (this._rotr(w[t - 15], 7) ^ this._rotr(w[t - 15], 18) ^ (w[t - 15] >>> 3)) >>> 0; var s1 = (this._rotr(w[t - 2], 17) ^ this._rotr(w[t - 2], 19) ^ (w[t - 2] >>> 10)) >>> 0; w[t] = (w[t - 16] + s0 + w[t - 7] + s1) >>> 0; } var a = H[0], b = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7]; for (t = 0; t < 64; t++) { var S1 = (this._rotr(e, 6) ^ this._rotr(e, 11) ^ this._rotr(e, 25)) >>> 0; var ch = ((e & f) ^ (~e & g)) >>> 0; var t1 = (h + S1 + ch + this._K[t] + w[t]) >>> 0; var S0 = (this._rotr(a, 2) ^ this._rotr(a, 13) ^ this._rotr(a, 22)) >>> 0; var mj = ((a & b) ^ (a & c) ^ (b & c)) >>> 0; var t2 = (S0 + mj) >>> 0; h = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0; } H[0] = (H[0] + a) >>> 0; H[1] = (H[1] + b) >>> 0; H[2] = (H[2] + c) >>> 0; H[3] = (H[3] + d) >>> 0; H[4] = (H[4] + e) >>> 0; H[5] = (H[5] + f) >>> 0; H[6] = (H[6] + g) >>> 0; H[7] = (H[7] + h) >>> 0; } var out = []; for (i = 0; i < 8; i++) { out.push((H[i] >>> 24) & 0xff, (H[i] >>> 16) & 0xff, (H[i] >>> 8) & 0xff, H[i] & 0xff); } return out; }, _hex: function(bytes) { var s = '', i; for (i = 0; i < bytes.length; i++) { s += (bytes[i] < 16 ? '0' : '') + bytes[i].toString(16); } return s; }, type: 'PensarSignature' }; ``` ### 4. Add the Business Rule Create one Business Rule per table you file into: * **Name**: `Pensar — notify on state change` * **Table**: the table Pensar files into (`Incident [incident]` or `Security Incident [sn_si_incident]`) * **Advanced**: checked * **When to run** → **When**: `after`, with **Update** checked * **When to run** → **Advanced** → **Condition**: `current.state.changes()` * **Active**: checked ```javascript (function executeRule(current, previous /*null when async*/ ) { var endpoint = gs.getProperty('pensar.webhook.url'); var secret = gs.getProperty('pensar.webhook.secret'); if (!endpoint || !secret) { gs.error('Pensar: pensar.webhook.url / pensar.webhook.secret are not set; skipping.'); return; } // The signature covers this exact string, so sign it and send it unmodified. var body = JSON.stringify({ table: current.getTableName(), sys_id: current.getUniqueValue(), state: current.getValue('state'), updated_by: current.getValue('sys_updated_by') || gs.getUserName() }); var signature = new PensarSignature().hmacSha256Hex(secret, body); var request = new sn_ws.RESTMessageV2(); request.setEndpoint(endpoint); request.setHttpMethod('post'); request.setRequestHeader('Content-Type', 'application/json'); request.setRequestHeader('x-pensar-signature', 'sha256=' + signature); request.setRequestBody(body); try { var response = request.execute(); var status = response.getStatusCode(); if (status >= 300) { gs.error('Pensar webhook returned HTTP ' + status + ': ' + response.getBody()); } } catch (err) { gs.error('Pensar webhook call failed: ' + err); } })(current, previous); ``` > **Note** > > Setting **When** to `after` makes the user's save wait on the HTTP round trip. If you'd rather keep the network call off the transaction, change **When** to `async` — the script above is unchanged, and `previous` is simply unused. If you create the Business Rule inside a scoped application rather than Global, call the Script Include as `new global.PensarSignature()`. Pensar looks the incoming `state` value up in your table's own choice list, resolves its label, and runs it through the [status mapping](#status-mapping) above. A change that resolves to the status the finding already has is a no-op, so Pensar's own outbound transitions don't bounce back as inbound ones. ### Rotating the Secret If you regenerate the signing secret in Pensar, every delivery signed with the old one is rejected. Update `pensar.webhook.secret` in ServiceNow to the new value at the same time — there is nothing to change in the Script Include or the Business Rule. ## Disconnecting ServiceNow To remove the ServiceNow integration: 1. Navigate to **Settings** → **Integrations** → **Issue tracking** 2. Find the **ServiceNow** row in the **Issue sync** card and click **Manage** 3. Click **Disconnect** > **Warning** > > Disconnecting clears Pensar's stored credential and webhook secret and stops both directions of sync. It does **not** reach into your instance: existing records are left in place, and you should also deactivate the Business Rule, and revoke the OAuth token (**System OAuth** → **Manage Tokens**) or deactivate the service account, on the ServiceNow side. ## Troubleshooting ### Authentication Failed (401) **OAuth.** The access token expired and could not be refreshed. Common causes: 1. The **Refresh Token Lifespan** on the Application Registry record elapsed — reconnect from Pensar 2. The client secret was regenerated in ServiceNow — reconnect with the new secret 3. The token was revoked under **System OAuth** → **Manage Tokens** 4. The approving user was deactivated or lost the roles Pensar needs **Basic authentication.** The credential no longer works. Check that the password hasn't been changed or expired, that the account isn't locked out, and that MFA isn't being enforced for it. Reconnect with the current password. **Either.** Confirm the instance URL is still correct and that the instance isn't hibernating — a paused developer instance rejects everything until it's woken up. ### No Table in the Picker Pensar lists only the filing tables it can see on your instance. 1. If **Security Incident** is missing, the Security Incident Response plugin isn't installed — file into `incident` instead 2. If the picker is empty, the connected identity can't read `sys_db_object`. Grant it read access, or connect as an account that has it 3. Reconnect after changing roles — the picker reads the instance with the credential you connected with ### Issues Not Being Created 1. Confirm a workspace-level **Table** is set, or that the repository has a table override 2. Check that **Sync to ServiceNow** is enabled for the repository 3. Verify the finding's severity meets the repository's **Severity Filter** 4. Confirm the connected identity can insert on the target table (`itil` for `incident`) ### Status Changes Not Reflecting If a state change in ServiceNow doesn't move the Pensar finding, work down this list: 1. **Reflect ServiceNow status changes** is turned off in the **ServiceNow** section — inbound sync is disabled while it is 2. **Signature mismatch.** The webhook rejects any body whose `x-pensar-signature` doesn't verify. Check that `pensar.webhook.secret` matches the secret currently shown in Pensar (rotating one without the other is the usual cause), that neither value has stray whitespace, and that nothing in your Business Rule modifies `body` between signing and `setRequestBody` 3. **No matching state label.** If the new state's label matches none of the patterns in [Status Mapping](#status-mapping), Pensar logs it and deliberately leaves the finding alone. Rename the state, or add one whose label matches 4. **The Business Rule didn't fire.** Its condition is `current.state.changes()`, so an update that doesn't change `state` sends nothing. Confirm the rule is **Active**, is on the right table, and check **System Logs** → **Outbound HTTP Requests** for the delivery 5. **The record wasn't filed by Pensar.** Status sync only applies to records Pensar created and linked to a finding 6. **Outbound HTTP is blocked.** If your instance restricts egress, the REST message never leaves — the `gs.error` lines in the Business Rule will show it in **System Logs** → **All** If the reverse direction is the problem — a finding closed in Pensar leaving the record open — see the note in [Status Mapping](#status-mapping) about tables whose state labels match nothing. ## Need Help? If you encounter issues setting up your ServiceNow integration, please contact our support team at [team@pensar.dev](mailto:team@pensar.dev). > Continuous, autonomous, on-demand penetration testing.