SafeToOpenBrowser Security Docs

SafeToOpen Browser Security

ServiceNow Integration Guide

Incidents into ITSM or Security Incident Response, with close-the-loop

Guide 2 of 4 · September 2026

This guide creates a ServiceNow incident for every Browser Security event SafeToOpen sends, deduplicates retries, sets priority from severity, and reports the resolution back to SafeToOpen when the incident is closed. It uses only standard ServiceNow features: a Scripted REST API, system properties, a Business Rule and, optionally, a Scheduled Script.

Two integration styles are covered. Use the webhook (push) style unless your instance cannot accept inbound requests from the internet, in which case use the polling style in section 6.

What you will need#

Note The scripts below are written for the global scope and were prepared against the documented ServiceNow APIs. Validate them in a sub-production instance first; API availability can differ between releases and scopes.

1. Store the secrets#

Create two private system properties (System Properties → All Properties → New):

NameTypeValuePrivate
sto.webhook_secretstringThe webhook secret from the SafeToOpen console (section 2)true
sto.api_tokenstringA SafeToOpen API token with write-back enabled (section 5)true

Marking a property private keeps it out of update sets and instance clones.

2. Create the receiver (Scripted REST API)#

  1. System Web Services → Scripted REST APIs → New. Name “SafeToOpen Webhook”, API ID safetoopen.
  2. Add a Resource: HTTP method POST, relative path /events/{slug}. The slug is a random path segment that acts as a second secret; generate 32 random characters and note them.
  3. Untick “Requires authentication” and “Requires ACL authorization”. SafeToOpen sends no username or password; the request is authenticated by the HMAC signature and the secret path.
  4. Paste the script below into the resource and save.
  5. The full endpoint URL is https://<instance>.service-now.com/api/<scope prefix>/safetoopen/events/<slug>. Copy it; you will paste it into the SafeToOpen console in the next section.
(function process(request, response) {
    // 1. Read the exact body bytes — never re-serialise before verifying.
    var raw = request.body.dataString || '';

    // 2. Parse X-STO-Signature: t=<unix>,v1=<hex>
    var sig = request.getHeader('X-STO-Signature') || '';
    var t = '', v1 = '';
    sig.split(',').forEach(function (kv) {
        var i = kv.indexOf('=');
        if (i < 0) return;
        var k = kv.substring(0, i).trim(), v = kv.substring(i + 1).trim();
        if (k === 't') t = v;
        if (k === 'v1') v1 = v;
    });
    var now = Math.floor(new Date().getTime() / 1000);
    if (!/^\d+$/.test(t) || Math.abs(now - parseInt(t, 10)) > 300) {
        response.setStatus(401); return;                     // stale or missing timestamp
    }

    // 3. Recompute HMAC-SHA256(secret, t + "." + body) and compare as hex.
    var secret = gs.getProperty('sto.webhook_secret');
    var macB64 = new GlideCertificateEncryption()
        .generateMac(GlideStringUtil.base64Encode(secret), 'HmacSHA256', t + '.' + raw);
    var bytes = GlideStringUtil.base64DecodeAsBytes(macB64), hex = '';
    for (var b = 0; b < bytes.length; b++) {
        var h = (bytes[b] & 0xff).toString(16);
        hex += (h.length < 2 ? '0' : '') + h;
    }
    if (hex !== String(v1).toLowerCase()) { response.setStatus(401); return; }

    // 4. Parse the event. The console's Test button sends {"test": true}.
    var data = JSON.parse(raw);
    if (data.test || !data.event || !data.event.id) { response.setStatus(200); return; }
    var ev = data.event;

    // 5. Deduplicate: retries carry the same event id.
    var corr = 'sto-' + ev.id;
    var gr = new GlideRecord('incident');
    gr.addQuery('correlation_id', corr);
    gr.query();
    if (gr.next()) { response.setStatus(200); return; }

    // 6. Create the incident.
    var sev = String(ev.severity || 'medium');
    var map = { critical: [1, 1], high: [2, 2], medium: [2, 3], low: [3, 3], weak: [3, 3], info: [3, 3] };
    var iu = map[sev] || [2, 3];
    var safeUrl = String(ev.url || '').replace('://', '[://]');   // defang — never a live link

    gr.initialize();
    gr.correlation_id     = corr;
    gr.correlation_display = 'SafeToOpen Browser Security';
    gr.contact_type       = 'integration';
    gr.category           = 'security';
    gr.impact             = iu[0];
    gr.urgency            = iu[1];
    gr.short_description  = '[SafeToOpen] ' + sev.toUpperCase() + ' ' + ev.event_kind + ' - ' + (ev.url_host || '');
    gr.description =
        'Browser Security event #' + ev.id + '\n' +
        'Occurred (UTC): ' + ev.occurred_at + '\n' +
        'Workspace: '      + (ev.workspace || 'default') + '\n' +
        'Member: '         + (ev.member_email || '') + '\n' +
        'Event kind: '     + ev.event_kind + '   Severity: ' + sev + '\n' +
        'URL (defanged): ' + safeUrl + '\n' +
        'Host: '           + (ev.url_host || '') + '   Brand match: ' + (ev.brand_match || '-') + '\n' +
        'Trust score: '    + (ev.trust_score === null ? '-' : ev.trust_score) + '\n' +
        'Reason: '         + (ev.finding_reason || '') + '\n' +
        'Page title: '     + (ev.page_title || '') + '   Referrer: ' + (ev.referrer_host || '-') + '\n' +
        'Console: https://plus.safetoopen.com/business-console#/incidents/' + ev.id;

    // Optional: caller = the affected member, when they exist in sys_user.
    if (ev.member_email) {
        var u = new GlideRecord('sys_user');
        u.addQuery('email', ev.member_email);
        u.query();
        if (u.next()) gr.caller_id = u.sys_id;
    }
    // Optional (MSP): route by workspace → assignment group named after it.
    if (ev.workspace) {
        var g = new GlideRecord('sys_user_group');
        g.addQuery('name', 'SafeToOpen - ' + ev.workspace);
        g.query();
        if (g.next()) gr.assignment_group = g.sys_id;
    }
    gr.insert();
    response.setStatus(200);
})(request, response);

