SafeToOpenBrowser Security Docs

SafeToOpen Browser Security

Jira Integration Guide

Jira Software, Jira Service Management and Jira Data Center, with close-the-loop

Guide 3 of 4 · September 2026

This guide turns every Browser Security incident into a Jira issue and reports the resolution back to SafeToOpen when the issue is done. It offers two receiving methods:

Close-the-loop (section 4) is the same for both methods and uses Jira Automation’s Send web request action.

What you will need#

1. Prepare Jira#

  1. Create a custom field of type Number named SafeToOpen Event ID and add it to the screens of the SEC project. It carries the event id used for deduplication and close-the-loop.
  2. Optionally create a Short text field SafeToOpen Workspace, or simply rely on labels.
  3. Decide the priority mapping: critical → Highest, high → High, medium → Medium, low/weak/info → Low.

2. Method A: Jira Automation incoming webhook#

2.1 Create the rule#

  1. Project settings → Automation → Create rule. Trigger: Incoming webhook. Choose “No issues from the webhook”. Copy the Webhook URL shown; it contains a secret token.
  2. Add condition: Advanced compare condition, first value {{webhookData.type}}, equals, browser_security.flagged_event. This ignores the console’s test message.
  3. Add action: Lookup issues, JQL: project = SEC AND "SafeToOpen Event ID[Number]" = {{webhookData.event.id}}.
  4. Add condition: Advanced compare condition, {{lookupIssues.size}} equals 0. This prevents duplicates when a delivery is retried.
  5. Add an If / else block on {{webhookData.event.severity}}: one branch per severity, each with a Create issue action that only differs in Priority. Fields for Create issue are in the table below.
  6. Turn the rule on.
Create issue fieldValue
Project / Issue typeSEC / Incident (or Task)
Summary[SafeToOpen] {{webhookData.event.severity.toUpperCase}} {{webhookData.event.event_kind}} - {{webhookData.event.url_host}}
DescriptionSee the template below
PriorityPer branch: Highest, High, Medium, Low
Labelssafetoopen and {{webhookData.event.workspace}} (workspace names contain no spaces)
SafeToOpen Event ID{{webhookData.event.id}}

Description template (Jira wiki markup; the URL is defanged so it never becomes a live link):

*SafeToOpen Browser Security event* #{{webhookData.event.id}}
Occurred (UTC): {{webhookData.event.occurred_at}}
Workspace: {{webhookData.event.workspace}}
Member: {{webhookData.event.member_email}}
Severity: {{webhookData.event.severity}}   Event kind: {{webhookData.event.event_kind}}
URL (defanged): {{webhookData.event.url.replace("://", "[://]")}}
Host: {{webhookData.event.url_host}}   Brand match: {{webhookData.event.brand_match}}
Trust score: {{webhookData.event.trust_score}}
Reason: {{webhookData.event.finding_reason}}
Page title: {{webhookData.event.page_title}}   Referrer: {{webhookData.event.referrer_host}}
Console: https://plus.safetoopen.com/business-console#/incidents/{{webhookData.event.id}}

2.2 Register the webhook in SafeToOpen#

  1. Business Console → Integrations → Add webhook. Name “Jira – SEC”. Endpoint URL: the Jira automation webhook URL.
  2. Workspaces: the workspaces this project should receive. Send: all severities, or high and critical only.
  3. Click Add webhook. The secret is not used by Method A; keep it anyway in case you move to Method B.
  4. Click Test. Jira answers 2xx; the rule’s audit log shows the run stopped at the type condition, and no issue is created.
Important Jira Automation cannot compute HMAC signatures, so the X-STO-Signature header is not checked in Method A. Anyone who learns the webhook URL could create issues. Keep the URL out of tickets and chat, regenerate it from the rule if it is exposed, and prefer Method B for higher-assurance environments.

MSPs with a project per customer#

Create one SafeToOpen webhook per customer, each filtered to that customer’s workspace and pointing at a rule in the matching project. Alternatively, one rule can branch on {{webhookData.event.workspace}} and create the issue in the right project.

3. Method B: signature-verifying relay#

