SafeToOpen Browser Security
Jira Integration Guide
Jira Software, Jira Service Management and Jira Data Center, with close-the-loop
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:
- Method A – no code — Jira Automation’s Incoming webhook trigger, configured entirely inside Jira. Quickest to set up; the request signature cannot be verified by Jira itself, so security rests on the unguessable webhook URL.
- Method B – relay — A small relay function (Node.js) that verifies the HMAC signature and creates the issue through the Jira REST API. Recommended where your policy requires signature verification or where you need routing logic (MSPs).
Close-the-loop (section 4) is the same for both methods and uses Jira Automation’s Send web request action.
What you will need#
- Jira project admin rights, and the ability to create automation rules and a custom field.
- A project for security incidents (examples use key
SEC). - For Method B: somewhere to run a small HTTPS function (Azure Functions, AWS Lambda, Cloudflare Workers, or any Node host), and a Jira API token for a service account.
- From SafeToOpen: a webhook secret (Method B) and an API token with write-back enabled (section 4).
1. Prepare Jira#
- Create a custom field of type Number named
SafeToOpen Event IDand add it to the screens of theSECproject. It carries the event id used for deduplication and close-the-loop. - Optionally create a Short text field
SafeToOpen Workspace, or simply rely on labels. - 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#
- Project settings → Automation → Create rule. Trigger: Incoming webhook. Choose “No issues from the webhook”. Copy the Webhook URL shown; it contains a secret token.
- Add condition: Advanced compare condition, first value
{{webhookData.type}}, equals,browser_security.flagged_event. This ignores the console’s test message. - Add action: Lookup issues, JQL:
project = SEC AND "SafeToOpen Event ID[Number]" = {{webhookData.event.id}}. - Add condition: Advanced compare condition,
{{lookupIssues.size}}equals0. This prevents duplicates when a delivery is retried. - 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. - Turn the rule on.
| Create issue field | Value |
|---|---|
| Project / Issue type | SEC / Incident (or Task) |
| Summary | [SafeToOpen] {{webhookData.event.severity.toUpperCase}} {{webhookData.event.event_kind}} - {{webhookData.event.url_host}} |
| Description | See the template below |
| Priority | Per branch: Highest, High, Medium, Low |
| Labels | safetoopen 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#
- Business Console → Integrations → Add webhook. Name “Jira – SEC”. Endpoint URL: the Jira automation webhook URL.
- Workspaces: the workspaces this project should receive. Send: all severities, or high and critical only.
- Click Add webhook. The secret is not used by Method A; keep it anyway in case you move to Method B.
- Click Test. Jira answers 2xx; the rule’s audit log shows the run stopped at the type condition, and no issue is created.
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:
| Variable | Value |
|---|---|
STO_WEBHOOK_SECRET | The secret shown when the webhook was created in SafeToOpen |
JIRA_BASE | https://<your-site>.atlassian.net |
JIRA_USER | Email address of a Jira service account |
JIRA_API_TOKEN | API token created for that account at id.atlassian.com |
JIRA_PROJECT | Project key, for example SEC |
JIRA_EVENT_FIELD | The 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.
- Deploy the relay and note its public HTTPS URL.
- Business Console → Integrations → Add webhook with that URL, copy the secret into
STO_WEBHOOK_SECRET, restart the relay. - 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.
- 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.
- Project settings → Automation → Create rule. Trigger: Issue transitioned, To status: Done.
- Condition: Issue fields condition, Labels contains
safetoopen(or “SafeToOpen Event ID” is not empty). - Add an If / else block: If Resolution is one of
Won’t Do,Cannot Reproduce,Duplicate→ Send web request with statusok; Else → Send web request with statusissue. Settings for the action are below. - Optionally add a third rule: Issue transitioned to “To Do” or “Reopened” → Send web request with status
open.
| Send web request setting | Value |
|---|---|
| Web request URL | https://plus.safetoopen.com/api/integrations/review |
| HTTP method | POST |
| Headers | Authorization: Bearer sto_live_… (tick Hidden) · Content-Type: application/json |
| Web request body | Custom data |
| Custom data | {"event_id": {{issue.SafeToOpen Event ID}}, "status": "issue"} (or "ok" / "open" in the other branches) |
| Wait for response | Ticked, so the audit log shows failures |
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#
- Test button in SafeToOpen → 2xx, no issue created.
- Trigger a flagged event from a test browser → an issue appears with the expected priority, labels and event id.
- Redeliver the same delivery from the console → no duplicate issue.
- Transition the issue to Done with resolution Done → the SafeToOpen Incidents page shows Confirmed issue; with Won’t Do → Reviewed, no issue.
- Check the automation audit log for the web request response (200).
7. Troubleshooting#
| Symptom | Fix |
|---|---|
| Rule never runs | The condition on {{webhookData.type}} is misspelt, or the rule is off. Check the automation audit log for the incoming request. |
| Duplicate issues | The Lookup issues JQL does not match: confirm the custom field name and that the field is of type Number. |
| Priority always Medium | The If / else branches compare {{webhookData.event.severity}} case-sensitively; values are lower-case. |
| Send web request returns 401 | The Authorization header is missing the Bearer prefix, or the token was rotated. |
| Relay: SafeToOpen test shows 401 | The relay is verifying a re-serialised body or the secret has whitespace. Verify the raw bytes exactly as received. |
| Relay: Jira 400 on create | Field id or issue type name is wrong for this project; check /rest/api/3/issue/createmeta. |