Skip to content
This repository has been archived by the owner on Jan 15, 2021. It is now read-only.

[WIP] Send nags to product owners #21

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,18 @@ string), the boolean is true; otherwise, it's false.
[DJ-Database-URL schema][]. When `DEBUG` is true, it defaults to a
sqlite file in the root of the repository called `db.sqlite3`.

* `EMAIL_URL` is the URL for the service to use when sending
email, as per the [dj-email-url schema][]. When `DEBUG` is true,
this defaults to `console:`. If it is set to `dummy:` then no emails will
be sent and messages about email notifications will not be shown to users.
The setting can easily be manually tested via the `manage.py sendtestemail`
command.

* `DEFAULT_FROM_EMAIL` is the email from-address to use in all system
generated emails to users. It corresponds to Django's
[`DEFAULT_FROM_EMAIL`][] setting. It defaults to `noreply@localhost`
when `DEBUG=True`.

* `H1_API_USERNAME` is your HackerOne API Token identifier. For more
details, see the [HackerOne API Authentication docs][h1docs].

Expand Down Expand Up @@ -143,6 +155,8 @@ flake8 && pytest
[virtualenv]: http://python-guide-pt-br.readthedocs.io/en/latest/dev/virtualenvs/
[twelve-factor]: http://12factor.net/
[DJ-Database-URL schema]: https://github.com/kennethreitz/dj-database-url#url-schema
[dj-email-url schema]: https://github.com/migonzalvar/dj-email-url#supported-backends
[`DEFAULT_FROM_EMAIL`]: https://docs.djangoproject.com/en/1.8/ref/settings/#std:setting-DEFAULT_FROM_EMAIL
[`SECRET_KEY`]: https://docs.djangoproject.com/en/1.8/ref/settings/#secret-key
[h1docs]: https://api.hackerone.com/docs/v1#authentication
[pytest]: https://docs.pytest.org/
Expand Down
12 changes: 12 additions & 0 deletions bugbounty/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from pathlib import Path
from dotenv import load_dotenv
import dj_database_url
import dj_email_url

# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
Expand Down Expand Up @@ -43,7 +44,18 @@
(Path(BASE_DIR) / 'db.sqlite3').as_uri().replace('file:///',
'sqlite:////')
)
os.environ.setdefault(
'EMAIL_URL',
os.environ.get('DEFAULT_DEBUG_EMAIL_URL', 'console:')
)
os.environ.setdefault('DEFAULT_FROM_EMAIL', 'noreply@localhost')

email_config = dj_email_url.parse(os.environ['EMAIL_URL'])
# Sets a number of settings values, as described at
# https://github.com/migonzalvar/dj-email-url
vars().update(email_config)

DEFAULT_FROM_EMAIL = os.environ['DEFAULT_FROM_EMAIL']

SECRET_KEY = os.environ['SECRET_KEY']

Expand Down
6 changes: 5 additions & 1 deletion dashboard/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ class ReportAdmin(admin.ModelAdmin):
'is_false_negative',
)

readonly_fields = Report.H1_OWNED_FIELDS + ('days_until_triage',)
readonly_fields = Report.H1_OWNED_FIELDS + (
'days_until_triage',
'last_nagged_at',
'next_nag_at',
)
fields = ('is_accurate', 'is_false_negative') + readonly_fields

def has_add_permission(self, request):
Expand Down
69 changes: 69 additions & 0 deletions dashboard/dates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import datetime
import pytz
from businesstime import BusinessTime
from businesstime.holidays.usa import USFederalHolidays


# Number of business days we have to fix a security vulnerability.
SLA_DAYS = 90

# Days before SLA_DAYS we'll nag someone to attend to the vulnerability.
NAG_DAYS = [45, 22, 11, 5, 3, 2, 1]

# The timezone we base all "business day aware" date calculations on.
BUSINESS_TIMEZONE = 'US/Eastern'

_businesstime = BusinessTime(holidays=USFederalHolidays())


def businesstimedelta(a, b):
'''
Calculate the timedelta between two timezone-aware datetimes.

Note that due to the fact that the businesstime package doesn't currently
accept timezone-aware datetimes, we need to convert them to
timezone-naive ones first.

For future reference, this issue has been filed at:

https://github.com/seatgeek/businesstime/issues/18
'''

# https://stackoverflow.com/a/5452709
est = pytz.timezone(BUSINESS_TIMEZONE)
return _businesstime.businesstimedelta(
a.astimezone(est).replace(tzinfo=None),
b.astimezone(est).replace(tzinfo=None),
)