The relay receives the SafeToOpen webhook, verifies the signature and timestamp, deduplicates, and creates the issue through the Jira Cloud REST API v3. It runs on Node.js 18 or later with no dependencies. Configure it through environment variables:

VariableValue
STO_WEBHOOK_SECRETThe secret shown when the webhook was created in SafeToOpen
JIRA_BASEhttps://<your-site>.atlassian.net
JIRA_USEREmail address of a Jira service account
JIRA_API_TOKENAPI token created for that account at id.atlassian.com
JIRA_PROJECTProject key, for example SEC
JIRA_EVENT_FIELDThe custom field id of “SafeToOpen Event ID”, for example customfield_10042
// sto-jira-relay.js — Node 18+. Run behind HTTPS (cloud function or reverse proxy).
const http = require('http');
const crypto = require('crypto');
const env = process.env;

function verify(headers, raw) {
  const sig = Object.fromEntries((headers['x-sto-signature'] || '').split(',').map(kv => kv.split('=')));
  if (!/^\d+$/.test(sig.t || '') || Math.abs(Date.now() / 1000 - Number(sig.t)) > 300) return false;
  const expected = crypto.createHmac('sha256', env.STO_WEBHOOK_SECRET).update(sig.t + '.').update(raw).digest('hex');
  const a = Buffer.from(expected), b = Buffer.from(String(sig.v1 || ''));
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const auth = 'Basic ' + Buffer.from(env.JIRA_USER + ':' + env.JIRA_API_TOKEN).toString('base64');
async function jira(path, method, body) {
  const res = await fetch(env.JIRA_BASE + path, { method, headers: { Authorization: auth, 'Content-Type': 'application/json', Accept: 'application/json' }, body: body ? JSON.stringify(body) : undefined });
  if (!res.ok) throw new Error('Jira ' + res.status + ' ' + await res.text());
  return res.json();
}
const PRIORITY = { critical: 'Highest', high: 'High', medium: 'Medium', low: 'Low', weak: 'Low', info: 'Low' };
const text = s => ({ type: 'paragraph', content: [{ type: 'text', text: String(s) }] });

async function createIssue(ev) {
  const jql = 'project = ' + env.JIRA_PROJECT + ' AND cf[' + env.JIRA_EVENT_FIELD.replace('customfield_', '') + '] = ' + ev.id;
  const found = await jira('/rest/api/3/search?maxResults=1&jql=' + encodeURIComponent(jql), 'GET');
  if (found.total > 0) return;                                        // retried delivery — already exists
  const safeUrl = String(ev.url || '').replace('://', '[://]');
  const fields = {
    project: { key: env.JIRA_PROJECT },
    issuetype: { name: 'Task' },
    summary: '[SafeToOpen] ' + String(ev.severity).toUpperCase() + ' ' + ev.event_kind + ' - ' + (ev.url_host || ''),
    priority: { name: PRIORITY[ev.severity] || 'Medium' },
    labels: ['safetoopen'].concat(ev.workspace ? [ev.workspace] : []),
    description: { type: 'doc', version: 1, content: [
      text('SafeToOpen Browser Security event #' + ev.id + ' at ' + ev.occurred_at + ' UTC'),
      text('Workspace: ' + (ev.workspace || 'default') + '   Member: ' + (ev.member_email || '')),
      text('URL (defanged): ' + safeUrl + '   Host: ' + (ev.url_host || '') + '   Brand: ' + (ev.brand_match || '-')),
      text('Trust score: ' + (ev.trust_score ?? '-') + '   Reason: ' + (ev.finding_reason || '')),
      text('Console: https://plus.safetoopen.com/business-console#/incidents/' + ev.id),
    ] },
  };
  fields[env.JIRA_EVENT_FIELD] = ev.id;
  await jira('/rest/api/3/issue', 'POST', { fields });
}

http.createServer((req, res) => {
  const chunks = [];
  req.on('data', c => chunks.push(c));
  req.on('end', async () => {
    const raw = Buffer.concat(chunks);
    if (req.method !== 'POST' || !verify(req.headers, raw)) { res.writeHead(401); return res.end(); }
    try {
      const data = JSON.parse(raw.toString('utf8'));
      if (!data.test && data.event && data.event.id) await createIssue(data.event);
      res.writeHead(200); res.end();
    } catch (e) { console.error(e); res.writeHead(500); res.end(); }   // 5xx → SafeToOpen retries
  });
}).listen(process.env.PORT || 8080);

Reference relay. Adapt the handler signature for your function platform; the verify() and createIssue() parts are platform-independent.

  1. Deploy the relay and note its public HTTPS URL.
  2. Business Console → Integrations → Add webhook with that URL, copy the secret into STO_WEBHOOK_SECRET, restart the relay.
  3. Click Test: expect “test ok: HTTP 200” and no issue in Jira.

For Jira Data Center use /rest/api/2/issue with a plain-text description and a personal access token (Authorization: Bearer).

4. Close the loop: report the resolution back#

A second automation rule calls SafeToOpen when an issue transitions to Done. Resolutions that mean “nothing was wrong” are reported as ok; everything else as issue. Works for both methods.

  1. In SafeToOpen create an API token named “Jira close-the-loop” with Allow write-back ticked, scoped to the same workspaces as the webhook. Copy the token.
  2. Project settings → Automation → Create rule. Trigger: Issue transitioned, To status: Done.
  3. Condition: Issue fields condition, Labels contains safetoopen (or “SafeToOpen Event ID” is not empty).
  4. Add an If / else block: If Resolution is one of Won’t Do, Cannot Reproduce, Duplicate → Send web request with status ok; Else → Send web request with status issue. Settings for the action are below.
  5. Optionally add a third rule: Issue transitioned to “To Do” or “Reopened” → Send web request with status open.
Send web request settingValue
Web request URLhttps://plus.safetoopen.com/api/integrations/review
HTTP methodPOST
HeadersAuthorization: Bearer sto_live_… (tick Hidden) · Content-Type: application/json
Web request bodyCustom data
Custom data{"event_id": {{issue.SafeToOpen Event ID}}, "status": "issue"} (or "ok" / "open" in the other branches)
Wait for responseTicked, so the audit log shows failures
Note A Number field may render as 2640.0 in the body; SafeToOpen accepts that. The endpoint returns 403 read_only_token if write-back was not enabled and 404 if the event is outside the token’s workspace scope. Each successful call is recorded in the SafeToOpen Activity log as “event reviewed … via api:Jira close-the-loop”.

Optional: block the URL when the issue is confirmed#

Add a Send web request to the issue branch: POST https://plus.safetoopen.com/api/integrations/action with body {"action": "block_url", "url": "{{issue.SafeToOpen URL}}", "category": "phishing"}. This needs an unscoped write-back token and a text field holding the original (non-defanged) URL; the relay can store it in such a field.

5. Jira Service Management#

Both methods apply unchanged. Use the JSM project key and a request type instead of an issue type (in Method B set issuetype to the request type’s underlying issue type and optionally the customfield for request type). Customer-facing portals should not show the raw URL; keep the defanged form.

6. Test plan#

  1. Test button in SafeToOpen → 2xx, no issue created.
  2. Trigger a flagged event from a test browser → an issue appears with the expected priority, labels and event id.
  3. Redeliver the same delivery from the console → no duplicate issue.
  4. Transition the issue to Done with resolution Done → the SafeToOpen Incidents page shows Confirmed issue; with Won’t Do → Reviewed, no issue.
  5. Check the automation audit log for the web request response (200).

7. Troubleshooting#

SymptomFix
Rule never runsThe condition on {{webhookData.type}} is misspelt, or the rule is off. Check the automation audit log for the incoming request.
Duplicate issuesThe Lookup issues JQL does not match: confirm the custom field name and that the field is of type Number.
Priority always MediumThe If / else branches compare {{webhookData.event.severity}} case-sensitively; values are lower-case.
Send web request returns 401The Authorization header is missing the Bearer prefix, or the token was rotated.
Relay: SafeToOpen test shows 401The relay is verifying a re-serialised body or the secret has whitespace. Verify the raw bytes exactly as received.
Relay: Jira 400 on createField id or issue type name is wrong for this project; check /rest/api/3/issue/createmeta.