Skip to content

Commit

Permalink
snapshot of BT linear integration
Browse files Browse the repository at this point in the history
  • Loading branch information
bsterne committed Dec 10, 2024
1 parent dd32d9a commit 2aa9bf7
Show file tree
Hide file tree
Showing 18 changed files with 730 additions and 0 deletions.
36 changes: 36 additions & 0 deletions dojo/db_migrations/0219_linear_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Generated by Django 5.1.2 on 2024-12-05 00:11

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('dojo', '0218_system_settings_enforce_verified_status_and_more'),
]

operations = [
migrations.AddField(
model_name='system_settings',
name='enable_linear',
field=models.BooleanField(default=False, verbose_name='Enable Linear integration'),
),
migrations.CreateModel(
name='Linear_Issue',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('url', models.URLField(max_length=2000, unique=True)),
('findings', models.ManyToManyField(related_name='linear_issues', to='dojo.finding')),
],
),
migrations.CreateModel(
name='Linear_Instance',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('instance_name', models.CharField(default='', help_text='Enter a name to give to this configuration', max_length=2000, unique=True)),
('api_key', models.CharField(max_length=2000)),
('team_id', models.CharField(default='', help_text='Enter the UUID of the Team where you want to file Issues', max_length=64)),
('addl_json_input', models.JSONField(blank=True, default=dict, help_text='Enter any additional JSON input you want to use when creating new Issues', max_length=2000, null=True)),
],
),
]
18 changes: 18 additions & 0 deletions dojo/db_migrations/0220_alter_linear_instance_addl_json_input.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.1.2 on 2024-12-05 20:24

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('dojo', '0219_linear_integration'),
]