def calculate_next_nag(created_at, last_nagged_at=None):
'''
Given the date/time a report was issued and the date/time we
last nagged someone to attend to it (if any), calculate the date/time of
the next nag.
'''

# Partly due to apparent limitations in the businesstime package,
# this code is horrible.

last_nag_day = 0
if last_nagged_at is not None:
last_nag_day = businesstimedelta(created_at, last_nagged_at).days

nag_days_left = [
SLA_DAYS - days for days in NAG_DAYS
if SLA_DAYS - days > last_nag_day
]

if nag_days_left:
next_nag_day = nag_days_left[0]
else:
next_nag_day = last_nag_day + 1

dt = created_at
while True:
dt += datetime.timedelta(hours=24)
days_since_creation = businesstimedelta(created_at, dt).days
if days_since_creation >= next_nag_day:
return dt
3 changes: 3 additions & 0 deletions dashboard/management/commands/h1sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ def handle(self, *args, **options):
title=h1_report.title,
created_at=h1_report.created_at,
triaged_at=h1_report.triaged_at,
closed_at=h1_report.closed_at,
state=h1_report.state,
asset_identifier=scope and scope.asset_identifier,
asset_type=scope and scope.asset_type,
is_eligible_for_bounty=scope and scope.eligible_for_bounty,
last_synced_at=now,
),
Expand Down
35 changes: 34 additions & 1 deletion dashboard/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,55 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.2 on 2017-06-23 12:55
# Generated by Django 1.11.2 on 2017-06-27 12:19
from __future__ import unicode_literals

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

initial = True

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='Product',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=50)),
('owner', models.ForeignKey(help_text="Individual responsible for the product. Nags will be sent to them if reports aren't closed.", on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='ProductAsset',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('h1_asset_identifier', models.CharField(help_text='The HackerOne asset identifier; it is probably a URL of some sort, but it can also be the name of a downloadable executable, an app store ID, or a variety of other things depending on the asset type.', max_length=255)),
('h1_asset_type', models.CharField(help_text='The HackerOne asset type; can be URL, SOURCE_CODE, or other values that are currently undocumented by H1.', max_length=255)),
('product', models.ForeignKey(help_text='The product this asset belongs to.', null=True, on_delete=django.db.models.deletion.CASCADE, to='dashboard.Product')),
],
),
migrations.CreateModel(
name='Report',
fields=[
('title', models.TextField()),
('created_at', models.DateTimeField()),
('triaged_at', models.DateTimeField(blank=True, null=True)),
('closed_at', models.DateTimeField(blank=True, null=True)),
('state', models.CharField(choices=[('new', 'new'), ('triaged', 'triaged'), ('needs-more-info', 'needs-more-info'), ('resolved', 'resolved'), ('not-applicable', 'not-applicable'), ('informative', 'informative'), ('duplicate', 'duplicate'), ('spam', 'spam')], max_length=30)),
('asset_identifier', models.CharField(max_length=255, null=True)),
('asset_type', models.CharField(max_length=255, null=True)),
('is_eligible_for_bounty', models.NullBooleanField()),
('id', models.PositiveIntegerField(primary_key=True, serialize=False)),
('is_accurate', models.BooleanField(default=True, help_text="Whether we agree with HackerOne's triage assessment.")),
('is_false_negative', models.BooleanField(default=False, help_text='Whether HackerOne improperly classified the report as invalid or duplicate.')),
('days_until_triage', models.IntegerField(blank=True, help_text='Number of business days between a report being filed and being triaged.', null=True)),
('last_nagged_at', models.DateTimeField(blank=True, help_text='When we last contacted the person ultimately responsible for this report, encouraging them to pay attention to it.', null=True)),
('next_nag_at', models.DateTimeField(blank=True, help_text='When we should contact the person ultimately responsible for this report, encouraging them to pay attention to it.', null=True)),
('last_synced_at', models.DateTimeField()),
],
),
Expand All @@ -35,4 +60,12 @@ class Migration(migrations.Migration):
('last_synced_at', models.DateTimeField(blank=True, help_text='When the dashboard was last synced with HackerOne.', null=True)),
],
),
migrations.AlterUniqueTogether(
name='productasset',
unique_together=set([('h1_asset_identifier', 'h1_asset_type')]),
),
migrations.AlterIndexTogether(
name='productasset',
index_together=set([('h1_asset_identifier', 'h1_asset_type')]),
),
]
106 changes: 96 additions & 10 deletions dashboard/models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import pytz
from django.db import models
from businesstime import BusinessTime
from businesstime.holidays.usa import USFederalHolidays
from django.contrib.auth.models import User

