SafeToOpen Browser Security
ServiceNow Integration Guide
Incidents into ITSM or Security Incident Response, with close-the-loop
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#
- ServiceNow admin rights, or a developer with rights to create Scripted REST APIs, Business Rules and system properties.
- A target table:
incident(ITSM) orsn_si_incident(Security Incident Response). Examples useincident; the SIR variant only changes the table name and close-code fields. - From the SafeToOpen console: a webhook secret (section 2) and, for close-the-loop, an API token with write-back enabled (section 5).
1. Store the secrets#
Create two private system properties (System Properties → All Properties → New):
| Name | Type | Value | Private |
|---|---|---|---|
sto.webhook_secret | string | The webhook secret from the SafeToOpen console (section 2) | true |
sto.api_token | string | A 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)#
- System Web Services → Scripted REST APIs → New. Name “SafeToOpen Webhook”, API ID
safetoopen. - 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. - 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.
- Paste the script below into the resource and save.
- 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.
3. Register the webhook in SafeToOpen#
- Business Console → Integrations → Add webhook.
- Name: “ServiceNow intake”. Endpoint URL: the full Scripted REST URL including the slug.
- Workspaces: all, or the customer workspaces this instance handles. Send: “High & critical only” is a good starting point for an on-call queue.
- Click Add webhook and copy the secret into the
sto.webhook_secretproperty. - 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 field | Value |
|---|---|
| Short description | [SafeToOpen] CRITICAL malicious_keyed - login-micros0ft.example |
| Description | Event id, time, workspace, member, defanged URL, brand match, trust score, reason, console link |
| Impact / Urgency | From severity: critical 1/1, high 2/2, medium 2/3, otherwise 3/3 |
| Correlation ID | sto-2640 (used for dedupe and close-the-loop) |
| Caller | The member, if their email exists in sys_user |
| Assignment group | Optional: “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.
- 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. - System Definition → Business Rules → New. Table
incident, When: after, Update ticked. Condition: State changes to Resolved or Closed, and Correlation ID starts withsto-. - 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.
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.
- Create a property
sto.since_idwith value0and, if not done already,sto.api_tokenholding a read-only token. - 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#
- Test button in SafeToOpen → expect HTTP 200 and no incident created (test payload is ignored).
- 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.
- Trigger the same event again or use Redeliver in the console → no duplicate incident.
- 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”.
- 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#
| Symptom | Fix |
|---|---|
| Test returns 401 | Most 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 twice | The dedupe query is missing or the correlation id format differs between the receiver and the poller. |
| Review call returns 404 | The write-back token is scoped to workspaces that do not include this event, or the event has expired (365 days). |
| Webhook disabled in the console | 20 straight failures. Fix the resource, click Enable, then Redeliver any deliveries you need. |