Ticketing: Jira and ServiceNow¶
The controller can turn a finding into a ticket in Jira (Cloud or Data Center) or in ServiceNow: by hand, from a finding card, or automatically for the projects given a severity threshold. Sending is one way: the key and the link of the ticket are kept on the finding, and its status is never changed from the ticket (a person still decides it, see triage of findings).
Each system is a connector: Jira and ServiceNow are built in, and a third-party package can add others (a GRC platform, an internal tracker): see third-party connectors and writing a connector.
Only the controller connects to these systems and loads the connectors; the MCP server never does.
Enabling¶
The Connectors screen (Administration, settings.manage permission) shows every available
connector as a card: its state, the number of projects that use it, a switch and a gear
that opens its settings (Connection and Projects tabs).
Every connector is disabled by default: nothing is sent through it until an administrator
switches it on. A connector can only be switched on once its connection is configured; switching
it off stops the sending by hand and the automatic sending at once. Each change is recorded in
the audit log (ticketing.changed, {"enabled": true}).
SECREVIEW_TICKETING_ENABLED is the lock of the operator:
| Value | Effect |
|---|---|
true (default) |
The switches of the screen decide; every connector is off until switched on |
false |
Every connector is locked off: nothing is sent, the switches cannot be turned on, Test the connection is refused |
Upgrade from 1.0
In 1.0, SECREVIEW_TICKETING_ENABLED=true was enough to send through every configured
connector. Since the switches, switch on each connector used in the Connectors screen
after the upgrade: until then, nothing is sent.
SECREVIEW_TICKETING_INTERVAL_SECONDS=300 # period of the automatic sending
SECREVIEW_TICKETING_CAFILE=/etc/ssl/corp-ca.pem # optional: internal certificate authority
Connections¶
Each system has its connection, set by its variables or in the Connection tab of its gear in
the Connectors screen. A variable that is set takes precedence: its
field is only shown in the screen. Secrets are never shown again (***); sending *** back
keeps the stored one. Test the connection checks the entered values with a read-only
request, without saving them, before the connector is switched on.
Every field of a connector has its variable, SECREVIEW_<KIND>_<FIELD>: those of the built-in
connectors are below. A secret is bound to the host of its URL: setting a URL for another host,
or a URL where there was none (after deleting it, for instance), requires entering the secrets
again in the same change. A secret set by a variable only goes to a URL set by a variable: the
screen refuses the URL (otherwise a stored token could be sent to another host).
| Variable | Default | Role |
|---|---|---|
SECREVIEW_JIRA_URL |
— | Jira address, https:// only (https://acme.atlassian.net, or the base URL of a Data Center) |
SECREVIEW_JIRA_USERNAME |
— | Jira Cloud: e-mail address of the account, used with an API token. Empty: the token is a Data Center personal access token, sent as a bearer token |
SECREVIEW_JIRA_API_TOKEN |
— | API token (Cloud) or personal access token (Data Center) |
SECREVIEW_JIRA_ISSUE_TYPE |
Bug |
Issue type of the tickets |
SECREVIEW_SERVICENOW_URL |
— | Address of the instance, https:// only |
SECREVIEW_SERVICENOW_USERNAME |
— | Account (basic authentication) |
SECREVIEW_SERVICENOW_PASSWORD |
— | Its password |
SECREVIEW_SERVICENOW_TABLE |
incident |
Table of the records |
The account only needs to create tickets: in Jira, the Browse projects and Create issues
permissions on the projects used; in ServiceNow, the right to create records in the table
(the itil role for incident).
Destinations of the projects¶
A project needs a destination to send its findings to a system: the Jira project key
(SEC), or the ServiceNow assignment group (optional, its name). The destinations are set
in the Projects tab of the gear of the connector, in the Connectors screen.
Each destination may have an automatic sending threshold: every new open finding at least that severe is then sent by the controller. The threshold only applies to the reports saved after it was set: the findings already stored are only sent by hand.
Sending a finding¶
A user who may triage the project (findings.triage permission) sees a Send to Jira /
Send to ServiceNow button on each finding card whose project has a destination. Once the
ticket exists, the card shows its key, linked to the ticket. A failure is shown on the card
with its reason (refused credentials, unknown project key...).
The automatic sending runs every SECREVIEW_TICKETING_INTERVAL_SECONDS (300 by default), at
most 20 findings per system and round, the most severe first. A failed finding is retried at
most 5 times, 15 minutes after the first failure, then with a doubled delay.
One ticket per issue¶
A finding found again by a later report (same project, rule, file and title: the SARIF fingerprint) reuses the ticket of the first occurrence: no new ticket is created. Sending a finding that already has its ticket returns it.
Content of a ticket¶
| Jira | ServiceNow | |
|---|---|---|
| Title | [HIGH] shop-api: SQL injection (summary) |
same (short_description) |
| Description | project, severity, location, rule, CWE, OWASP, branch, commit, link to the report (with SECREVIEW_WEB_URL), then description, evidence and recommendation |
same, as plain text |
| Other fields | labels security-review and severity-<severity> |
urgency and impact from the severity, assignment_group, correlation_id (the fingerprint) |
ServiceNow urgency and impact: critical 1 / 1, high 1 / 2, medium 2 / 2, low and
info 3 / 3.
The texts of the report are masked again (secrets) before they leave. In Jira, they are put in
{noformat} blocks: written by an agent, they are never read as wiki markup (links, images
loaded from another site).
Security¶
- Outgoing connections of the controller only, every connector disabled by default (switched
on by an administrator, or locked off by
SECREVIEW_TICKETING_ENABLED=false), over HTTPS with certificate verification (SECREVIEW_TICKETING_CAFILEfor an internal authority), through the outgoing proxy. - Redirects are refused: they would carry the credentials elsewhere. Timeout 15 seconds, answer bounded to 1 MiB.
- Secrets entered in the screen are stored in the database in clear, like the OIDC client
secret: the API never returns them (
***), the logs only name the changed settings, the audit event masks them. Prefer the variables (Kubernetes secret) if the database is not protected like a secret. - A failure keeps a short code and a bounded excerpt of the error message of the system, masked and without the credentials.
- A secret is bound to the host of its URL: a new URL requires its secrets again, even after the URL was deleted, and a secret set by a variable needs its URL set by a variable, so that a stored token never reaches another host.
- A third-party connector is loaded only when
SECREVIEW_TICKETING_PLUGINSnames it, and runs with the rights of the controller: install only code you trust. - Audit events:
ticketing.changed(connection or switch),ticketing.target_changed(destination of a project),finding.ticket_created(ticket created or reused, with its key; actorautofor the automatic sending).
Third-party connectors¶
A connector published by a Python package (entry point of the group
security_review_mcp.ticketing) is installed next to the controller, then allowed by its
entry point name: installing a package is not enough.
pip install secreview-acme-connector # in the image or the environment of the controller
SECREVIEW_TICKETING_PLUGINS=acme # comma separated
SECREVIEW_ACME_URL=https://risk.acme.example/api
SECREVIEW_ACME_API_KEY=...
The controller loads the allowed connectors at start and refuses to start if one is missing,
written for another version of the API, or declares a variable of the server itself
(SECREVIEW_<KIND>_<FIELD> must not be an existing setting). The Connectors screen shows it
as a card marked Third-party, off until switched on. A connector runs in the controller with its
rights: only install and allow code you trust, like any dependency.
Writing a connector¶
A connector is a subclass of TicketConnector (module security_review_mcp.ticketing.api,
version API_VERSION = 1). The complete example
examples/ticketing-connector
is a package to copy.
from security_review_mcp.ticketing.api import (
Connection,
ConnectorField,
CreatedTicket,
TicketConnector,
TicketContent,
TicketingError,
)
class AcmeConnector(TicketConnector):
kind = "acme" # identifier: tickets, URLs, SECREVIEW_ACME_* variables
name = "Acme Risk" # display name
url_placeholder = "https://risk.acme.example/api"
fields = ( # the URL field is added by the controller
ConnectorField(
name="api_key",
label="API key",
secret=True,
required=True,
translations={"fr": {"label": "Clé d'API"}},
),
)
destination = ConnectorField(
name="destination", label="Risk register", required=True, pattern=r"[A-Z0-9_-]{1,40}"
)
def auth_headers(self, values):
return {"X-Api-Key": values["api_key"]}
def create_ticket(
self, connection: Connection, destination: str, content: TicketContent
) -> CreatedTicket:
answer = connection.http.request(
"POST",
f"/registers/{destination}/risks",
json_body={"title": content.summary, "description": content.text()},
)
if not isinstance(answer.get("id"), str):
raise TicketingError("invalid", "acme sent no risk id")
return CreatedTicket(key=answer["id"], url=f"{connection.base_url}/risks/{answer['id']}")
def test_connection(self, connection: Connection) -> None:
connection.http.request("GET", "/me")
# pyproject.toml of the package
[project.entry-points."security_review_mcp.ticketing"]
acme = "acme_connector:AcmeConnector"
| Element | Role |
|---|---|
kind |
Lowercase letters, digits and _ (2 to 20 characters); stored with the tickets |
fields |
ConnectorField: name, label, secret, required, default, help, placeholder, pattern (full match), max_length, translations ({"fr": {"label": ..., "help": ...}}) |
destination |
What an administrator chooses per project; override check_destination to normalize it |
auth_headers(values) |
Authentication headers, added by the controller to each request |
create_ticket(connection, destination, content) |
Creates the ticket and returns CreatedTicket(key, url) (https:// link) |
test_connection(connection) |
Read-only request that needs valid credentials |
What the controller guarantees, whatever the connector:
connection.http.request(method, path, json_body=..., query=...)is the only way out:pathis relative to the configured URL (no other host), over HTTPS, through the outgoing proxy, without following redirects, 15 second timeout, JSON answer bounded to 1 MiB; a failure raisesTicketingError(auth,refused,unreachable,invalid) without the secrets;contentis already masked:summary,facts,sections(texts written by an agent: escape them if the system renders markup),text()(plain description),fingerprint,report_url;- the claim (no duplicate ticket), the reuse of the ticket of an issue found again, the stored outcome, the audit and the retries of the automatic sending;
- any other exception of the connector becomes the
invaliderror, without its message (it could hold a value of the connection).
A connector must never open a connection by itself, log the values of its fields, nor change anything but the ticket it creates.
REST API¶
| Route | Permission | Role |
|---|---|---|
GET /api/v1/settings/ticketing |
settings.manage |
Every connector with its fields and connection (secrets ***) and its switch (enabled), allowed plugins, SECREVIEW_TICKETING_ENABLED (enabled) |
GET /api/v1/ticketing/systems |
any | Available systems (kind, name) |
PUT /api/v1/settings/ticketing/{kind} |
settings.manage |
Changes fields of a connection ({"url": ..., "api_token": ...}); null or "" deletes the stored value; 404 for an unknown system |
PUT /api/v1/settings/ticketing/{kind}/enabled |
settings.manage |
Switches a connector on or off ({"enabled": true}); 422 while its connection is not configured or when SECREVIEW_TICKETING_ENABLED=false |
POST /api/v1/settings/ticketing/{kind}/test |
settings.manage |
Tests the stored values and those of the body, not saved, the connector switched on or not |
GET /api/v1/ticketing/targets |
settings.manage |
Destinations of the projects |
PUT /api/v1/projects/{project}/ticket-targets/{kind} |
settings.manage |
{"destination": "SEC", "auto_min_severity": "high"} (null: by hand only) |
DELETE /api/v1/projects/{project}/ticket-targets/{kind} |
settings.manage |
Deletes a destination (the tickets are kept) |
GET /api/v1/ticketing/destinations |
any | Projects and systems the caller may send findings to now |
POST /api/v1/findings/{id}/tickets/{kind} |
findings.triage |
Sends a finding: 201 with the ticket; 409 when the connector is switched off, or the connection or the destination is missing; 502 when the system refuses it |
curl -X POST -H "Authorization: Bearer $TOKEN" \
https://secreview.example.com/api/v1/findings/42/tickets/jira
# {"kind": "jira", "state": "sent", "external_key": "SEC-12", "url": "https://acme.atlassian.net/browse/SEC-12", …}
The tickets of a finding are also in its tickets list, in the reports and lists of findings
(REST API and MCP tools).