Scripted REST resource script. For Security Incident Response, replace incident with sn_si_incident and category with the SIR fields you use.

Note The comparison of the two hex strings is a plain string compare because ServiceNow offers no constant-time helper. Together with the secret path segment and the 5-minute timestamp window this is adequate for an inbound webhook; put a relay in front (Reference guide, section 3) if your security policy demands constant-time comparison.

3. Register the webhook in SafeToOpen#

  1. Business Console → Integrations → Add webhook.
  2. Name: “ServiceNow intake”. Endpoint URL: the full Scripted REST URL including the slug.
  3. Workspaces: all, or the customer workspaces this instance handles. Send: “High & critical only” is a good starting point for an on-call queue.
  4. Click Add webhook and copy the secret into the sto.webhook_secret property.
  5. Click Test. Expect “test ok: HTTP 200”. If you see 401, the secret or the body handling differs; check section 8.

4. What the incident looks like#

Incident fieldValue
Short description[SafeToOpen] CRITICAL malicious_keyed - login-micros0ft.example
DescriptionEvent id, time, workspace, member, defanged URL, brand match, trust score, reason, console link
Impact / UrgencyFrom severity: critical 1/1, high 2/2, medium 2/3, otherwise 3/3
Correlation IDsto-2640 (used for dedupe and close-the-loop)
CallerThe member, if their email exists in sys_user
Assignment groupOptional: “SafeToOpen - <workspace>” if such a group exists

5. Close the loop: report the resolution back#

When the incident is resolved or closed, a Business Rule calls SafeToOpen’s review endpoint so the console shows the event as reviewed and the Activity log records the resolution as coming from ServiceNow.

  1. In the SafeToOpen console create an API token named “ServiceNow close-the-loop” with Allow write-back ticked. Scope it to the same workspaces as the webhook. Store it in sto.api_token.
  2. System Definition → Business Rules → New. Table incident, When: after, Update ticked. Condition: State changes to Resolved or Closed, and Correlation ID starts with sto-.
  3. Tick Advanced and paste the script.
(function executeRule(current, previous) {
    var corr = String(current.correlation_id);
    if (corr.indexOf('sto-') !== 0) return;
    var eventId = parseInt(corr.substring(4), 10);
    if (!eventId) return;

    // Map your close code to SafeToOpen's review status.
    //   ok    = reviewed, no real issue (false positive, user error)
    //   issue = confirmed security problem
    var closeCode = String(current.close_code || '').toLowerCase();
    var status = (closeCode.indexOf('false positive') >= 0 || closeCode.indexOf('not reproducible') >= 0 ||
                  closeCode.indexOf('no issue') >= 0) ? 'ok' : 'issue';

    try {
        var rm = new sn_ws.RESTMessageV2();
        rm.setEndpoint('https://plus.safetoopen.com/api/integrations/review');
        rm.setHttpMethod('POST');
        rm.setRequestHeader('Authorization', 'Bearer ' + gs.getProperty('sto.api_token'));
        rm.setRequestHeader('Content-Type', 'application/json');
        rm.setRequestBody(JSON.stringify({ event_id: eventId, status: status }));
        var resp = rm.execute();
        if (resp.getStatusCode() !== 200) {
            gs.warn('SafeToOpen review failed for ' + corr + ': HTTP ' + resp.getStatusCode() + ' ' + resp.getBody());
        }
    } catch (e) {
        gs.error('SafeToOpen review error for ' + corr + ': ' + e);
    }
})(current, previous);