from . import dates

# These need to be copacetic w/ the HackerOne API, i.e. H1 should never
# give us asset IDs or types that are longer than these values.
H1_ASSET_ID_MAXLEN = 255
H1_ASSET_TYPE_MAXLEN = 255


def percentage(n, d, default=0):
Expand Down Expand Up @@ -32,7 +37,10 @@ class Report(models.Model):
'title',
'created_at',
'triaged_at',
'closed_at',
'state',
'asset_identifier',
'asset_type',
'is_eligible_for_bounty',
'id',
)
Expand All @@ -41,9 +49,14 @@ class Report(models.Model):
title = models.TextField()
created_at = models.DateTimeField()
triaged_at = models.DateTimeField(blank=True, null=True)
closed_at = models.DateTimeField(blank=True, null=True)
state = models.CharField(max_length=30, choices=[
(name, name) for name in STATES
])
asset_identifier = models.CharField(max_length=H1_ASSET_ID_MAXLEN,
null=True)
asset_type = models.CharField(max_length=H1_ASSET_TYPE_MAXLEN,
null=True)
is_eligible_for_bounty = models.NullBooleanField()
id = models.PositiveIntegerField(primary_key=True)

Expand All @@ -63,22 +76,46 @@ class Report(models.Model):
blank=True,
null=True,
)
last_nagged_at = models.DateTimeField(
help_text=('When we last contacted the person ultimately responsible '
'for this report, encouraging them to pay attention to '
'it.'),
blank=True,
null=True
)
next_nag_at = models.DateTimeField(
help_text=('When we should contact the person ultimately responsible '
'for this report, encouraging them to pay attention to '
'it.'),
blank=True,
null=True
)
last_synced_at = models.DateTimeField()

def get_absolute_url(self):
return f'https://hackerone.com/reports/{self.id}'

def save(self, *args, **kwargs):
def _set_days_until_triage(self):
if self.created_at and self.triaged_at:
bt = BusinessTime(holidays=USFederalHolidays())
# https://stackoverflow.com/a/5452709
est = pytz.timezone('US/Eastern')
self.days_until_triage = bt.businesstimedelta(
self.created_at.astimezone(est).replace(tzinfo=None),
self.triaged_at.astimezone(est).replace(tzinfo=None),
self.days_until_triage = dates.businesstimedelta(
self.created_at,
self.triaged_at
).days
else:
self.days_until_triage = None

def _set_next_nag_at(self):
if self.closed_at is None and self.is_eligible_for_bounty:
self.next_nag_at = dates.calculate_next_nag(
self.created_at,
self.last_nagged_at
)
else:
self.next_nag_at = None

def save(self, *args, **kwargs):
self._set_days_until_triage()
self._set_next_nag_at()
return super().save(*args, **kwargs)

@classmethod
Expand Down Expand Up @@ -121,3 +158,52 @@ def save(self, *args, **kwargs):
@classmethod
def load(cls):
return cls.objects.get_or_create(id=cls.SINGLETON_ID)[0]


class Product(models.Model):
'''
Represents one of our products, e.g. Federalist or login.gov.
'''

title = models.CharField(max_length=50)

owner = models.ForeignKey(
to=User,
help_text=('Individual responsible for the product. Nags will '
'be sent to them if reports aren\'t closed.')
)


class ProductAsset(models.Model):
'''
Represents an asset that is associated with a product.

This is the primary mechanism through which reports are mapped to
products and product owners.
'''

class Meta:
unique_together = ("h1_asset_identifier", "h1_asset_type")
index_together = list(unique_together)

h1_asset_identifier = models.CharField(
max_length=H1_ASSET_ID_MAXLEN,
help_text=('The HackerOne asset identifier; it is probably a URL '
'of some sort, but it can also be the name of a '
'downloadable executable, an app store ID, or a variety '
'of other things depending on the asset type.'),
)

# We would supply a list of choices here, but HackerOne hasn't
# documented them at the time of this writing (June 2017), so we won't.
h1_asset_type = models.CharField(
max_length=H1_ASSET_TYPE_MAXLEN,
help_text=('The HackerOne asset type; can be URL, SOURCE_CODE, or '
'other values that are currently undocumented by H1.'),
)

product = models.ForeignKey(
to=Product,
help_text='The product this asset belongs to.',
null=True,
)
Loading