Ticket Sync (Jira / GitHub Issues)¶
Availability: marketed as Pro and Enterprise · Tenant-scoped
See the Tier & Scope Matrix for why "marketed" and "enforced" differ here.
Ticket Sync automatically creates an external ticket (Jira issue or GitHub issue) when a new finding appears, keeps that ticket's content and priority in sync as the finding changes, closes it when the finding is resolved, and escalates it if the finding breaches its SLA. It runs as a per-schedule notification channel - there is currently no dedicated Settings UI page for it; it's configured through a schedule's ticket_sync config block plus service-level credentials.
How a finding becomes a ticket¶
Ticket Sync listens for three event types on the same event stream every other notification channel (Slack, email, webhook) consumes:
| Event | Emitted when | Ticket Sync action |
|---|---|---|
new_finding | A scheduled diff run finds a finding not seen in the prior run for that org | Creates a ticket (or, if one already exists for that finding+provider, updates it instead of creating a duplicate) |
resolved_finding | A diff run finds a fingerprint that was open in the baseline and is no longer present | Closes/transitions the linked ticket |
escalated_finding | Either a diff-driven severity increase, or the hourly SLA-breach check (see below) | Updates the ticket's priority/labels to reflect the new severity |
Before doing anything, the plugin checks:
ticket_sync.enabledis true in the schedule's config. This flag alone turns ticket sync on for a schedule. The deployment'sTICKET_SYNC_ENABLED(chartnotification.ticketSync.enabled) neither turns it on nor off (see the environment variables).- The configured provider has its required target set -
project_keyfor Jira,repofor GitHub Issues. If the target is missing, the event is skipped before any HTTP call is made. - If the schedule brings no credential of its own, its target is on the deployment's allowlist (see the environment variables). Otherwise the event fails before any HTTP call.
- The finding's severity meets or exceeds
severity_threshold(defaultmedium).
A failure anywhere in this path never blocks delivery to other notification channels (Slack/email) for the same event. Each outcome is recorded in the run's notification delivery log (the run Overview, and Admin > Operations > Deliveries):
| Outcome | Recorded as |
|---|---|
The schedule does not enable ticket_sync | Nothing - no row |
Unknown provider; missing project_key / repo; finding below the threshold; event carries no finding id; nothing to close or escalate; the ticket is already closed; persistence unavailable | skipped, with the reason |
| The provider did not create the ticket, or refused an escalation | failed |
A target or credential the service refuses: a target the deployment's credential may not be sent to (when the schedule brings none of its own), a malformed repo, or a schedule credential that cannot be resolved | failed (not retried) |
The providers' update and close calls still log an HTTP failure and return, so those two are recorded as sent (platform#475).
Provider configuration¶
Jira¶
| Field | Source | Notes |
|---|---|---|
jira_domain | schedule config or JIRA_DOMAIN env var | e.g. mycompany → mycompany.atlassian.net |
jira_email | schedule config or JIRA_EMAIL env var | Account used for Basic auth |
jira_api_token | schedule config or JIRA_API_TOKEN env var | Jira Cloud API token |
project_key | schedule config (ticket_sync.project_key) | e.g. SEC - required, no fallback |
jira_close_transition_id | schedule config or service-level default | Workflow transition ID used to close an issue. No default exists - Jira Cloud transition IDs are per-project/per-workflow. If unset, closing a resolved finding's ticket fails loudly (logged error, no API call) rather than guessing an ID that might be wrong for your workflow. |
Issues are created with summary prefixed [SEVERITY], an ADF-formatted description (finding description + finding ID + resource ID), issue type Bug, priority mapped from severity (critical→Highest, high→High, medium→Medium, low→Low, info→Lowest), and labels vsutra, security.
GitHub Issues¶
| Field | Source | Notes |
|---|---|---|
github_token | schedule config or GITHUB_ISSUES_TOKEN env var | Needs issue read/write access on the target repo |
repo | schedule config (ticket_sync.repo) | owner/repo - required, no fallback |
Issues are created with title [SEVERITY] <finding title>, a Markdown body (finding ID, severity, resource, check ID, scanner, description), and labels security, vsutra. Severity escalation is expressed as a single priority:* label (GitHub Issues has no native priority field) - any existing priority:* label is removed before the new one is added, so an issue never carries two contradictory priority labels.
Choosing/overriding the provider¶
ticket_sync.provider ("jira" or "github_issues") selects the provider; it falls back to the service-level TICKET_SYNC_PROVIDER env var if unset on the schedule. ticket_sync.severity_threshold falls back to TICKET_SYNC_SEVERITY_THRESHOLD (service default: medium).
Two independent GitHub issue-creation paths
The older GITHUB_ISSUES_ENABLED auto-create channel and this ticket_sync provider are separate code paths that can both create a GitHub issue for the same finding if both are active for the same repo/schedule scope. If you enable ticket_sync with provider: github_issues, make sure GITHUB_ISSUES_ENABLED is not also driving issue creation for that same scope.
Closing and reopening - the GitHub side¶
GitHub Issues sync is bidirectional: closing or reopening the linked GitHub issue updates the finding's status back in VirtualSutra, via POST /webhooks/github/issues.
- Issue reopened → the linked finding's status is set to
open. - Issue closed → the linked finding's status is set to
resolved.
Requirements and safeguards:
- The webhook is authenticated via HMAC-SHA256 using the
GITHUB_WEBHOOK_SECRETenvironment variable (configured as the shared secret on the GitHub webhook itself). This is a separate secret from the per-installationwebhook_secretused by the PR-check integration under Settings > Integrations. - If
GITHUB_WEBHOOK_SECRETis unset, the endpoint runs in a permissive dev-mode forreopenedevents only (an ERROR is logged to surface the misconfiguration) - but aclosedevent is refused outright when the secret isn't configured, since re-resolving a finding suppresses its visibility and that direction must never run unauthenticated. - The finding to update is looked up by its stored
external_issue_url, which must match therepository.full_name+issue.numbercarried in the same signed payload - this stops a payload from naming one repo while pointing at an issue URL belonging to a different one. - Jira has no equivalent inbound webhook in this build - closing/reopening a Jira issue does not currently sync back to the finding's status.
SLA-breach escalation¶
Independent of any new scan, an hourly scheduled job (SLA_BREACH_CHECK_ENABLED, default on; interval via SLA_BREACH_CHECK_INTERVAL_HOURS, default 1 hour) scans every org for open, unmuted findings older than that org's SLA threshold for their severity, and emits an escalated_finding event (escalation_reason: "sla_breach") for each one - which Ticket Sync picks up the same way as a diff-driven escalation.
Default thresholds (hours), overridable per-org:
| Severity | Default threshold | Org override key |
|---|---|---|
| Critical | 48h (2 days) | critical_hours |
| High | 168h (7 days) | high_hours |
| Medium | 720h (30 days) | medium_hours |
| Low | (untracked - no SLA) | - |
Setting an override to 0 or a non-positive number disables SLA tracking for that severity. Each org's breach check is isolated - a failure processing one org doesn't stop the hourly cycle from checking the rest. Each finding/severity breach is deduplicated via a Redis key so the same breach doesn't re-fire every hour within the same threshold window.
Environment variables reference¶
| Variable | Purpose |
|---|---|
TICKET_SYNC_ENABLED | Shown as ticket sync's enabled status in GET /notifications/channels. It does not turn ticket sync on or off; each schedule's own ticket_sync.enabled does |
TICKET_SYNC_PROVIDER | Service-level default provider (jira / github_issues) |
TICKET_SYNC_PROJECT_KEY | Not a default: a schedule must set project_key itself. Under SaaS, it (with JIRA_PROJECT_KEY) names the only projects on JIRA_DOMAIN the deployment's credential may be used for. Self-hosted it narrows nothing: every project on JIRA_DOMAIN is allowed |
TICKET_SYNC_REPO | Not a default: a schedule must set repo itself. A GitHub owner/repo the deployment's credential may be used for |
TICKET_SYNC_ALLOWED_TARGETS | Further targets the deployment's Jira/GitHub credential may be sent to. Comma-separated, self-hosted only (ignored under SaaS): jira:<site> (the bare site name, e.g. acme for acme.atlassian.net), jira:<site>/<PROJECT>, github:<owner>/<repo>, github:<owner>/*. A malformed entry is ignored, with no error |
TICKET_SYNC_SEVERITY_THRESHOLD | Service-level default severity threshold (default medium) |
JIRA_DOMAIN, JIRA_EMAIL, JIRA_API_TOKEN | Shared Jira credentials (also used by the separate Jira auto-create channel) |
GITHUB_ISSUES_TOKEN | Shared GitHub token (also used by the separate GitHub Issues auto-create channel) |
GITHUB_WEBHOOK_SECRET | HMAC secret for the inbound /webhooks/github/issues bidirectional-sync endpoint |
SLA_BREACH_CHECK_ENABLED | Enable/disable the hourly SLA-breach scheduler (default true) |
SLA_BREACH_CHECK_INTERVAL_HOURS | How often the SLA-breach job runs (default 1) |
Per-schedule ticket_sync config values (set in the schedule's config JSON) always take precedence over these service-level values. The credential env vars exist as fallbacks so you don't have to repeat credentials on every schedule, but the deployment's credential is only sent to a target on its allowlist. Self-hosted, that is any project on JIRA_DOMAIN, the repos in TICKET_SYNC_REPO / GITHUB_ISSUES_REPO, and TICKET_SYNC_ALLOWED_TARGETS. Under SaaS, it is only the projects in TICKET_SYNC_PROJECT_KEY / JIRA_PROJECT_KEY on JIRA_DOMAIN, plus those repos. A schedule that names any other target must carry its own credential (platform#472); under SaaS, such a schedule is refused when it is saved.