Business Rule script. Add a second rule with status "open" if you want reopening an incident to reopen the event.

Note The review endpoint answers 403 read_only_token if the token was created without write-back, and 404 if the event is outside the token’s workspace scope. Both are visible in the SafeToOpen Activity log and in the ServiceNow system log.

Optional: block the URL when the incident is confirmed#

A playbook or a second Business Rule can add the hostile URL to the organisation’s block list, which reaches every protected browser within minutes. This needs an unscoped write-back token and is limited to 60 actions per hour.

var rm = new sn_ws.RESTMessageV2();
rm.setEndpoint('https://plus.safetoopen.com/api/integrations/action');
rm.setHttpMethod('POST');
rm.setRequestHeader('Authorization', 'Bearer ' + gs.getProperty('sto.api_token'));
rm.setRequestHeader('Content-Type', 'application/json');
rm.setRequestBody(JSON.stringify({ action: 'block_url', url: hostileUrl, category: 'phishing' }));
var resp = rm.execute();

6. Alternative: polling from ServiceNow (no inbound webhook)#

If your instance may not expose a public endpoint, a Scheduled Script Execution can pull new events every few minutes with a read-only token. The cursor is kept in a system property so nothing is missed or duplicated.

  1. Create a property sto.since_id with value 0 and, if not done already, sto.api_token holding a read-only token.
  2. System Definition → Scheduled Jobs → New → Automatically run a script. Repeat every 5 minutes.
var since = parseInt(gs.getProperty('sto.since_id', '0'), 10) || 0;
var token = gs.getProperty('sto.api_token');
for (var page = 0; page < 10; page++) {                 // at most 10 pages per run
    var rm = new sn_ws.RESTMessageV2();
    rm.setEndpoint('https://plus.safetoopen.com/api/integrations/events?since_id=' + since +
                   '&limit=200&severity=high');           // drop &severity= to pull everything
    rm.setHttpMethod('GET');
    rm.setRequestHeader('Authorization', 'Bearer ' + token);
    var resp = rm.execute();
    if (resp.getStatusCode() !== 200) { gs.warn('SafeToOpen pull: HTTP ' + resp.getStatusCode()); break; }
    var data = JSON.parse(resp.getBody());
    data.events.forEach(function (ev) { createIncident(ev); });   // same mapping as section 2
    since = data.next_since_id;
    gs.setProperty('sto.since_id', String(since));
    if (!data.has_more) break;
}

createIncident is the dedupe-and-insert part of the receiver script (steps 5 and 6). The pull payload has the same fields plus review_status, received_at and member_redacted.

7. Test plan#

  1. Test button in SafeToOpen → expect HTTP 200 and no incident created (test payload is ignored).
  2. From a test browser with the extension, visit a page the workspace policy flags (for example a known test phishing URL in your lab) → an incident appears within a minute with the expected priority.
  3. Trigger the same event again or use Redeliver in the console → no duplicate incident.
  4. Resolve the incident with a close code → the SafeToOpen Incidents page shows it as Reviewed, and the Activity log shows “event reviewed … via api:ServiceNow close-the-loop”.
  5. Stop the Scripted REST API temporarily and send a test → the webhook status shows the failure and the delivery retries automatically once the resource is restored.

8. Troubleshooting#

SymptomFix
Test returns 401Most often the property value has trailing whitespace, or the script verifies a re-parsed body. Use request.body.dataString exactly as shown. Confirm the clock of the instance is within 5 minutes of UTC.
Test returns 403 or a login page“Requires authentication” is still ticked on the resource, or an ACL blocks the path. SafeToOpen cannot send credentials.
Incidents created twiceThe dedupe query is missing or the correlation id format differs between the receiver and the poller.
Review call returns 404The write-back token is scoped to workspaces that do not include this event, or the event has expired (365 days).
Webhook disabled in the console20 straight failures. Fix the resource, click Enable, then Redeliver any deliveries you need.