operations = [
migrations.AlterField(
model_name='linear_instance',
name='addl_json_input',
field=models.JSONField(blank=True, default=dict, help_text='Enter any additional JSON input you want to use when creating new Issues, e.g. { "labelIds": ["your-label-uuid"] }', max_length=2000, null=True),
),
]
2 changes: 2 additions & 0 deletions dojo/finding/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@
name="choose_finding_template_options"),
re_path(r"^finding/(?P<fid>\d+)/(?P<tid>\d+)/apply_template_to_finding$",
views.apply_template_to_finding, name="apply_template_to_finding"),
re_path(r"^finding/(?P<fid>\d+)/linear$", views.create_linear_issue,
name="create_linear_issue"),
re_path(r"^finding/(?P<fid>\d+)/close$", views.close_finding,
name="close_finding"),
re_path(r"^finding/(?P<fid>\d+)/defect_review$",
Expand Down
179 changes: 179 additions & 0 deletions dojo/finding/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from django.db.models.query import Prefetch
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse, StreamingHttpResponse
from django.shortcuts import get_object_or_404, render
from django.template import Context, Template
from django.template.defaultfilters import pluralize
from django.urls import reverse
from django.utils import formats, timezone
Expand Down Expand Up @@ -65,6 +66,7 @@
FindingTemplateForm,
GITHUBFindingForm,
JIRAFindingForm,
LinearFindingForm,
MergeFindings,
NoteForm,
PromoteFindingForm,
Expand All @@ -86,6 +88,8 @@
Finding_Template,
GITHUB_Issue,
GITHUB_PKey,
Linear_Instance,
Linear_Issue,
Note_Type,
NoteHistory,
Notes,
Expand Down Expand Up @@ -1212,6 +1216,181 @@ def post(self, request: HttpRequest, finding_id):
return redirect_to_return_url_or_else(request, reverse("view_test", args=(finding.test.id,)))
raise PermissionDenied

def linear_api_create_issue(post_body):
import requests
lid = int(post_body.get("linear"))
linear_instance = get_object_or_404(Linear_Instance, pk=lid)

url = linear_instance.LINEAR_API_URL

headers = {
"Authorization": linear_instance.api_key,
"Content-Type": "application/json"
}

query = """
mutation CreateIssue($input: IssueCreateInput!) {
issueCreate(input: $input) {
success
issue {
id
identifier
title
url
}
}
}
"""

variables = {
"input": {
"teamId": linear_instance.team_id,
"title": post_body.get("title"),
"description": post_body.get("description")
}
}
# add any additional configuration that was specified (any falsey configs, e.g.
# [], {}, null, etc. won't be applied even though they can be stored as valid JSON
if linear_instance.addl_json_input:
for key, val in linear_instance.addl_json_input.items():
variables["input"][key] = val

payload = {
"query": query,
"variables": variables
}

return requests.post(url, json=payload, headers=headers)


def linear_create_formatted_description(finding):
tmpl_desc = Template("""
## Description
{{ description }}
## Affected Systems
{{ repo }}
- {{ path }}
{{ mitigation }}
""".strip())

repo = "[%s](%s)" % (
finding.test.engagement.product.name,
finding.test.engagement.source_code_management_uri
)
path = ""
if finding.file_path:
path = "[%s%s](%s)" % (
finding.file_path,
":"+str(finding.line) if finding.line else "",
finding.get_file_path_with_raw_link()
)

mitigation = ""
if finding.mitigation:
mitigation = ("""
## Mitigation
```
%s
```
""".strip() % finding.mitigation
)

return tmpl_desc.render(
Context({
"description": finding.description,
"repo": repo,
"path": path,
"mitigation": mitigation
})
)

# display a form to push a finding to Linear and create a new issue
@user_is_authorized(Finding, Permissions.Finding_Edit, "fid")
def create_linear_issue(request, fid):
finding = get_object_or_404(Finding, id=fid)
if request.method == "POST":
form = LinearFindingForm(request.POST)
if form.is_valid():
response = linear_api_create_issue(request.POST)
# success creating issue
if response.status_code == 200:
resp_obj = response.json()
# 200 responses can represent an error in GraphQL, so we need to look
# in the response body
if "errors" in resp_obj:
error_msgs = [
e.get("extensions", {}).get("userPresentableMessage", "Unknown Error")
for e in resp_obj.get("errors", [])
]
messages.add_message(
request,
messages.ERROR,
"Error creating Linear issue: {}".format(", ".join(error_msgs)),
extra_tags="alert-danger",
)
# successfully created the issue --> update the UI
else:
issue_url = resp_obj.get("data", {}).get("issueCreate", {}).get(
"issue", {}).get("url")
# keep track of linear issues
issue, cr = Linear_Issue.objects.get_or_create(url=issue_url)
# associate new linear issue with finding
issue.findings.add(finding)
# update UI
messages.add_message(
request,
messages.SUCCESS,
mark_safe(
"Linear issue created: <a href='{}'>{}</a>".format(
issue_url,
resp_obj.get("data", {}).get("issueCreate", {}).get(
"issue", {}).get("identifier", "")
)
),
extra_tags="alert-success",
)
# failure to create issue
else:
messages.add_message(
request,
messages.ERROR,
"Error creating Linear issue: {}".format(response.text),
extra_tags="alert-danger",
)
return HttpResponseRedirect(
reverse("create_linear_issue", args=(finding.id,)),
)

# non-POST
else:
# build initial form with values, user can edit them if needed
form = LinearFindingForm(
initial = {
"title": finding.title,
"description": linear_create_formatted_description(finding)
}
)

product_tab = Product_Tab(
finding.test.engagement.product, title="Create Linear Issue", tab="findings",
)

return render(
request,
"dojo/create_linear_issue.html",
{
"finding": finding,
"product_tab": product_tab,
"active_tab": "findings",
"user": request.user,
"form": form,
"linear_issues": finding.linear_issues.all()
},
)

@user_is_authorized(Finding, Permissions.Finding_Edit, "fid")
def close_finding(request, fid):
Expand Down
56 changes: 56 additions & 0 deletions dojo/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
JIRA_Instance,
JIRA_Issue,
JIRA_Project,
Linear_Instance,
Note_Type,
Notes,
Notification_Webhooks,
Expand Down Expand Up @@ -1306,6 +1307,30 @@ class Meta:
exclude = ("reporter", "url", "numerical_severity", "active", "false_p", "verified", "endpoint_status", "cve", "inherited_tags",
"duplicate", "out_of_scope", "under_review", "reviewers", "review_requested_by", "is_mitigated", "jira_creation", "jira_change", "planned_remediation_date", "planned_remediation_version", "effort_for_fixing")

class LinearFindingForm(forms.ModelForm):
title = forms.CharField(max_length=1000)
description = forms.CharField(widget=forms.Textarea)
linear = forms.ModelChoiceField(
required=True,
queryset = Linear_Instance.objects.all().order_by("instance_name")
)

class Meta:
fields = ["title", "description", "linear"]
model = Finding

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

queryset = Linear_Instance.objects.all().order_by("instance_name")
self.fields["linear"].queryset = queryset

# if we only have one Linear instance, prefill and make the field read-only
if queryset.count() == 1:
single_instance = queryset.first()
self.fields["linear"].initial = single_instance
self.fields["linear"].widget.attrs['readonly'] = True


class FindingForm(forms.ModelForm):
title = forms.CharField(max_length=1000)
Expand Down Expand Up @@ -2438,6 +2463,28 @@ def clean(self):
self.test_jira_connection()
return self.cleaned_data

class LinearForm(forms.ModelForm):
class Meta:
model = Linear_Instance
fields = "__all__"

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["team_id"].widget = forms.TextInput(
attrs={
"placeholder": "https://developers.linear.app/docs/graphql/working-with-the-graphql-api"
}
)
self.fields["addl_json_input"].label = "Additional JSON Input"
self.fields["addl_json_input"].widget = forms.Textarea(attrs={'rows': 3})
self.fields["addl_json_input"].validators = [self.validate_addl_json]
self.fields["api_key"].widget = forms.PasswordInput(render_value=True)
if self.instance and self.instance.pk:
self.fields['api_key'].initial = self.instance.api_key

def validate_addl_json(self, value):
if not isinstance(value, dict):
raise ValidationError("Value must be an object with key-value pairs.")

class AdvancedJIRAForm(BaseJiraForm):
issue_template_dir = forms.ChoiceField(required=False,
Expand Down Expand Up @@ -2520,6 +2567,15 @@ class Meta:
fields = ["id"]


class DeleteLinearInstanceForm(forms.ModelForm):
id = forms.IntegerField(required=True,
widget=forms.widgets.HiddenInput())

class Meta:
model = Linear_Instance
fields = ["id"]


class DeleteJIRAInstanceForm(forms.ModelForm):
id = forms.IntegerField(required=True,
widget=forms.widgets.HiddenInput())
Expand Down
Empty file added dojo/linear/__init__.py
Empty file.
10 changes: 10 additions & 0 deletions dojo/linear/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from django.urls import re_path

from . import views

urlpatterns = [
re_path(r"^linear/add", views.NewLinearView.as_view(), name="add_linear"),
re_path(r"^linear/(?P<lid>\d+)/edit$", views.EditLinearView.as_view(), name="edit_linear"),
re_path(r"^linear/(?P<lid>\d+)/delete$", views.DeleteLinearView.as_view(), name="delete_linear"),
re_path(r"^linear$", views.ListLinearView.as_view(), name="linear"),
]
Loading

0 comments on commit 2aa9bf7

Please sign in to comment.