From b0293fcc1ca7cfa204247bfb11db7aed18731611 Mon Sep 17 00:00:00 2001 From: Greg Strom Date: Fri, 26 Jun 2026 11:44:43 -0400 Subject: [PATCH 1/2] Upgrade VINCE to 3.0.43 --- CHANGELOG.md | 54 +++ Procfile | 2 +- bigvince/settings_.py | 7 +- cogauth/views.py | 14 + lib/vince/markdown_helpers.py | 18 +- requirements.txt | 15 +- vince/forms.py | 69 +++- vince/lib.py | 251 ++++++++++- vince/migrations/0114_ssvcassessment.py | 89 ++++ .../data/ssvc_coordinator_triage_1_0_0.json | 210 ++++++++++ vince/templates/vince/group.html | 5 + vince/templates/vince/include/changelog.html | 2 +- vince/templates/vince/ssvc_assessment.html | 101 +++++ vince/ticket_update.py | 2 +- vince/views.py | 132 ++++++ vincepub/templates/vincepub/security.txt | 2 +- vincepub/urls.py | 4 - vincepub/views.py | 4 - vinceworker/views.py | 14 + vinny/templates/vinny/case.html | 8 +- vinny/templates/vinny/cr_activity.html | 12 +- vinny/templates/vinny/cr_report.html | 11 +- vinny/templatetags/user_tags.py | 4 +- vinny/urls.py | 2 +- vinny/utils.py | 3 + vinny/views.py | 388 +++++++++++++++++- 26 files changed, 1381 insertions(+), 42 deletions(-) create mode 100644 vince/migrations/0114_ssvcassessment.py create mode 100644 vince/static/vince/data/ssvc_coordinator_triage_1_0_0.json create mode 100644 vince/templates/vince/ssvc_assessment.html diff --git a/CHANGELOG.md b/CHANGELOG.md index d17bd58..44ae3ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,60 @@ VINCE Coordination platform code ## Description VINCE Coordination platform +Version 3.0.43 2026-06-25 + +* dependabot update recommendations: `PyJWT` 2.12.0 to 2.13.0, `cryptography` 46.0.7 to 48.0.1, `bleach` from 5.0.1 to 6.4.0 +* updated `packaging` 22.0 to 24.2 +* increased number of workers and established connection pooling (Internal-857) + + +Version 3.0.42 2026-06-01 + +* dependabot update recommendation: `idna` 3.7 to 3.15 +* anonymized coordinator names in case request activity and file uploads shown to submitters before case creation (Internal-850) +* improved vulnerability note template formatting to add proper spacing before author attribution (Internal-853) +* updated security.txt disclosure policy link to point to new documentation location (Internal-816) +* added due date display to group case view showing time remaining until due date or "TBD" if not set (Internal-849) +* rephrased vulnerability status language throughout VINCE Comm case pages (Internal-852) +* added detailed logging for debugging HMAC mismatches + + + +Version 3.0.41 2026-05-18 + +* modified code for filtering out spoofed emails to allow legititmately encoded headers through vetting process (Internal-846) + + + +Version 3.0.40 2026-05-12 + +* dependabot update recommendation: `urllib3` 1.26.19 to 2.7.0 +* added code to properly record identity of users who submit vul reports for VINCE Comm (Internal-821) +* fixed VINCE Comm login code to prevent "login loop" caused in rare cases by corrupted session objects (Internal-847) +* added SSVC assessment tool to the Case Reqeust page in VINCE Track (Internal-840) + + + +Version 3.0.39 2026-05-06 + +* fixed issue preventing users from making lists and tables in VINCE Comm comments (VIN-845) +* fixed bug reported by user affecting VINCE's vetting of incoming email ticket reports (VIN-846) + + + +Version 3.0.38 2026-05-05 + +* fixed issue preventing vulnote tab from displaying in certain circumstances (Internal-828) + + +Version 3.0.37 2026-05-04 + +* updates: `jsonschema` 4.17.0 to 4.25.1, `pydantic` 1.10.13 to 2.11.7, `attrs` 22.1.0 to 23.1.0, `packaging` 22.0 to 24.0 +* fixed review link to appear even if case review not complete (Internal-828) +* added active status filter to autoassign function (Internal-844) +* fixed security issue caused by debug endpoint + + Version 3.0.36 2026-04-20 * dependabot update recommendation: `sqlparse` 0.5.0 to 0.5.4, `PyJWT` 2.6.0 to 2.12.0, `markdown` 3.5 to 3.8.1, `pyasn1` 0.4.8 to 0.6.3, `awscli` 1.26.85 to 1.44.38, `Django` 4.2.28 to 4.2.30, `cryptography` 46.0.6 to 46.0.7 diff --git a/Procfile b/Procfile index 7cfd850..b846944 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: gunicorn bigvince.wsgi:application --bind 0.0.0.0:8000 --timeout 600 \ No newline at end of file +web: gunicorn bigvince.wsgi:application --bind 0.0.0.0:8000 --workers 5 --timeout 600 \ No newline at end of file diff --git a/bigvince/settings_.py b/bigvince/settings_.py index 1366e17..cc53e0c 100644 --- a/bigvince/settings_.py +++ b/bigvince/settings_.py @@ -54,7 +54,7 @@ ROOT_DIR = environ.Path(__file__) - 3 # any change that requires database migrations is a minor release -VERSION = "3.0.36" +VERSION = "3.0.43" # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/ @@ -419,6 +419,7 @@ def get_secret(secret_arn): "OPTIONS": { "sslmode": "require", }, + "CONN_MAX_AGE": int(os.environ.get("DB_CONN_MAX_AGE", "60")), } if VINCE_NAMESPACE in ["vince", "vinny"]: @@ -429,6 +430,7 @@ def get_secret(secret_arn): "PASSWORD": vincecomm_password, "HOST": os.environ.get("VINCE_COMM_DB_HOST", "localhost"), "PORT": os.environ.get("VINCE_COMM_DB_PORT", 5432), + "CONN_MAX_AGE": int(os.environ.get("DB_CONN_MAX_AGE", "60")), } DATABASES["vincepub"] = { @@ -438,6 +440,7 @@ def get_secret(secret_arn): "PASSWORD": vincepub_password, "HOST": os.environ.get("VINCE_PUB_DB_HOST", "localhost"), "PORT": os.environ.get("VINCE_PUB_DB_PORT", 5432), + "CONN_MAX_AGE": int(os.environ.get("DB_CONN_MAX_AGE", "60")), } if VINCE_NAMESPACE == "vincepub": @@ -450,6 +453,7 @@ def get_secret(secret_arn): "PASSWORD": vincepub_password, "HOST": os.environ.get("VINCE_PUB_DB_HOST", "localhost"), "PORT": os.environ.get("VINCE_PUB_DB_PORT", 5432), + "CONN_MAX_AGE": int(os.environ.get("DB_CONN_MAX_AGE", "60")), } @@ -462,6 +466,7 @@ def get_secret(secret_arn): "PASSWORD": vincecomm_password, "HOST": os.environ.get("VINCE_COMM_DB_HOST", "localhost"), "PORT": os.environ.get("VINCE_COMM_DB_PORT", 5432), + "CONN_MAX_AGE": int(os.environ.get("DB_CONN_MAX_AGE", "60")), } # Each application has their own database, so we can set permissions diff --git a/cogauth/views.py b/cogauth/views.py index d471f71..20209c8 100644 --- a/cogauth/views.py +++ b/cogauth/views.py @@ -563,6 +563,11 @@ def get_context_data(self, **kwargs): if settings.DEBUG: context["token_login"] = True + # Pass the 'next' parameter to the template context + # This is required for the hidden input field in the login form + next_page = self.request.GET.get("next", "") + context["next"] = next_page + return context def form_valid(self, form): @@ -791,6 +796,15 @@ class MFAAuthRequiredView(FormView, AccessMixin): def dispatch(self, request, *args, **kwargs): if not (request.session.get("MFAREQUIRED") and request.session.get("username")): + # Check for potential redirect loop: if user came from login page and is trying + # to access MFA page, but session check failed, redirect to dashboard instead + # of creating a loop where next=/mfa/ + referer = request.META.get('HTTP_REFERER', '') + if '/login/' in referer and '/mfa/' in request.path: + logger.debug( + f"Detected potential MFA redirect loop. Referer: {referer}, Path: {request.path}. Session missing MFAREQUIRED or username. Redirecting to dashboard to break loop." + ) + return redirect(settings.LOGIN_REDIRECT_URL) return self.handle_no_permission() return super(MFAAuthRequiredView, self).dispatch(request, *args, **kwargs) diff --git a/lib/vince/markdown_helpers.py b/lib/vince/markdown_helpers.py index 8a939cd..ad2a1ba 100644 --- a/lib/vince/markdown_helpers.py +++ b/lib/vince/markdown_helpers.py @@ -144,10 +144,24 @@ def markdown(value): markdown_attrs['a'].append("class") markdown_attrs['img'].append("width") markdown_attrs['img'].append("height") - return bleach.clean(md.markdown(value, extensions=['toc', 'markdown.extensions.fenced_code', UserMentionExtension(), MathExtension()]), generally_xss_safe, markdown_attrs) + # Add table-related tags to allow markdown tables to render + markdown_attrs.setdefault('table', []) + markdown_attrs.setdefault('thead', []) + markdown_attrs.setdefault('tbody', []) + markdown_attrs.setdefault('tr', []) + markdown_attrs.setdefault('th', []) + markdown_attrs.setdefault('td', []) + return bleach.clean(md.markdown(value, extensions=['toc', 'markdown.extensions.fenced_code', 'tables', 'nl2br', UserMentionExtension(), MathExtension()]), generally_xss_safe, markdown_attrs) def markdown_filter(value, users): markdown_attrs['a'].append("class") markdown_attrs['img'].append("width") markdown_attrs['img'].append("height") - return bleach.clean(md.markdown(value, extensions=['toc', 'markdown.extensions.fenced_code', UserMentionExtension(users), MathExtension()]), generally_xss_safe, markdown_attrs) + # Add table-related tags to allow markdown tables to render + markdown_attrs.setdefault('table', []) + markdown_attrs.setdefault('thead', []) + markdown_attrs.setdefault('tbody', []) + markdown_attrs.setdefault('tr', []) + markdown_attrs.setdefault('th', []) + markdown_attrs.setdefault('td', []) + return bleach.clean(md.markdown(value, extensions=['toc', 'markdown.extensions.fenced_code', 'tables', 'nl2br', UserMentionExtension(users), MathExtension()]), generally_xss_safe, markdown_attrs) diff --git a/requirements.txt b/requirements.txt index efcd486..7fef6cc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ awscli==1.44.38 backports.zoneinfo;python_version<"3.9" beautifulsoup4==4.11.1 billiard==4.0.2 -bleach==5.0.1 +bleach==6.4.0 bleach-whitelist==0.0.11 boto==2.49.0 boto3==1.42.48 @@ -20,7 +20,7 @@ chardet==5.0.0 charset-normalizer==2.1.1 click==8.1.3 colorama==0.4.4 -cryptography==46.0.7 +cryptography==48.0.1 cvelib==1.3.0 Deprecated==1.2.13 dictdiffer==0.9.0 @@ -40,7 +40,7 @@ envs==1.4 fs==2.4.16 fs-s3fs==1.1.1 gunicorn==22.0.0 -idna==3.7 +idna==3.15 importlib-metadata==5.0.0 importlib-resources==5.10.0 pip-install==1.3.5 @@ -50,7 +50,7 @@ kombu==5.2.4 M2Crypto==0.47.0 Markdown==3.8.1 openpyxl==3.1.5 -packaging==22.0 +packaging==24.2 pinax-messages==3.0.0 pip-autoremove==0.10.0 pkgutil-resolve-name==1.3.10 @@ -60,7 +60,7 @@ pyasn1==0.6.3 pycparser==2.21 pycryptodome==3.19.1 pydantic==1.10.13 -PyJWT==2.12.0 +PyJWT==2.13.0 pyparsing==3.0.9 pyrsistent==0.19.2 python-dateutil==2.8.2 @@ -80,10 +80,9 @@ six==1.16.0 soupsieve==2.3.2.post1 sqlparse==0.5.4 typing-extensions>=4.9.0 -# (urllib3 is currently at 1.26.19. Dependabot recommends urllib3 2.6.3, but that breaks when combined with any currently available version of botocore.) -urllib3==1.26.19 +urllib3==2.7.0 vine==5.0.0 watchtower==3.0.0 webencodings==0.5.1 wrapt==1.14.1 -zipp==3.19.1 \ No newline at end of file +zipp==3.19.1 diff --git a/vince/forms.py b/vince/forms.py index 5f4ddba..f5749f2 100644 --- a/vince/forms.py +++ b/vince/forms.py @@ -287,13 +287,13 @@ def __init__(self, *args, **kwargs): if self.case.team_owner.groupsettings.vulnote_template: if self.case.get_assigned_to: self.fields["content"].initial = ( - f"{self.case.team_owner.groupsettings.vulnote_template}This document was written by {self.case.get_assigned_to}.\r\n" + f"{self.case.team_owner.groupsettings.vulnote_template}\r\n\r\nThis document was written by {self.case.get_assigned_to}.\r\n" ) else: self.fields["content"].initial = f"{self.case.team_owner.groupsettings.vulnote_template}.\r\n" elif self.case.get_assigned_to: self.fields["content"].initial = ( - f"{VULNOTE_TEMPLATE}This document was written by {self.case.get_assigned_to}.\r\n" + f"{VULNOTE_TEMPLATE}\r\n\r\nThis document was written by {self.case.get_assigned_to}.\r\n" ) @@ -3203,3 +3203,68 @@ class CVEFilterForm(forms.Form): ) vince = forms.BooleanField(required=False, label="Search VINCE CVEs") + + +class SSVCAssessmentForm(forms.Form): + """ + Form for SSVC Coordinator Triage Assessment. + """ + + report_public = forms.MultipleChoiceField( + label="Is a viable report of the vulnerability already publicly available?", + choices=[('Y', 'Yes'), ('N', 'No')], + widget=forms.CheckboxSelectMultiple(attrs={"class": "ul_nobullet"}), + required=False + ) + + supplier_contacted = forms.MultipleChoiceField( + label="Has the reporter made a good-faith effort to contact the supplier?", + choices=[('Y', 'Yes'), ('N', 'No')], + widget=forms.CheckboxSelectMultiple(attrs={"class": "ul_nobullet"}), + required=False + ) + + report_credibility = forms.MultipleChoiceField( + label="Is the report credible?", + choices=[('C', 'Credible'), ('NC', 'Not Credible')], + widget=forms.CheckboxSelectMultiple(attrs={"class": "ul_nobullet"}), + required=False + ) + + supplier_cardinality = forms.MultipleChoiceField( + label="How many suppliers are responsible for the vulnerable component?", + choices=[('O', 'One'), ('M', 'Multiple')], + widget=forms.CheckboxSelectMultiple(attrs={"class": "ul_nobullet"}), + required=False + ) + + supplier_engagement = forms.MultipleChoiceField( + label="Is the supplier responding and actively participating?", + choices=[('A', 'Active'), ('U', 'Unresponsive')], + widget=forms.CheckboxSelectMultiple(attrs={"class": "ul_nobullet"}), + required=False + ) + + utility = forms.MultipleChoiceField( + label="What is the usefulness of the exploit to the adversary?", + choices=[ + ('L', 'Laborious'), + ('E', 'Efficient'), + ('S', 'Super Effective') + ], + widget=forms.CheckboxSelectMultiple(attrs={"class": "ul_nobullet"}), + required=False + ) + + public_safety_impact = forms.MultipleChoiceField( + label="What is the impact to public safety?", + choices=[('M', 'Minimal'), ('S', 'Significant')], + widget=forms.CheckboxSelectMultiple(attrs={"class": "ul_nobullet"}), + required=False + ) + + notes = forms.CharField( + label="Assessment Notes (optional)", + widget=forms.Textarea(attrs={'rows': 4, 'class': 'form-control'}), + required=False + ) diff --git a/vince/lib.py b/vince/lib.py index 3b4ecce..83ec2e6 100644 --- a/vince/lib.py +++ b/vince/lib.py @@ -2294,14 +2294,22 @@ def generate_hmac(key, message, b64=True): key = key.encode("utf-8") if isinstance(key, str) else key message = message.encode("utf-8") if isinstance(message, str) else message + + # Show what we're hashing (but truncate key for security - just show length and first/last few chars) + logger.debug(f"generate_hmac: key length = {len(key)}, key preview = {key[:4]!r}...{key[-4:]!r}") + logger.debug(f"generate_hmac: message length = {len(message)}, message = {message!r}") + hash_algorithm = hashlib.sha256 # Create HMAC object hmac_obj = hmac.new(key, message, hash_algorithm) hmac_digest = hmac_obj.digest() if b64: - return base64.b64encode(hmac_digest).decode("utf-8") + result = base64.b64encode(hmac_digest).decode("utf-8") + logger.debug(f"generate_hmac result (base64): {result}") + return result + logger.debug(f"generate_hmac result (raw bytes): {hmac_digest!r}") return hmac_digest @@ -2334,8 +2342,22 @@ def compute_authenticity_header(msg, key, headers, b64=True): auth_str = "|".join(parts) logger.debug(f"Authenticity header string: {auth_str}") + # Show exact bytes of auth_str + logger.debug(f"Authenticity header string bytes: {auth_str.encode('utf-8')!r}") + logger.debug(f"Authenticity header string length: {len(auth_str)}, bytes length: {len(auth_str.encode('utf-8'))}") + + value = generate_hmac(key=key, message=auth_str, b64=b64) + # Show computed HMAC in base64 for easy comparison + if not b64: + value_b64 = base64.b64encode(value).decode('utf-8') + logger.debug(f"Computed HMAC (raw bytes): {value!r}") + logger.debug(f"Computed HMAC (base64): {value_b64}") + else: + logger.debug(f"Computed HMAC (base64): {value}") + + return value @@ -2358,17 +2380,31 @@ def verify_authenticity_header(msg, key, headers): if header in msg: b64value = msg.get(header) + # Show what we received + logger.debug(f"Received X-Cert-Auth (base64): {b64value}") + value = base64.b64decode(b64value.encode("utf-8")) + + # Show received value in raw bytes + logger.debug(f"Received X-Cert-Auth (decoded bytes): {value!r}") + # note: the hmac docs recommend using compare_digest to avoid timing attacks # that can be a concern if we were to use: # return msg[header] == expected_value match = hmac.compare_digest(value, expected_value) if not match: + # Show both values in multiple formats for comparison + expected_b64 = base64.b64encode(expected_value).decode('utf-8') logger.warn( - f"match did not happen correctly. expected_value is {expected_value} and actual value is {value}" + f"HMAC mismatch. " + f"Expected (bytes): {expected_value!r}, " + f"Expected (base64): {expected_b64}, " + f"Actual (bytes): {value!r}, " + f"Actual (base64): {b64value}" ) return match + logger.warn(f"X-Cert-Auth header not found in message") return False @@ -2378,6 +2414,9 @@ def get_decoded_header(message, header_key): if not raw_value: return "" # Return empty string if header is missing + # Show raw value with repr() to see hidden characters + logger.debug(f"get_decoded_header({header_key}): raw_value = {repr(raw_value)}") + decoded_parts = decode_header(raw_value) decoded_value = "" @@ -2387,6 +2426,10 @@ def get_decoded_header(message, header_key): else: decoded_value += part + # Show decoded value and its byte representation + logger.debug(f"get_decoded_header({header_key}): decoded_value = {repr(decoded_value)}, bytes = {decoded_value.encode('utf-8')!r}") + + return decoded_value @@ -2399,14 +2442,37 @@ def create_ticket_from_email(filename, body, bucket): from_email = email_header_decode_helper(b["From"]) if from_email: outbound_email_address = get_parameter("OUTBOUND_EMAIL_ADDRESS") + + # Security check before trusted-forwarder comparison: + # Reject if the decoded from_email matches OUTBOUND_EMAIL_ADDRESS but the raw + # From header had no literal @ outside of RFC 2047 encoded-word segments. + # This prevents RFC 2047 encoded-word attacks where an attacker sends + # From: =?utf-8?Q?vincecert=40cert.org?= which decodes to vincecert@cert.org + # and bypasses DMARC checks while matching the OUTBOUND_EMAIL_ADDRESS. + if from_email == outbound_email_address: + raw_from = b["From"] + if raw_from: + # Check if @ appears in the raw From header outside of encoded-word segments + # Encoded-words have format: =?charset?encoding?encoded-text?= + encoded_word_pattern = r'=\?[^?]+\?[BbQq]\?[^?]*\?=' + raw_from_without_encoded_words = re.sub(encoded_word_pattern, '', raw_from) + has_literal_at_in_raw = '@' in raw_from_without_encoded_words + + if not has_literal_at_in_raw: + # The @ only appears in encoded form - reject as spoofing attempt + logger.warning( + f"Rejecting email that matches OUTBOUND_EMAIL_ADDRESS but has no literal @ in raw From header. " + f"This indicates a potential RFC 2047 encoded-word spoofing attack. " + f"Raw From: {raw_from}, Decoded: {from_email}, S3 filename: {filename}" + ) + return + if from_email == outbound_email_address: # obtain the secret key (in the AWS parameter store) auth_key = get_parameter("AUTHENTICITY_KEY") # use the secret key and the specific attributes to verify the HMAC string in the `X-Cert-Auth` header auth_hdrs = ["X-Original-Message-ID", "From", "X-Original-From", "X-Cert-Index", "X-Date-Received"] auth_checks_out = verify_authenticity_header(b, auth_key, auth_hdrs) - # temporarily ignoring authenticity checks: - auth_checks_out = True if auth_checks_out: # true if auth checks out logger.debug(f"in create_ticket_from_email, auth checks out with b = {b}") try: @@ -3989,3 +4055,180 @@ def reset_user_mfa(attributes, body): ) fup.save() + + +# SSVC Coordinator Triage Decision Calculator +# Based on SSVC v2 Coordinator Triage v1.0.0 + +class SSVCCalculator: + """ + Calculator for SSVC Coordinator Triage decisions. + + Loads the decision table from JSON and provides lookup functionality + based on the 7 decision point values using VINCE's short codes. + """ + + def __init__(self): + """Initialize the calculator and load the decision table.""" + self._decision_table = None + self._load_decision_table() + + def _load_decision_table(self): + """Load the decision table from JSON file.""" + from pathlib import Path + + # Path to the JSON file in static/vince/data/ + json_path = Path(__file__).parent / "static" / "vince" / "data" / "ssvc_coordinator_triage_1_0_0.json" + + if not json_path.exists(): + raise FileNotFoundError( + f"SSVC decision table not found at {json_path}" + ) + + with open(json_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + # Build a dictionary for fast lookup + # Key: tuple of 7 decision values + # Value: outcome + self._decision_table = {} + for decision in data['decisions']: + key = ( + decision['rp'], + decision['sc'], + decision['rc'], + decision['scard'], + decision['se'], + decision['u'], + decision['psi'] + ) + self._decision_table[key] = decision['outcome'] + + def calculate_outcome( + self, + report_public, + supplier_contacted, + report_credibility, + supplier_cardinality, + supplier_engagement, + utility, + public_safety_impact, + ): + """ + Calculate SSVC coordinator triage outcome. + + Args: + report_public: 'Y' (Yes) or 'N' (No) + supplier_contacted: 'Y' (Yes) or 'N' (No) + report_credibility: 'C' (Credible) or 'NC' (Not Credible) + supplier_cardinality: 'O' (One) or 'M' (Multiple) + supplier_engagement: 'A' (Active) or 'U' (Unresponsive) + utility: 'L' (Laborious), 'E' (Efficient), or 'S' (Super Effective) + public_safety_impact: 'M' (Minimal) or 'S' (Significant) + + Returns: + Outcome code: 'D' (Decline), 'T' (Track), or 'C' (Coordinate) + + Raises: + ValueError: If invalid codes provided or combination not in decision table + """ + # Validate inputs + valid_codes = { + 'report_public': ['Y', 'N'], + 'supplier_contacted': ['Y', 'N'], + 'report_credibility': ['C', 'NC'], + 'supplier_cardinality': ['O', 'M'], + 'supplier_engagement': ['A', 'U'], + 'utility': ['L', 'E', 'S'], + 'public_safety_impact': ['M', 'S'], + } + + inputs = { + 'report_public': report_public, + 'supplier_contacted': supplier_contacted, + 'report_credibility': report_credibility, + 'supplier_cardinality': supplier_cardinality, + 'supplier_engagement': supplier_engagement, + 'utility': utility, + 'public_safety_impact': public_safety_impact, + } + + for field_name, value in inputs.items(): + if value not in valid_codes[field_name]: + raise ValueError( + f"Invalid value '{value}' for {field_name}. " + f"Expected one of: {valid_codes[field_name]}" + ) + + # Build lookup key + key = ( + report_public, + supplier_contacted, + report_credibility, + supplier_cardinality, + supplier_engagement, + utility, + public_safety_impact, + ) + + # Look up outcome + outcome = self._decision_table.get(key) + if outcome is None: + raise ValueError( + f"No outcome found in decision table for: {key}" + ) + + return outcome + + +# Singleton instance for SSVC calculator +_ssvc_calculator_instance = None + + +def get_ssvc_calculator(): + """ + Get or create the singleton SSVCCalculator instance. + + Returns: + SSVCCalculator: The calculator instance + """ + global _ssvc_calculator_instance + if _ssvc_calculator_instance is None: + _ssvc_calculator_instance = SSVCCalculator() + return _ssvc_calculator_instance + + +def calculate_ssvc_outcome( + report_public, + supplier_contacted, + report_credibility, + supplier_cardinality, + supplier_engagement, + utility, + public_safety_impact, +): + """ + Convenience function to calculate SSVC coordinator triage outcome. + + Args: + report_public: 'Y' (Yes) or 'N' (No) + supplier_contacted: 'Y' (Yes) or 'N' (No) + report_credibility: 'C' (Credible) or 'NC' (Not Credible) + supplier_cardinality: 'O' (One) or 'M' (Multiple) + supplier_engagement: 'A' (Active) or 'U' (Unresponsive) + utility: 'L' (Laborious), 'E' (Efficient), or 'S' (Super Effective) + public_safety_impact: 'M' (Minimal) or 'S' (Significant) + + Returns: + Outcome code: 'D' (Decline), 'T' (Track), or 'C' (Coordinate) + """ + calculator = get_ssvc_calculator() + return calculator.calculate_outcome( + report_public=report_public, + supplier_contacted=supplier_contacted, + report_credibility=report_credibility, + supplier_cardinality=supplier_cardinality, + supplier_engagement=supplier_engagement, + utility=utility, + public_safety_impact=public_safety_impact, + ) diff --git a/vince/migrations/0114_ssvcassessment.py b/vince/migrations/0114_ssvcassessment.py new file mode 100644 index 0000000..1770c23 --- /dev/null +++ b/vince/migrations/0114_ssvcassessment.py @@ -0,0 +1,89 @@ +# Generated manually + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('vince', '0113_add_api_submission_type'), + ] + + operations = [ + migrations.CreateModel( + name='SSVCAssessment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('assessed_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('report_public', models.CharField( + choices=[('Y', 'Yes'), ('N', 'No')], + max_length=2, + verbose_name='Report Public' + )), + ('supplier_contacted', models.CharField( + choices=[('Y', 'Yes'), ('N', 'No')], + max_length=2, + verbose_name='Supplier Contacted' + )), + ('report_credibility', models.CharField( + choices=[('C', 'Credible'), ('NC', 'Not Credible')], + max_length=2, + verbose_name='Report Credibility' + )), + ('supplier_cardinality', models.CharField( + choices=[('O', 'One'), ('M', 'Multiple')], + max_length=2, + verbose_name='Supplier Cardinality' + )), + ('supplier_engagement', models.CharField( + choices=[('A', 'Active'), ('U', 'Unresponsive')], + max_length=2, + verbose_name='Supplier Engagement' + )), + ('utility', models.CharField( + choices=[ + ('L', 'Laborious'), + ('E', 'Efficient'), + ('S', 'Super Effective') + ], + max_length=2, + verbose_name='Utility' + )), + ('public_safety_impact', models.CharField( + choices=[('M', 'Minimal'), ('S', 'Significant')], + max_length=2, + verbose_name='Public Safety Impact' + )), + ('outcome', models.CharField( + choices=[ + ('D', 'Decline'), + ('T', 'Track'), + ('C', 'Coordinate') + ], + max_length=2, + verbose_name='Recommended Action' + )), + ('ssvc_json', models.JSONField(blank=True, null=True)), + ('notes', models.TextField(blank=True)), + ('assessed_by', models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL + )), + ('case_request', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='ssvc_assessments', + to='vince.Ticket' + )), + ], + options={ + 'verbose_name': 'SSVC Assessment', + 'verbose_name_plural': 'SSVC Assessments', + 'ordering': ['-assessed_at'], + }, + ), + ] diff --git a/vince/static/vince/data/ssvc_coordinator_triage_1_0_0.json b/vince/static/vince/data/ssvc_coordinator_triage_1_0_0.json new file mode 100644 index 0000000..152d1f3 --- /dev/null +++ b/vince/static/vince/data/ssvc_coordinator_triage_1_0_0.json @@ -0,0 +1,210 @@ +{ + "version": "1.0.0", + "name": "SSVC Coordinator Triage Decision Table", + "description": "Decision table mapping 7 decision point values to coordinator triage outcomes (Decline, Track, Coordinate)", + "source": "CERT/CC SSVC coordinator_triage_1_0_0.csv", + "last_updated": "2026-04-23", + "decision_points": [ + "report_public", + "supplier_contacted", + "report_credibility", + "supplier_cardinality", + "supplier_engagement", + "utility", + "public_safety_impact" + ], + "decisions": [ + {"rp": "Y", "sc": "N", "rc": "NC", "scard": "O", "se": "A", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "NC", "scard": "O", "se": "A", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "NC", "scard": "O", "se": "A", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "C", "scard": "O", "se": "A", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "NC", "scard": "M", "se": "A", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "NC", "scard": "O", "se": "U", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "NC", "scard": "O", "se": "A", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "NC", "scard": "O", "se": "A", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "NC", "scard": "O", "se": "A", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "C", "scard": "O", "se": "A", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "C", "scard": "O", "se": "A", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "NC", "scard": "M", "se": "A", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "NC", "scard": "M", "se": "A", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "C", "scard": "M", "se": "A", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "NC", "scard": "O", "se": "U", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "NC", "scard": "O", "se": "U", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "C", "scard": "O", "se": "U", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "NC", "scard": "M", "se": "U", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "NC", "scard": "O", "se": "A", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "NC", "scard": "O", "se": "A", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "C", "scard": "O", "se": "A", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "NC", "scard": "M", "se": "A", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "NC", "scard": "O", "se": "U", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "NC", "scard": "O", "se": "A", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "NC", "scard": "O", "se": "A", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "NC", "scard": "O", "se": "A", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "C", "scard": "O", "se": "A", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "NC", "scard": "M", "se": "A", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "NC", "scard": "O", "se": "U", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "NC", "scard": "O", "se": "A", "u": "E", "psi": "S", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "C", "scard": "O", "se": "A", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "NC", "scard": "M", "se": "A", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "C", "scard": "M", "se": "A", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "C", "scard": "M", "se": "A", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "NC", "scard": "O", "se": "U", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "C", "scard": "O", "se": "U", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "C", "scard": "O", "se": "U", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "NC", "scard": "M", "se": "U", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "NC", "scard": "M", "se": "U", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "C", "scard": "M", "se": "U", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "NC", "scard": "O", "se": "A", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "C", "scard": "O", "se": "A", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "C", "scard": "O", "se": "A", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "NC", "scard": "M", "se": "A", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "NC", "scard": "M", "se": "A", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "C", "scard": "M", "se": "A", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "NC", "scard": "O", "se": "U", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "NC", "scard": "O", "se": "U", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "C", "scard": "O", "se": "U", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "NC", "scard": "M", "se": "U", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "NC", "scard": "O", "se": "A", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "NC", "scard": "O", "se": "A", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "C", "scard": "O", "se": "A", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "NC", "scard": "M", "se": "A", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "NC", "scard": "O", "se": "U", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "NC", "scard": "O", "se": "A", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "C", "scard": "O", "se": "A", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "C", "scard": "O", "se": "A", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "NC", "scard": "M", "se": "A", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "NC", "scard": "M", "se": "A", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "C", "scard": "M", "se": "A", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "NC", "scard": "O", "se": "U", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "NC", "scard": "O", "se": "U", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "C", "scard": "O", "se": "U", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "NC", "scard": "M", "se": "U", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "NC", "scard": "O", "se": "A", "u": "E", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "NC", "scard": "O", "se": "A", "u": "E", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "C", "scard": "O", "se": "A", "u": "E", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "NC", "scard": "M", "se": "A", "u": "E", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "NC", "scard": "O", "se": "U", "u": "E", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "NC", "scard": "O", "se": "A", "u": "S", "psi": "S", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "C", "scard": "M", "se": "A", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "C", "scard": "O", "se": "U", "u": "L", "psi": "M", "outcome": "T"}, + {"rp": "N", "sc": "Y", "rc": "NC", "scard": "M", "se": "U", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "C", "scard": "M", "se": "U", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "C", "scard": "M", "se": "U", "u": "L", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "C", "scard": "O", "se": "A", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "NC", "scard": "M", "se": "A", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "C", "scard": "M", "se": "A", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "C", "scard": "M", "se": "A", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "NC", "scard": "O", "se": "U", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "C", "scard": "O", "se": "U", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "C", "scard": "O", "se": "U", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "NC", "scard": "M", "se": "U", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "NC", "scard": "M", "se": "U", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "C", "scard": "M", "se": "U", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "NC", "scard": "O", "se": "A", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "C", "scard": "O", "se": "A", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "C", "scard": "O", "se": "A", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "NC", "scard": "M", "se": "A", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "NC", "scard": "M", "se": "A", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "C", "scard": "M", "se": "A", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "NC", "scard": "O", "se": "U", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "NC", "scard": "O", "se": "U", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "C", "scard": "O", "se": "U", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "NC", "scard": "M", "se": "U", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "C", "scard": "O", "se": "A", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "NC", "scard": "M", "se": "A", "u": "L", "psi": "S", "outcome": "T"}, + {"rp": "N", "sc": "N", "rc": "C", "scard": "M", "se": "A", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "C", "scard": "M", "se": "A", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "NC", "scard": "O", "se": "U", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "C", "scard": "O", "se": "U", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "C", "scard": "O", "se": "U", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "NC", "scard": "M", "se": "U", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "NC", "scard": "M", "se": "U", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "C", "scard": "M", "se": "U", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "NC", "scard": "O", "se": "A", "u": "E", "psi": "S", "outcome": "T"}, + {"rp": "N", "sc": "N", "rc": "C", "scard": "O", "se": "A", "u": "E", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "C", "scard": "O", "se": "A", "u": "E", "psi": "S", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "NC", "scard": "M", "se": "A", "u": "E", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "NC", "scard": "M", "se": "A", "u": "E", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "C", "scard": "M", "se": "A", "u": "E", "psi": "S", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "NC", "scard": "O", "se": "U", "u": "E", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "NC", "scard": "O", "se": "U", "u": "E", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "C", "scard": "O", "se": "U", "u": "E", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "NC", "scard": "M", "se": "U", "u": "E", "psi": "S", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "NC", "scard": "O", "se": "A", "u": "S", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "NC", "scard": "O", "se": "A", "u": "S", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "C", "scard": "O", "se": "A", "u": "S", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "NC", "scard": "M", "se": "A", "u": "S", "psi": "S", "outcome": "C"}, + {"rp": "Y", "sc": "N", "rc": "NC", "scard": "O", "se": "U", "u": "S", "psi": "S", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "C", "scard": "M", "se": "U", "u": "L", "psi": "M", "outcome": "C"}, + {"rp": "N", "sc": "Y", "rc": "C", "scard": "M", "se": "A", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "C", "scard": "O", "se": "U", "u": "E", "psi": "M", "outcome": "C"}, + {"rp": "N", "sc": "Y", "rc": "NC", "scard": "M", "se": "U", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "C", "scard": "M", "se": "U", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "C", "scard": "M", "se": "U", "u": "E", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "C", "scard": "O", "se": "A", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "NC", "scard": "M", "se": "A", "u": "S", "psi": "M", "outcome": "T"}, + {"rp": "N", "sc": "N", "rc": "C", "scard": "M", "se": "A", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "C", "scard": "M", "se": "A", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "NC", "scard": "O", "se": "U", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "C", "scard": "O", "se": "U", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "C", "scard": "O", "se": "U", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "NC", "scard": "M", "se": "U", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "NC", "scard": "M", "se": "U", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "C", "scard": "M", "se": "U", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "C", "scard": "M", "se": "A", "u": "L", "psi": "S", "outcome": "T"}, + {"rp": "N", "sc": "Y", "rc": "C", "scard": "O", "se": "U", "u": "L", "psi": "S", "outcome": "C"}, + {"rp": "N", "sc": "Y", "rc": "NC", "scard": "M", "se": "U", "u": "L", "psi": "S", "outcome": "T"}, + {"rp": "N", "sc": "N", "rc": "C", "scard": "M", "se": "U", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "C", "scard": "M", "se": "U", "u": "L", "psi": "S", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "C", "scard": "O", "se": "A", "u": "E", "psi": "S", "outcome": "T"}, + {"rp": "N", "sc": "Y", "rc": "NC", "scard": "M", "se": "A", "u": "E", "psi": "S", "outcome": "T"}, + {"rp": "N", "sc": "N", "rc": "C", "scard": "M", "se": "A", "u": "E", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "C", "scard": "M", "se": "A", "u": "E", "psi": "S", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "NC", "scard": "O", "se": "U", "u": "E", "psi": "S", "outcome": "T"}, + {"rp": "N", "sc": "N", "rc": "C", "scard": "O", "se": "U", "u": "E", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "C", "scard": "O", "se": "U", "u": "E", "psi": "S", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "NC", "scard": "M", "se": "U", "u": "E", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "NC", "scard": "M", "se": "U", "u": "E", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "C", "scard": "M", "se": "U", "u": "E", "psi": "S", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "NC", "scard": "O", "se": "A", "u": "S", "psi": "S", "outcome": "T"}, + {"rp": "N", "sc": "N", "rc": "C", "scard": "O", "se": "A", "u": "S", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "C", "scard": "O", "se": "A", "u": "S", "psi": "S", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "NC", "scard": "M", "se": "A", "u": "S", "psi": "S", "outcome": "C"}, + {"rp": "Y", "sc": "Y", "rc": "NC", "scard": "M", "se": "A", "u": "S", "psi": "S", "outcome": "C"}, + {"rp": "Y", "sc": "N", "rc": "C", "scard": "M", "se": "A", "u": "S", "psi": "S", "outcome": "C"}, + {"rp": "N", "sc": "N", "rc": "NC", "scard": "O", "se": "U", "u": "S", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "NC", "scard": "O", "se": "U", "u": "S", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "C", "scard": "O", "se": "U", "u": "S", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "N", "rc": "NC", "scard": "M", "se": "U", "u": "S", "psi": "S", "outcome": "C"}, + {"rp": "N", "sc": "Y", "rc": "C", "scard": "M", "se": "U", "u": "E", "psi": "M", "outcome": "C"}, + {"rp": "N", "sc": "Y", "rc": "C", "scard": "M", "se": "A", "u": "S", "psi": "M", "outcome": "C"}, + {"rp": "N", "sc": "Y", "rc": "C", "scard": "O", "se": "U", "u": "S", "psi": "M", "outcome": "C"}, + {"rp": "N", "sc": "Y", "rc": "NC", "scard": "M", "se": "U", "u": "S", "psi": "M", "outcome": "T"}, + {"rp": "N", "sc": "N", "rc": "C", "scard": "M", "se": "U", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "C", "scard": "M", "se": "U", "u": "S", "psi": "M", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "C", "scard": "M", "se": "U", "u": "L", "psi": "S", "outcome": "C"}, + {"rp": "N", "sc": "Y", "rc": "C", "scard": "M", "se": "A", "u": "E", "psi": "S", "outcome": "T"}, + {"rp": "N", "sc": "Y", "rc": "C", "scard": "O", "se": "U", "u": "E", "psi": "S", "outcome": "C"}, + {"rp": "N", "sc": "Y", "rc": "NC", "scard": "M", "se": "U", "u": "E", "psi": "S", "outcome": "T"}, + {"rp": "N", "sc": "N", "rc": "C", "scard": "M", "se": "U", "u": "E", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "C", "scard": "M", "se": "U", "u": "E", "psi": "S", "outcome": "D"}, + {"rp": "N", "sc": "Y", "rc": "C", "scard": "O", "se": "A", "u": "S", "psi": "S", "outcome": "T"}, + {"rp": "N", "sc": "Y", "rc": "NC", "scard": "M", "se": "A", "u": "S", "psi": "S", "outcome": "C"}, + {"rp": "N", "sc": "N", "rc": "C", "scard": "M", "se": "A", "u": "S", "psi": "S", "outcome": "C"}, + {"rp": "Y", "sc": "Y", "rc": "C", "scard": "M", "se": "A", "u": "S", "psi": "S", "outcome": "C"}, + {"rp": "N", "sc": "Y", "rc": "NC", "scard": "O", "se": "U", "u": "S", "psi": "S", "outcome": "T"}, + {"rp": "N", "sc": "N", "rc": "C", "scard": "O", "se": "U", "u": "S", "psi": "S", "outcome": "D"}, + {"rp": "Y", "sc": "Y", "rc": "C", "scard": "O", "se": "U", "u": "S", "psi": "S", "outcome": "D"}, + {"rp": "N", "sc": "N", "rc": "NC", "scard": "M", "se": "U", "u": "S", "psi": "S", "outcome": "C"}, + {"rp": "Y", "sc": "Y", "rc": "NC", "scard": "M", "se": "U", "u": "S", "psi": "S", "outcome": "C"}, + {"rp": "Y", "sc": "N", "rc": "C", "scard": "M", "se": "U", "u": "S", "psi": "S", "outcome": "C"}, + {"rp": "N", "sc": "Y", "rc": "C", "scard": "M", "se": "U", "u": "S", "psi": "M", "outcome": "C"}, + {"rp": "N", "sc": "Y", "rc": "C", "scard": "M", "se": "U", "u": "E", "psi": "S", "outcome": "C"}, + {"rp": "N", "sc": "Y", "rc": "C", "scard": "M", "se": "A", "u": "S", "psi": "S", "outcome": "C"}, + {"rp": "N", "sc": "Y", "rc": "C", "scard": "O", "se": "U", "u": "S", "psi": "S", "outcome": "C"}, + {"rp": "N", "sc": "Y", "rc": "NC", "scard": "M", "se": "U", "u": "S", "psi": "S", "outcome": "C"}, + {"rp": "N", "sc": "N", "rc": "C", "scard": "M", "se": "U", "u": "S", "psi": "S", "outcome": "C"}, + {"rp": "Y", "sc": "Y", "rc": "C", "scard": "M", "se": "U", "u": "S", "psi": "S", "outcome": "C"}, + {"rp": "N", "sc": "Y", "rc": "C", "scard": "M", "se": "U", "u": "S", "psi": "S", "outcome": "C"} + ] +} diff --git a/vince/templates/vince/group.html b/vince/templates/vince/group.html index 038aead..2fe4e38 100644 --- a/vince/templates/vince/group.html +++ b/vince/templates/vince/group.html @@ -119,6 +119,11 @@

{{ c.case.get_title }}

Last updated {{ c.case.modified|naturaltime }}
+ {% if c.case.due_date %} +
Due Date {{ c.case.due_date|naturaltime }}
+ {% else %} +
Due Date TBD
+ {% endif %}
{% autoescape off %}{{ c.case.get_status_html }}{% endautoescape %} diff --git a/vince/templates/vince/include/changelog.html b/vince/templates/vince/include/changelog.html index 52f1379..bccdb48 100644 --- a/vince/templates/vince/include/changelog.html +++ b/vince/templates/vince/include/changelog.html @@ -18,7 +18,7 @@
Vul Note pending approval. Refer to the following ticket(s):
{% for tkt in approvaltickets %} - {{ tkt.ticket_for_url }} {% if tkt.review_type == "tech_review" %}Technical review{% elif tkt.review_type == "prepublish_review" %}Publication approval {% endif %}{% if tkt.reviewer %}{% if tkt.status != 4 %}{% if tkt.review_type == "prepublish_review" and tkt.assigned_to == vulnote.owner %}ticket returned to author {% else %}assigned to {% endif %}{% else %} completed by {% endif %}{{ tkt.reviewer.usersettings.vince_username }} {% else %}Unassigned{% endif %} {% autoescape off %} {{ tkt.get_status_html }} {% endautoescape %} {% if tkt.review.complete %}View Review {% endif %}
+ {{ tkt.ticket_for_url }} {% if tkt.review_type == "tech_review" %}Technical review{% elif tkt.review_type == "prepublish_review" %}Publication approval {% endif %}{% if tkt.reviewer %}{% if tkt.status != 4 %}{% if tkt.review_type == "prepublish_review" and tkt.assigned_to == vulnote.owner %}ticket returned to author {% else %} assigned to {% endif %}{% else %} completed by {% endif %}{{ tkt.reviewer.usersettings.vince_username }} {% else %}Unassigned{% endif %} {% autoescape off %} {{ tkt.get_status_html }} {% endautoescape %} {% if tkt.review %}View Review{% endif %}
{% endfor %}
{% endif %} diff --git a/vince/templates/vince/ssvc_assessment.html b/vince/templates/vince/ssvc_assessment.html new file mode 100644 index 0000000..1386f41 --- /dev/null +++ b/vince/templates/vince/ssvc_assessment.html @@ -0,0 +1,101 @@ +{% extends 'vince/base.html' %} +{% load i18n humanize %} +{% load staticfiles %} + +{% block content %} + +
+
+
+

SSVC Coordinator Triage Assessment

+

Case Request [{{ ticket.queue }}-{{ ticket.id }}]: {{ ticket.title }}

+
+
+
+ +
+
+
+

About SSVC

+

+ The Stakeholder-Specific Vulnerability Categorization (SSVC) helps coordinators + prioritize vulnerability response by answering 7 decision points. Based on your + answers, SSVC will recommend whether to Decline, Track, + or Coordinate this case request. +

+

+ Instructions: For each question below, check all answers that are + possible based on what you currently know. If you're uncertain between + multiple options, check all that apply. Only leave answers unchecked if you're + certain they are false. +

+
+ +
+ {% csrf_token %} + +
+ Decision Points + + {% for field in form %} +
+ + {% if field.help_text %} +

{{ field.help_text }}

+ {% endif %} + {{ field }} + {% if field.errors %} +
{{ field.errors }}
+ {% endif %} +
+ {% if not forloop.last %}
{% endif %} + {% endfor %} +
+ +
+ + + Cancel + +
+
+
+ +
+
+

Case Request Details

+
+
CR ID:
+
{{ ticket.queue }}-{{ ticket.id }}
+ +
Title:
+
{{ ticket.title }}
+ +
Status:
+
{{ ticket.get_status_display }}
+ +
Created:
+
{{ ticket.created|date:"Y-m-d H:i" }}
+
+
+ + {% if previous_assessments %} +
+

Previous Assessments

+

This CR has been assessed {{ previous_assessments.count }} time(s) before.

+
    + {% for assessment in previous_assessments %} +
  • + {{ assessment.assessed_at|date:"Y-m-d" }} by {{ assessment.assessed_by.get_username }} +
    Outcome: {{ assessment.get_outcome_display }} +
  • + {% endfor %} +
+
+ {% endif %} +
+
+ +{% endblock %} diff --git a/vince/ticket_update.py b/vince/ticket_update.py index e813f1b..1d08d88 100644 --- a/vince/ticket_update.py +++ b/vince/ticket_update.py @@ -101,7 +101,7 @@ def get_next_assignment(data): def auto_assignment(role, exclude=None): # get users for this role - users = UserAssignmentWeight.objects.filter(role__id=role) + users = UserAssignmentWeight.objects.filter(role__id=role, user__is_active=True) # are any of these users OOF today? oof_users = get_oof_users() diff --git a/vince/views.py b/vince/views.py index 84aa528..397ad0a 100644 --- a/vince/views.py +++ b/vince/views.py @@ -18856,3 +18856,135 @@ def error_400(request, exception): return render(request, f"{app_name}/400.html", data, status=400) return render(request, "vincepub/400.html", data, status=400) + + +class SSVCAssessmentView(LoginRequiredMixin, UserPassesTestMixin, FormView): + """ + SSVC Coordinator Triage Assessment for Case Requests. + """ + template_name = 'vince/ssvc_assessment.html' + form_class = SSVCAssessmentForm + login_url = "vince:login" + + def test_func(self): + return is_in_group_vincetrack(self.request.user) + + def dispatch(self, request, *args, **kwargs): + self.ticket_id = kwargs.get('ticket_id') + self.ticket = get_object_or_404(Ticket, id=self.ticket_id) + + # Verify this is a Case Request + if not (self.ticket.queue and self.ticket.queue.queue_type == TicketQueue.CASE_REQUEST_QUEUE): + raise Http404("SSVC assessment only available for Case Requests") + + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['ticket'] = self.ticket + + # Import SSVCAssessment here to avoid circular import + from vince.models import SSVCAssessment + context['previous_assessments'] = SSVCAssessment.objects.filter( + case_request=self.ticket + ) + return context + + def form_valid(self, form): + # Import SSVCAssessment here to avoid circular import + from vince.models import SSVCAssessment + import itertools + + # Calculate possible outcomes using SSVC decision table + possible_outcomes = self.calculate_possible_outcomes(form.cleaned_data) + + # Determine outcome to store + if len(possible_outcomes) == 1: + outcome = list(possible_outcomes)[0] + else: + # Multiple possible outcomes - prioritize Coordinate > Track > Decline + if 'C' in possible_outcomes: + outcome = 'C' + elif 'T' in possible_outcomes: + outcome = 'T' + else: + outcome = 'D' + + # For storage, use first selected value or empty string + data = form.cleaned_data + + # Create assessment record + assessment = SSVCAssessment( + case_request=self.ticket, + assessed_by=self.request.user, + report_public=data['report_public'][0] if data['report_public'] else '', + supplier_contacted=data['supplier_contacted'][0] if data['supplier_contacted'] else '', + report_credibility=data['report_credibility'][0] if data['report_credibility'] else '', + supplier_cardinality=data['supplier_cardinality'][0] if data['supplier_cardinality'] else '', + supplier_engagement=data['supplier_engagement'][0] if data['supplier_engagement'] else '', + utility=data['utility'][0] if data['utility'] else '', + public_safety_impact=data['public_safety_impact'][0] if data['public_safety_impact'] else '', + outcome=outcome, + notes=form.cleaned_data.get('notes', ''), + ssvc_json={ + 'selections': data, + 'possible_outcomes': sorted(list(possible_outcomes)) + } + ) + assessment.save() + + # Create message based on outcomes + if len(possible_outcomes) == 1: + message = f"SSVC Assessment completed. Recommendation: {assessment.get_outcome_display()}" + else: + outcome_labels = [dict(SSVCAssessment._meta.get_field('outcome').choices)[o] for o in sorted(possible_outcomes, reverse=True)] + message = f"SSVC Assessment completed. Possible outcomes: {', '.join(outcome_labels)}" + + messages.success(self.request, message) + + return HttpResponseRedirect(reverse('vince:cr', args=[self.ticket_id])) + + def calculate_possible_outcomes(self, data): + """ + Calculate all possible SSVC outcomes based on checkbox selections. + For each decision point, if nothing is checked, all values are possible. + Returns a set of possible outcome codes. + """ + from vince.lib import calculate_ssvc_outcome + import itertools + + # Get all selected values for each decision point + # If nothing selected, use all possible values + rp_values = data['report_public'] or ['Y', 'N'] + sc_values = data['supplier_contacted'] or ['Y', 'N'] + rc_values = data['report_credibility'] or ['C', 'NC'] + scard_values = data['supplier_cardinality'] or ['O', 'M'] + se_values = data['supplier_engagement'] or ['A', 'U'] + u_values = data['utility'] or ['L', 'E', 'S'] + psi_values = data['public_safety_impact'] or ['M', 'S'] + + # Generate all combinations + possible_outcomes = set() + for combo in itertools.product(rp_values, sc_values, rc_values, scard_values, se_values, u_values, psi_values): + try: + outcome = calculate_ssvc_outcome( + report_public=combo[0], + supplier_contacted=combo[1], + report_credibility=combo[2], + supplier_cardinality=combo[3], + supplier_engagement=combo[4], + utility=combo[5], + public_safety_impact=combo[6], + ) + possible_outcomes.add(outcome) + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.error(f"SSVC calculation error for combo {combo}: {str(e)}") + continue + + # If no outcomes calculated, default to Coordinate + if not possible_outcomes: + possible_outcomes.add('C') + + return possible_outcomes diff --git a/vincepub/templates/vincepub/security.txt b/vincepub/templates/vincepub/security.txt index 337a80d..289985f 100644 --- a/vincepub/templates/vincepub/security.txt +++ b/vincepub/templates/vincepub/security.txt @@ -6,5 +6,5 @@ Encryption: https://certcc.github.io/pgp/ Acknowledgments: https://kb.cert.org/vuls/ Preferred-Languages: en Canonical: https://certcc.github.io/.well-known/security.txt -Policy: https://certcc.github.io/CERT-Guide-to-CVD/reference/certcc_disclosure_policy/ +Policy: https://certcc.github.io/certcc_disclosure_policy/ Hiring: https://cmu.wd5.myworkdayjobs.com/SEI \ No newline at end of file diff --git a/vincepub/urls.py b/vincepub/urls.py index 8858f1b..740401d 100644 --- a/vincepub/urls.py +++ b/vincepub/urls.py @@ -28,7 +28,6 @@ ######################################################################## from django.urls import include, path, re_path from vincepub import views -from vincepub.views import env_debug_view from django.conf import settings from bakery.static_views import serve from rest_framework import routers @@ -108,6 +107,3 @@ # }, name="test"), # ] - - -urlpatterns += [path("debug-env/", env_debug_view)] diff --git a/vincepub/views.py b/vincepub/views.py index 8865731..1ebf343 100644 --- a/vincepub/views.py +++ b/vincepub/views.py @@ -483,10 +483,6 @@ def cvevuls(vuls): return vuls.filter(cve__isnull=False) -def env_debug_view(request): - return JsonResponse({"SECRET_KEY": os.environ.get("SECRET_KEY", "NOT FOUND")}) - - def estimate_count_fast(type): """postgres really sucks at full table counts, this is a faster version see: http://wiki.postgresql.org/wiki/Slow_Counting""" diff --git a/vinceworker/views.py b/vinceworker/views.py index a16f55a..5c5b589 100644 --- a/vinceworker/views.py +++ b/vinceworker/views.py @@ -373,6 +373,20 @@ def ingest_vulreport(request): return JsonResponse({"response": "success"}, status=200) data["submission_type"] = data.get("submission_source", "web") + # code for later, maybe, if we add a t_cr endpoint: + # if ( + # data.get("metadata") + # and data["metadata"].get("source") == "t_cr" + # ): + # tcrqueue = TicketQueue.objects.filter(title="TCR").first() + # if tcrqueue: + # data["queue"] = tcrqueue.id + # logger.debug(f"{log_tag} routing T report to TCR queue") + # else: + # # Fallback to CR if TCR queue doesn't exist yet + # data["queue"] = vulqueue.id + # logger.warning(f"{log_tag} TCR queue not found, falling back to CR queue") + # elif data.get("affected_website"): if data.get("affected_website"): data["request_type"] = CaseRequest.GOV_FORM data["queue"] = cisaqueue.id diff --git a/vinny/templates/vinny/case.html b/vinny/templates/vinny/case.html index 15df23a..b1a40e4 100644 --- a/vinny/templates/vinny/case.html +++ b/vinny/templates/vinny/case.html @@ -103,7 +103,7 @@

Status submitted for {{ checkvuls|length }} {% i {{ message }}
{% endfor %} {% else %} - We have identified {{ checkvuls|length }} {% if checkvuls|length > 1 %}vulnerabilities {% else %} vulnerability {% endif %}in this case. Your status was previously submitted on {% with s1=status|last_status %}{{ s1.date_modified|date:"c" }} by {{ s1.user.vinceprofile.preferred_username }}{% endwith %}. + {{ checkvuls|length }} {% if checkvuls|length > 1 %}vulnerabilities have {% else %} vulnerability has {% endif %}been reported in this case. Your status was previously submitted on {% with s1=status|last_status %}{{ s1.date_modified|date:"c" }} by {{ s1.user.vinceprofile.preferred_username }}{% endwith %}. {% endif %}

View vuls and update your status

@@ -114,7 +114,7 @@

View vuls and update yo

Action Required

{{ checkvuls|length }} {% if checkvuls|length > 1 %}Vulnerabilities{% else %}Vulnerability{% endif %}

-

We have identified {{ checkvuls|length }} {% if checkvuls|length > 1 %}vulnerabilities{% else %}vulnerability{% endif %} in this +

{{ checkvuls|length }} {% if checkvuls|length > 1 %}vulnerabilities have{% else %}vulnerability has{% endif %} been reported in this case. Please let us know if your organization is vulnerable.

View vulnerabilities and update status

@@ -160,8 +160,8 @@

Add a statement

-

{{ vuls|length }} {% if vuls|length == 1 %}Vulnerability{% else %}Vulnerabilities{% endif %} Identified

-

We have identified {{ vuls|length }} {% if vuls|length == 1 %}vulnerability{% else %}vulnerabilities{% endif %} in this case. +

{{ vuls|length }} {% if vuls|length == 1 %}Vulnerability{% else %}Vulnerabilities{% endif %} Reported

+

{{ vuls|length }} {% if vuls|length == 1 %}vulnerability has{% else %}vulnerabilities have{% endif %} been reported in this case.

View vulnerabilities

diff --git a/vinny/templates/vinny/cr_activity.html b/vinny/templates/vinny/cr_activity.html index 544e706..629934d 100644 --- a/vinny/templates/vinny/cr_activity.html +++ b/vinny/templates/vinny/cr_activity.html @@ -1,11 +1,11 @@ {% load i18n humanize post_tags %} -{% if report.crfollowup_set.all %} +{% if followups %}

{% trans "Activity" %}

- {% for followup in report.crfollowup_set.all %} + {% for followup in followups %}
{% autoescape off %} @@ -17,7 +17,13 @@

{% trans "Activity" %}

{% endautoescape %}
-

{{ followup.user.vinceprofile.vince_username }} {{ followup.title }} +

+ {% if followup.is_coordinator %} + Vulnerability Coordinator + {% else %} + {{ followup.user.vinceprofile.vince_username }} + {% endif %} + {{ followup.title }}

{% if followup.comment %}

{{ followup.comment|force_escape|urlizetrunc:50|linebreaksbr }}

diff --git a/vinny/templates/vinny/cr_report.html b/vinny/templates/vinny/cr_report.html index 0e59efb..54b6994 100644 --- a/vinny/templates/vinny/cr_report.html +++ b/vinny/templates/vinny/cr_report.html @@ -91,7 +91,14 @@

Files

{{ attachment.attachment.name }} {% endif %} -
Uploaded by {{ attachment.action.user.vinceprofile.vince_username }} on {{ attachment.action.date|date:'Y-m-d' }}
+
Uploaded by + {% if attachment.action.is_coordinator %} + Vulnerability Coordinator + {% else %} + {{ attachment.action.user.vinceprofile.vince_username }} + {% endif %} + on {{ attachment.action.date|date:'Y-m-d' }} +
{% empty %} @@ -128,7 +135,7 @@

Comment

- +
diff --git a/vinny/templatetags/user_tags.py b/vinny/templatetags/user_tags.py index 609f287..b586a07 100644 --- a/vinny/templatetags/user_tags.py +++ b/vinny/templatetags/user_tags.py @@ -69,5 +69,5 @@ def notify_emails(user, contact): else: return False return False - - + + diff --git a/vinny/urls.py b/vinny/urls.py index 3429865..749d3f3 100644 --- a/vinny/urls.py +++ b/vinny/urls.py @@ -174,8 +174,8 @@ path("reports/pub/", views.AdminReportsView.as_view(), name="adminreports"), path("reports/", views.ReportsView.as_view(), name="reports"), path("api/vendor/", views.VendorInfoAPIView.as_view(), name="vendor_api"), - # Make this endpoint go live when we complete 821: path("api/vulreport/", views.CommVulReportAPIView.as_view(), name="vul_report_api"), + # path("api/vulreport/t_cr/", views.TVulReportAPIView.as_view(), name="t_vul_report_api"), path("api/cases/", views.CasesAPIView.as_view(), name="cases_api"), re_path("api/case/(?P\d+)/$", views.CaseAPIView.as_view({"get": "retrieve"}), name="case_api"), re_path("api/case/posts/(?P\d+)/$", views.CasePostAPIView.as_view(), name="case_post_api"), diff --git a/vinny/utils.py b/vinny/utils.py index 6f8c2ed..8ce3230 100644 --- a/vinny/utils.py +++ b/vinny/utils.py @@ -31,6 +31,9 @@ #from pinax-messages from functools import wraps +import logging + +logger = logging.getLogger(__name__) def cached_attribute(func): diff --git a/vinny/views.py b/vinny/views.py index 268b530..bde6b01 100644 --- a/vinny/views.py +++ b/vinny/views.py @@ -4312,6 +4312,29 @@ def get_context_data(self, **kwargs): context["attachments"] = ReportAttachment.objects.filter(action__cr=context["report"]) if hasattr(context["report"], "case"): context["case_permission"] = _is_my_case(self.request.user, context["report"].case.id) + + # Mark coordinators for anonymous display to submitters BEFORE case creation + # Only anonymize Track coordinators, not regular users/submitters + # After case creation (when report.case exists), show real names + if context["report"]: + # Determine if we should anonymize: only before case creation + should_anonymize = not hasattr(context["report"], "case") + + # Get followups and mark which are from coordinators + followups = list(context["report"].crfollowup_set.all()) + for followup in followups: + # Mark as coordinator only if anonymizing and user is a track coordinator + followup.is_coordinator = should_anonymize and followup.user and is_in_group_vincetrack(followup.user) + context["followups"] = followups + + # Mark attachment actions from coordinators + for attachment in context["attachments"]: + if attachment.action and attachment.action.user: + # Mark as coordinator only if anonymizing and user is a track coordinator + attachment.action.is_coordinator = should_anonymize and is_in_group_vincetrack(attachment.action.user) + else: + attachment.action.is_coordinator = False + return context @@ -4577,7 +4600,7 @@ def has_permission(self, request, view): else: return True -# make this view go live when we complete work on the API vul report endpoint 821: + class CommVulReportAPIView(generics.GenericAPIView): throttle_classes = [UserRateThrottle] @@ -4603,6 +4626,7 @@ def post(self, request, *args, **kwargs): context["metadata"] = {"ai_ml_system": False} form.instance.vrf_id = vrf_id newrequest = form.save(commit=False) + newrequest.user = self.request.user newrequest.save() context["vrf_id"] = vrf_id if context["why_no_attempt"]: @@ -4724,6 +4748,368 @@ def post(self, request, *args, **kwargs): }, status=201) +class TVulReportAPIView(generics.GenericAPIView): + """ + T Vulnerability Report API endpoint (Extended fields version). + + This endpoint accepts vulnerability reports with extended metadata fields. + Reports are routed to the TCR queue for specialized handling. + + Key differences from standard CommVulReportAPIView: + - Accepts JSON POST body instead of form data + - Stores extended fields in the metadata JSON field + - Routes to TCR queue instead of CR queue + - Supports ZIP file attachment for bundling multiple files + """ + throttle_classes = [UserRateThrottle] + + def post(self, request, *args, **kwargs): + """ + Accept T vulnerability report submission. + + Expected JSON body structure: + { + // Standard VINCE fields (required) + "product_name": "...", + "product_version": "...", + "vul_description": "...", + "vul_exploit": "...", + "vul_impact": "...", + "vul_discovery": "...", + "vul_public": true/false, + "vul_exploited": true/false, + "vul_disclose": true/false, + "share_release": true/false, + "credit_release": true/false, + "comm_attempt": true/false, + "multiplevendors": true/false, + + // Optional standard fields + "contact_name": "...", + "contact_email": "...", + "contact_org": "...", + "vendor_name": "...", + "tracking": "...", + + // Extended T fields (stored in metadata) + "discovery_method_type": "ai_scan|manual|hybrid", + "cvss_score": 8.1, + "cvss_version": "3.1", + "cvss_vector": "CVSS:3.1/AV:N/AC:L/...", + "epss_score": 0.75, + "epss_date": "2026-06-20", + "embargo_requested": true, + "embargo_rationale": "...", + "ci_impact": true, + "ci_sectors": ["financial_services"], + "validation_status": "validated|partially_validated|unvalidated", + ... (see metadata schema docs) + } + + File upload: Attach as multipart/form-data "user_file" field. + For multiple files, submit as ZIP archive. + """ + try: + # Parse JSON body + if request.content_type == 'application/json': + data = json.loads(request.body.decode('utf-8')) + else: + # Fallback to POST data for form submissions + data = request.POST.dict() + + # Extract T metadata fields + t_metadata = self._extract_t_metadata(data) + + # Build form data from standard VINCE fields + form_data = self._build_form_data(data) + + # Handle file upload + files = request.FILES if request.FILES else {} + + # Create form and validate + form = CaseRequestForm(data=form_data, files=files) + + if not form.is_valid(): + return JsonResponse({ + "error": "Validation failed", + "errors": form.errors, + "status": "error" + }, status=400) + + # Record API access + create_record_of_API_access(self.request.build_absolute_uri(), self.request.user) + + # Generate VRF ID + # NOTE (JH concern): For bulk T submissions, consider using a separate + # seed/number range to avoid exhausting the standard VRF ID pool. + # Current implementation uses standard get_vrf_id() - may need modification + # for high-volume batch imports. + vrf_id = get_vrf_id() + + # Prepare context with form data + context = form.cleaned_data + + # Store T metadata with source marker + context["metadata"] = { + "source": "t_cr", + "ai_ml_system": t_metadata.get("ai_ml_system", False), + **t_metadata # Include all T-specific fields + } + + # Save the CaseRequest + form.instance.vrf_id = vrf_id + newrequest = form.save(commit=False) + newrequest.user = self.request.user + newrequest.submission_type = "api" + newrequest.save() + + # Prepare context for SNS and email + context["vrf_id"] = vrf_id + context["vrf_date_submitted"] = datetime.now(EST()).isoformat() + context["submission_type"] = "T Vulnerability Report" + context["submission_source"] = "api" + + # Get submission metadata + context["remote_addr"] = self.request.META.get("REMOTE_ADDR", "unknown") + context["remote_host"] = self.request.META.get("REMOTE_HOST", "unknown") + context["http_user_agent"] = self.request.META.get("HTTP_USER_AGENT", "unknown") + context["http_referer"] = self.request.META.get("HTTP_REFERER", "unknown") + + # Construct subject line + subject = f"[{settings.REPORT_IDENTIFIER}{vrf_id}] " + if context.get("product_name"): + subject += context["product_name"] + else: + subject += "New T Report Submission (No Title Provided)" + if context.get("tracking"): + subject += " [" + context["tracking"] + "]" + + context["title"] = subject + if len(subject) > 99: + subject = subject[:99] + + # Handle file upload to S3 + s3Client = boto3.client("s3", region_name=settings.AWS_REGION, config=Config(signature_version="s3v4")) + attachment = context.get("user_file") + + if attachment: + context["s3_file_name"] = newrequest.user_file.name + try: + # Copy file to VRF reports directory + rd = s3Client.copy_object( + CopySource=f"/{settings.PRIVATE_BUCKET_NAME}/{settings.AWS_PRIVATE_MEDIA_LOCATION}/{newrequest.user_file.name}", + Bucket=settings.VP_PRIVATE_BUCKET_NAME, + Key=settings.VRF_PRIVATE_MEDIA_LOCATION + "/" + newrequest.user_file.name, + Tagging=f"ID={vrf_id}&source=t_cr", + ) + logger.debug(f"T file uploaded to {settings.VRF_PRIVATE_MEDIA_LOCATION}, result: {rd}") + except Exception as e: + send_sns(vrf_id, "T API: tagging uploaded file", traceback.format_exc()) + logger.error(f"T file upload error: {e}") + + # Format dates for serialization + if context.get("first_contact"): + context["first_contact"] = str(context["first_contact"]) + + context["vrf_id"] = f"{settings.REPORT_IDENTIFIER}{vrf_id}" + + # Save report to S3 + try: + report_template = get_template("vincepub/email-md.txt") + fkey = f"{settings.VRF_REPORT_DIR}/{vrf_id}.txt" + s3Client.put_object( + Body=report_template.render(context=context), + Bucket=settings.VP_PRIVATE_BUCKET_NAME, + Key=fkey + ) + except Exception as e: + send_sns(vrf_id, "T API: writing report to S3 bucket", traceback.format_exc()) + logger.error(f"T S3 report save error: {e}") + + # Reset vrf_id for SNS message + context["vrf_id"] = vrf_id + if "user_file" in context: + context.pop("user_file") + + # CRITICAL: Route to TCR queue instead of default CR queue + # This is handled in vinceworker/views.py ingest_vulreport by checking + # metadata["source"] == "t_cr" and routing to TCR queue + context["queue_name"] = "TCR" # Signal to route to TCR queue + + # Send to SNS topic for processing by vinceworker + send_sns_json("vul", subject, json.dumps(context)) + + # Restore attachment for potential email + context["user_file"] = attachment + + # Send acknowledgment email if reporter provided email + # NOTE: For bulk T submissions, consider disabling auto-ack emails + reporter_email = context.get("contact_email") + if reporter_email: + try: + autoack_email_template = get_template(settings.ACK_EMAIL_TEMPLATE) + sesclient = boto3.client("ses", "us-east-1") + + response = sesclient.send_email( + Destination={"ToAddresses": [reporter_email]}, + Message={ + "Body": { + "Text": { + "Data": html.unescape(autoack_email_template.render(context=context)), + "Charset": "UTF-8", + } + }, + "Subject": { + "Charset": "UTF-8", + "Data": f"Thank you for submitting {settings.REPORT_IDENTIFIER}{vrf_id}", + }, + }, + Source=f"{settings.DEFAULT_VISIBLE_NAME} DONOTREPLY <{settings.DEFAULT_FROM_EMAIL}>", + ) + logger.debug(f"T ack email sent, Message ID: {response['MessageId']}") + except ClientError as e: + send_sns(vrf_id, "T API: Sending ack email", e.response["Error"]["Message"]) + logger.error(f"T ack email error: {e}") + except Exception as e: + send_sns(vrf_id, "T API: Sending ack email", traceback.format_exc()) + logger.error(f"T ack email error: {e}") + + # Log successful submission + logger.info(f"T report submitted successfully: VRF#{vrf_id}, user={self.request.user}, product={data.get('product_name')}") + + # Return success response + return JsonResponse({ + "message": "T vulnerability report submitted successfully", + "vrf_id": f"{settings.REPORT_IDENTIFIER}{vrf_id}", + "status": "success", + "queue": "TCR" + }, status=201) + + except json.JSONDecodeError as e: + logger.error(f"T API: Invalid JSON: {e}") + return JsonResponse({ + "error": "Invalid JSON in request body", + "details": str(e), + "status": "error" + }, status=400) + except Exception as e: + logger.error(f"T API: Unexpected error: {e}") + logger.error(traceback.format_exc()) + return JsonResponse({ + "error": "Internal server error processing T report", + "details": str(e), + "status": "error" + }, status=500) + + def _extract_t_metadata(self, data): + """ + Extract T-specific fields from request data and structure for metadata JSON. + + Returns dict of T metadata fields. + """ + t_fields = { + # Discovery fields + "discovery_method_type": data.get("discovery_method_type"), + "discovery_model_owner": data.get("discovery_model_owner"), + "discovery_model_name": data.get("discovery_model_name"), + "discovery_model_version": data.get("discovery_model_version"), + "discovery_date": data.get("discovery_date"), + + # Severity scoring + "cvss_score": data.get("cvss_score"), + "cvss_version": data.get("cvss_version"), + "cvss_vector": data.get("cvss_vector"), + "epss_score": data.get("epss_score"), + "epss_date": data.get("epss_date"), + + # Embargo + "embargo_requested": data.get("embargo_requested"), + "embargo_rationale": data.get("embargo_rationale"), + + # Critical Infrastructure + "ci_impact": data.get("ci_impact"), + "ci_sectors": data.get("ci_sectors"), # Expecting list + + # Validation + "validation_status": data.get("validation_status"), + "validation_evidence_summary": data.get("validation_evidence_summary"), + "reproduction_steps": data.get("reproduction_steps"), + "network_reachable": data.get("network_reachable"), + "network_reachable_explanation": data.get("network_reachable_explanation"), + "exploitability_assessed": data.get("exploitability_assessed"), + "exploitability_evidence": data.get("exploitability_evidence"), + + # Mitigation + "mitigation_summary": data.get("mitigation_summary"), + "workaround_available": data.get("workaround_available"), + "workaround_steps": data.get("workaround_steps"), + "config_mitigation": data.get("config_mitigation"), + "fixed_version": data.get("fixed_version"), + "recommended_version": data.get("recommended_version"), + + # File references (if multiple files submitted as ZIP) + "attached_files": data.get("attached_files"), # Dict or list of filenames + } + + # Remove None values to keep metadata clean + return {k: v for k, v in t_fields.items() if v is not None} + + def _build_form_data(self, data): + """ + Extract standard VINCE fields from request data for CaseRequestForm. + + Returns dict suitable for CaseRequestForm initialization. + """ + form_data = { + # Required fields + "product_name": data.get("product_name", ""), + "product_version": data.get("product_version", ""), + "vul_description": data.get("vul_description", ""), + "vul_exploit": data.get("vul_exploit", ""), + "vul_impact": data.get("vul_impact", ""), + "vul_discovery": data.get("vul_discovery", ""), + "vul_public": data.get("vul_public", False), + "vul_exploited": data.get("vul_exploited", False), + "vul_disclose": data.get("vul_disclose", False), + "share_release": data.get("share_release", True), + "credit_release": data.get("credit_release", True), + "comm_attempt": data.get("comm_attempt", False), + "multiplevendors": data.get("multiplevendors", False), + + # Optional contact fields + "contact_name": data.get("contact_name", ""), + "contact_org": data.get("contact_org", ""), + "contact_email": data.get("contact_email", ""), + "contact_phone": data.get("contact_phone", ""), + + # Optional vendor fields + "vendor_name": data.get("vendor_name", ""), + "other_vendors": data.get("other_vendors", ""), + "vendor_communication": data.get("vendor_communication", ""), + + # Optional vuln fields + "public_references": data.get("public_references", ""), + "exploit_references": data.get("exploit_references", ""), + "disclosure_plans": data.get("disclosure_plans", ""), + + # Optional meta fields + "tracking": data.get("tracking", ""), + "comments": data.get("comments", ""), + "ics_impact": data.get("ics_impact", False), + "ai_ml_system": data.get("ai_ml_system", False), + + # Optional coordination fields + "why_no_attempt": data.get("why_no_attempt", ""), + "please_explain": data.get("please_explain", ""), + } + + # Handle date field if provided + if data.get("first_contact"): + form_data["first_contact"] = data.get("first_contact") + + return form_data + + class CasesAPIView(generics.ListAPIView): # authentication_classes = (JSONWebTokenAuthentication,) serializer_class = CaseSerializer From d43ab4b9250df91bb20736de3bd5c598cdc58355 Mon Sep 17 00:00:00 2001 From: Greg Strom Date: Mon, 29 Jun 2026 11:30:11 -0400 Subject: [PATCH 2/2] commented out TCR API endpoint view --- vinny/views.py | 720 ++++++++++++++++++++++++------------------------- 1 file changed, 360 insertions(+), 360 deletions(-) diff --git a/vinny/views.py b/vinny/views.py index bde6b01..8419ab4 100644 --- a/vinny/views.py +++ b/vinny/views.py @@ -4748,366 +4748,366 @@ def post(self, request, *args, **kwargs): }, status=201) -class TVulReportAPIView(generics.GenericAPIView): - """ - T Vulnerability Report API endpoint (Extended fields version). - - This endpoint accepts vulnerability reports with extended metadata fields. - Reports are routed to the TCR queue for specialized handling. - - Key differences from standard CommVulReportAPIView: - - Accepts JSON POST body instead of form data - - Stores extended fields in the metadata JSON field - - Routes to TCR queue instead of CR queue - - Supports ZIP file attachment for bundling multiple files - """ - throttle_classes = [UserRateThrottle] - - def post(self, request, *args, **kwargs): - """ - Accept T vulnerability report submission. - - Expected JSON body structure: - { - // Standard VINCE fields (required) - "product_name": "...", - "product_version": "...", - "vul_description": "...", - "vul_exploit": "...", - "vul_impact": "...", - "vul_discovery": "...", - "vul_public": true/false, - "vul_exploited": true/false, - "vul_disclose": true/false, - "share_release": true/false, - "credit_release": true/false, - "comm_attempt": true/false, - "multiplevendors": true/false, - - // Optional standard fields - "contact_name": "...", - "contact_email": "...", - "contact_org": "...", - "vendor_name": "...", - "tracking": "...", - - // Extended T fields (stored in metadata) - "discovery_method_type": "ai_scan|manual|hybrid", - "cvss_score": 8.1, - "cvss_version": "3.1", - "cvss_vector": "CVSS:3.1/AV:N/AC:L/...", - "epss_score": 0.75, - "epss_date": "2026-06-20", - "embargo_requested": true, - "embargo_rationale": "...", - "ci_impact": true, - "ci_sectors": ["financial_services"], - "validation_status": "validated|partially_validated|unvalidated", - ... (see metadata schema docs) - } - - File upload: Attach as multipart/form-data "user_file" field. - For multiple files, submit as ZIP archive. - """ - try: - # Parse JSON body - if request.content_type == 'application/json': - data = json.loads(request.body.decode('utf-8')) - else: - # Fallback to POST data for form submissions - data = request.POST.dict() - - # Extract T metadata fields - t_metadata = self._extract_t_metadata(data) - - # Build form data from standard VINCE fields - form_data = self._build_form_data(data) - - # Handle file upload - files = request.FILES if request.FILES else {} - - # Create form and validate - form = CaseRequestForm(data=form_data, files=files) - - if not form.is_valid(): - return JsonResponse({ - "error": "Validation failed", - "errors": form.errors, - "status": "error" - }, status=400) - - # Record API access - create_record_of_API_access(self.request.build_absolute_uri(), self.request.user) - - # Generate VRF ID - # NOTE (JH concern): For bulk T submissions, consider using a separate - # seed/number range to avoid exhausting the standard VRF ID pool. - # Current implementation uses standard get_vrf_id() - may need modification - # for high-volume batch imports. - vrf_id = get_vrf_id() - - # Prepare context with form data - context = form.cleaned_data - - # Store T metadata with source marker - context["metadata"] = { - "source": "t_cr", - "ai_ml_system": t_metadata.get("ai_ml_system", False), - **t_metadata # Include all T-specific fields - } - - # Save the CaseRequest - form.instance.vrf_id = vrf_id - newrequest = form.save(commit=False) - newrequest.user = self.request.user - newrequest.submission_type = "api" - newrequest.save() - - # Prepare context for SNS and email - context["vrf_id"] = vrf_id - context["vrf_date_submitted"] = datetime.now(EST()).isoformat() - context["submission_type"] = "T Vulnerability Report" - context["submission_source"] = "api" - - # Get submission metadata - context["remote_addr"] = self.request.META.get("REMOTE_ADDR", "unknown") - context["remote_host"] = self.request.META.get("REMOTE_HOST", "unknown") - context["http_user_agent"] = self.request.META.get("HTTP_USER_AGENT", "unknown") - context["http_referer"] = self.request.META.get("HTTP_REFERER", "unknown") - - # Construct subject line - subject = f"[{settings.REPORT_IDENTIFIER}{vrf_id}] " - if context.get("product_name"): - subject += context["product_name"] - else: - subject += "New T Report Submission (No Title Provided)" - if context.get("tracking"): - subject += " [" + context["tracking"] + "]" - - context["title"] = subject - if len(subject) > 99: - subject = subject[:99] - - # Handle file upload to S3 - s3Client = boto3.client("s3", region_name=settings.AWS_REGION, config=Config(signature_version="s3v4")) - attachment = context.get("user_file") - - if attachment: - context["s3_file_name"] = newrequest.user_file.name - try: - # Copy file to VRF reports directory - rd = s3Client.copy_object( - CopySource=f"/{settings.PRIVATE_BUCKET_NAME}/{settings.AWS_PRIVATE_MEDIA_LOCATION}/{newrequest.user_file.name}", - Bucket=settings.VP_PRIVATE_BUCKET_NAME, - Key=settings.VRF_PRIVATE_MEDIA_LOCATION + "/" + newrequest.user_file.name, - Tagging=f"ID={vrf_id}&source=t_cr", - ) - logger.debug(f"T file uploaded to {settings.VRF_PRIVATE_MEDIA_LOCATION}, result: {rd}") - except Exception as e: - send_sns(vrf_id, "T API: tagging uploaded file", traceback.format_exc()) - logger.error(f"T file upload error: {e}") - - # Format dates for serialization - if context.get("first_contact"): - context["first_contact"] = str(context["first_contact"]) - - context["vrf_id"] = f"{settings.REPORT_IDENTIFIER}{vrf_id}" - - # Save report to S3 - try: - report_template = get_template("vincepub/email-md.txt") - fkey = f"{settings.VRF_REPORT_DIR}/{vrf_id}.txt" - s3Client.put_object( - Body=report_template.render(context=context), - Bucket=settings.VP_PRIVATE_BUCKET_NAME, - Key=fkey - ) - except Exception as e: - send_sns(vrf_id, "T API: writing report to S3 bucket", traceback.format_exc()) - logger.error(f"T S3 report save error: {e}") - - # Reset vrf_id for SNS message - context["vrf_id"] = vrf_id - if "user_file" in context: - context.pop("user_file") - - # CRITICAL: Route to TCR queue instead of default CR queue - # This is handled in vinceworker/views.py ingest_vulreport by checking - # metadata["source"] == "t_cr" and routing to TCR queue - context["queue_name"] = "TCR" # Signal to route to TCR queue - - # Send to SNS topic for processing by vinceworker - send_sns_json("vul", subject, json.dumps(context)) - - # Restore attachment for potential email - context["user_file"] = attachment - - # Send acknowledgment email if reporter provided email - # NOTE: For bulk T submissions, consider disabling auto-ack emails - reporter_email = context.get("contact_email") - if reporter_email: - try: - autoack_email_template = get_template(settings.ACK_EMAIL_TEMPLATE) - sesclient = boto3.client("ses", "us-east-1") - - response = sesclient.send_email( - Destination={"ToAddresses": [reporter_email]}, - Message={ - "Body": { - "Text": { - "Data": html.unescape(autoack_email_template.render(context=context)), - "Charset": "UTF-8", - } - }, - "Subject": { - "Charset": "UTF-8", - "Data": f"Thank you for submitting {settings.REPORT_IDENTIFIER}{vrf_id}", - }, - }, - Source=f"{settings.DEFAULT_VISIBLE_NAME} DONOTREPLY <{settings.DEFAULT_FROM_EMAIL}>", - ) - logger.debug(f"T ack email sent, Message ID: {response['MessageId']}") - except ClientError as e: - send_sns(vrf_id, "T API: Sending ack email", e.response["Error"]["Message"]) - logger.error(f"T ack email error: {e}") - except Exception as e: - send_sns(vrf_id, "T API: Sending ack email", traceback.format_exc()) - logger.error(f"T ack email error: {e}") - - # Log successful submission - logger.info(f"T report submitted successfully: VRF#{vrf_id}, user={self.request.user}, product={data.get('product_name')}") - - # Return success response - return JsonResponse({ - "message": "T vulnerability report submitted successfully", - "vrf_id": f"{settings.REPORT_IDENTIFIER}{vrf_id}", - "status": "success", - "queue": "TCR" - }, status=201) - - except json.JSONDecodeError as e: - logger.error(f"T API: Invalid JSON: {e}") - return JsonResponse({ - "error": "Invalid JSON in request body", - "details": str(e), - "status": "error" - }, status=400) - except Exception as e: - logger.error(f"T API: Unexpected error: {e}") - logger.error(traceback.format_exc()) - return JsonResponse({ - "error": "Internal server error processing T report", - "details": str(e), - "status": "error" - }, status=500) - - def _extract_t_metadata(self, data): - """ - Extract T-specific fields from request data and structure for metadata JSON. - - Returns dict of T metadata fields. - """ - t_fields = { - # Discovery fields - "discovery_method_type": data.get("discovery_method_type"), - "discovery_model_owner": data.get("discovery_model_owner"), - "discovery_model_name": data.get("discovery_model_name"), - "discovery_model_version": data.get("discovery_model_version"), - "discovery_date": data.get("discovery_date"), - - # Severity scoring - "cvss_score": data.get("cvss_score"), - "cvss_version": data.get("cvss_version"), - "cvss_vector": data.get("cvss_vector"), - "epss_score": data.get("epss_score"), - "epss_date": data.get("epss_date"), - - # Embargo - "embargo_requested": data.get("embargo_requested"), - "embargo_rationale": data.get("embargo_rationale"), - - # Critical Infrastructure - "ci_impact": data.get("ci_impact"), - "ci_sectors": data.get("ci_sectors"), # Expecting list - - # Validation - "validation_status": data.get("validation_status"), - "validation_evidence_summary": data.get("validation_evidence_summary"), - "reproduction_steps": data.get("reproduction_steps"), - "network_reachable": data.get("network_reachable"), - "network_reachable_explanation": data.get("network_reachable_explanation"), - "exploitability_assessed": data.get("exploitability_assessed"), - "exploitability_evidence": data.get("exploitability_evidence"), - - # Mitigation - "mitigation_summary": data.get("mitigation_summary"), - "workaround_available": data.get("workaround_available"), - "workaround_steps": data.get("workaround_steps"), - "config_mitigation": data.get("config_mitigation"), - "fixed_version": data.get("fixed_version"), - "recommended_version": data.get("recommended_version"), - - # File references (if multiple files submitted as ZIP) - "attached_files": data.get("attached_files"), # Dict or list of filenames - } - - # Remove None values to keep metadata clean - return {k: v for k, v in t_fields.items() if v is not None} - - def _build_form_data(self, data): - """ - Extract standard VINCE fields from request data for CaseRequestForm. - - Returns dict suitable for CaseRequestForm initialization. - """ - form_data = { - # Required fields - "product_name": data.get("product_name", ""), - "product_version": data.get("product_version", ""), - "vul_description": data.get("vul_description", ""), - "vul_exploit": data.get("vul_exploit", ""), - "vul_impact": data.get("vul_impact", ""), - "vul_discovery": data.get("vul_discovery", ""), - "vul_public": data.get("vul_public", False), - "vul_exploited": data.get("vul_exploited", False), - "vul_disclose": data.get("vul_disclose", False), - "share_release": data.get("share_release", True), - "credit_release": data.get("credit_release", True), - "comm_attempt": data.get("comm_attempt", False), - "multiplevendors": data.get("multiplevendors", False), - - # Optional contact fields - "contact_name": data.get("contact_name", ""), - "contact_org": data.get("contact_org", ""), - "contact_email": data.get("contact_email", ""), - "contact_phone": data.get("contact_phone", ""), - - # Optional vendor fields - "vendor_name": data.get("vendor_name", ""), - "other_vendors": data.get("other_vendors", ""), - "vendor_communication": data.get("vendor_communication", ""), - - # Optional vuln fields - "public_references": data.get("public_references", ""), - "exploit_references": data.get("exploit_references", ""), - "disclosure_plans": data.get("disclosure_plans", ""), - - # Optional meta fields - "tracking": data.get("tracking", ""), - "comments": data.get("comments", ""), - "ics_impact": data.get("ics_impact", False), - "ai_ml_system": data.get("ai_ml_system", False), - - # Optional coordination fields - "why_no_attempt": data.get("why_no_attempt", ""), - "please_explain": data.get("please_explain", ""), - } - - # Handle date field if provided - if data.get("first_contact"): - form_data["first_contact"] = data.get("first_contact") - - return form_data +# class TVulReportAPIView(generics.GenericAPIView): +# """ +# T Vulnerability Report API endpoint (Extended fields version). + +# This endpoint accepts vulnerability reports with extended metadata fields. +# Reports are routed to the TCR queue for specialized handling. + +# Key differences from standard CommVulReportAPIView: +# - Accepts JSON POST body instead of form data +# - Stores extended fields in the metadata JSON field +# - Routes to TCR queue instead of CR queue +# - Supports ZIP file attachment for bundling multiple files +# """ +# throttle_classes = [UserRateThrottle] + +# def post(self, request, *args, **kwargs): +# """ +# Accept T vulnerability report submission. + +# Expected JSON body structure: +# { +# // Standard VINCE fields (required) +# "product_name": "...", +# "product_version": "...", +# "vul_description": "...", +# "vul_exploit": "...", +# "vul_impact": "...", +# "vul_discovery": "...", +# "vul_public": true/false, +# "vul_exploited": true/false, +# "vul_disclose": true/false, +# "share_release": true/false, +# "credit_release": true/false, +# "comm_attempt": true/false, +# "multiplevendors": true/false, + +# // Optional standard fields +# "contact_name": "...", +# "contact_email": "...", +# "contact_org": "...", +# "vendor_name": "...", +# "tracking": "...", + +# // Extended T fields (stored in metadata) +# "discovery_method_type": "ai_scan|manual|hybrid", +# "cvss_score": 8.1, +# "cvss_version": "3.1", +# "cvss_vector": "CVSS:3.1/AV:N/AC:L/...", +# "epss_score": 0.75, +# "epss_date": "2026-06-20", +# "embargo_requested": true, +# "embargo_rationale": "...", +# "ci_impact": true, +# "ci_sectors": ["financial_services"], +# "validation_status": "validated|partially_validated|unvalidated", +# ... (see metadata schema docs) +# } + +# File upload: Attach as multipart/form-data "user_file" field. +# For multiple files, submit as ZIP archive. +# """ +# try: +# # Parse JSON body +# if request.content_type == 'application/json': +# data = json.loads(request.body.decode('utf-8')) +# else: +# # Fallback to POST data for form submissions +# data = request.POST.dict() + +# # Extract T metadata fields +# t_metadata = self._extract_t_metadata(data) + +# # Build form data from standard VINCE fields +# form_data = self._build_form_data(data) + +# # Handle file upload +# files = request.FILES if request.FILES else {} + +# # Create form and validate +# form = CaseRequestForm(data=form_data, files=files) + +# if not form.is_valid(): +# return JsonResponse({ +# "error": "Validation failed", +# "errors": form.errors, +# "status": "error" +# }, status=400) + +# # Record API access +# create_record_of_API_access(self.request.build_absolute_uri(), self.request.user) + +# # Generate VRF ID +# # NOTE (JH concern): For bulk T submissions, consider using a separate +# # seed/number range to avoid exhausting the standard VRF ID pool. +# # Current implementation uses standard get_vrf_id() - may need modification +# # for high-volume batch imports. +# vrf_id = get_vrf_id() + +# # Prepare context with form data +# context = form.cleaned_data + +# # Store T metadata with source marker +# context["metadata"] = { +# "source": "t_cr", +# "ai_ml_system": t_metadata.get("ai_ml_system", False), +# **t_metadata # Include all T-specific fields +# } + +# # Save the CaseRequest +# form.instance.vrf_id = vrf_id +# newrequest = form.save(commit=False) +# newrequest.user = self.request.user +# newrequest.submission_type = "api" +# newrequest.save() + +# # Prepare context for SNS and email +# context["vrf_id"] = vrf_id +# context["vrf_date_submitted"] = datetime.now(EST()).isoformat() +# context["submission_type"] = "T Vulnerability Report" +# context["submission_source"] = "api" + +# # Get submission metadata +# context["remote_addr"] = self.request.META.get("REMOTE_ADDR", "unknown") +# context["remote_host"] = self.request.META.get("REMOTE_HOST", "unknown") +# context["http_user_agent"] = self.request.META.get("HTTP_USER_AGENT", "unknown") +# context["http_referer"] = self.request.META.get("HTTP_REFERER", "unknown") + +# # Construct subject line +# subject = f"[{settings.REPORT_IDENTIFIER}{vrf_id}] " +# if context.get("product_name"): +# subject += context["product_name"] +# else: +# subject += "New T Report Submission (No Title Provided)" +# if context.get("tracking"): +# subject += " [" + context["tracking"] + "]" + +# context["title"] = subject +# if len(subject) > 99: +# subject = subject[:99] + +# # Handle file upload to S3 +# s3Client = boto3.client("s3", region_name=settings.AWS_REGION, config=Config(signature_version="s3v4")) +# attachment = context.get("user_file") + +# if attachment: +# context["s3_file_name"] = newrequest.user_file.name +# try: +# # Copy file to VRF reports directory +# rd = s3Client.copy_object( +# CopySource=f"/{settings.PRIVATE_BUCKET_NAME}/{settings.AWS_PRIVATE_MEDIA_LOCATION}/{newrequest.user_file.name}", +# Bucket=settings.VP_PRIVATE_BUCKET_NAME, +# Key=settings.VRF_PRIVATE_MEDIA_LOCATION + "/" + newrequest.user_file.name, +# Tagging=f"ID={vrf_id}&source=t_cr", +# ) +# logger.debug(f"T file uploaded to {settings.VRF_PRIVATE_MEDIA_LOCATION}, result: {rd}") +# except Exception as e: +# send_sns(vrf_id, "T API: tagging uploaded file", traceback.format_exc()) +# logger.error(f"T file upload error: {e}") + +# # Format dates for serialization +# if context.get("first_contact"): +# context["first_contact"] = str(context["first_contact"]) + +# context["vrf_id"] = f"{settings.REPORT_IDENTIFIER}{vrf_id}" + +# # Save report to S3 +# try: +# report_template = get_template("vincepub/email-md.txt") +# fkey = f"{settings.VRF_REPORT_DIR}/{vrf_id}.txt" +# s3Client.put_object( +# Body=report_template.render(context=context), +# Bucket=settings.VP_PRIVATE_BUCKET_NAME, +# Key=fkey +# ) +# except Exception as e: +# send_sns(vrf_id, "T API: writing report to S3 bucket", traceback.format_exc()) +# logger.error(f"T S3 report save error: {e}") + +# # Reset vrf_id for SNS message +# context["vrf_id"] = vrf_id +# if "user_file" in context: +# context.pop("user_file") + +# # CRITICAL: Route to TCR queue instead of default CR queue +# # This is handled in vinceworker/views.py ingest_vulreport by checking +# # metadata["source"] == "t_cr" and routing to TCR queue +# context["queue_name"] = "TCR" # Signal to route to TCR queue + +# # Send to SNS topic for processing by vinceworker +# send_sns_json("vul", subject, json.dumps(context)) + +# # Restore attachment for potential email +# context["user_file"] = attachment + +# # Send acknowledgment email if reporter provided email +# # NOTE: For bulk T submissions, consider disabling auto-ack emails +# reporter_email = context.get("contact_email") +# if reporter_email: +# try: +# autoack_email_template = get_template(settings.ACK_EMAIL_TEMPLATE) +# sesclient = boto3.client("ses", "us-east-1") + +# response = sesclient.send_email( +# Destination={"ToAddresses": [reporter_email]}, +# Message={ +# "Body": { +# "Text": { +# "Data": html.unescape(autoack_email_template.render(context=context)), +# "Charset": "UTF-8", +# } +# }, +# "Subject": { +# "Charset": "UTF-8", +# "Data": f"Thank you for submitting {settings.REPORT_IDENTIFIER}{vrf_id}", +# }, +# }, +# Source=f"{settings.DEFAULT_VISIBLE_NAME} DONOTREPLY <{settings.DEFAULT_FROM_EMAIL}>", +# ) +# logger.debug(f"T ack email sent, Message ID: {response['MessageId']}") +# except ClientError as e: +# send_sns(vrf_id, "T API: Sending ack email", e.response["Error"]["Message"]) +# logger.error(f"T ack email error: {e}") +# except Exception as e: +# send_sns(vrf_id, "T API: Sending ack email", traceback.format_exc()) +# logger.error(f"T ack email error: {e}") + +# # Log successful submission +# logger.info(f"T report submitted successfully: VRF#{vrf_id}, user={self.request.user}, product={data.get('product_name')}") + +# # Return success response +# return JsonResponse({ +# "message": "T vulnerability report submitted successfully", +# "vrf_id": f"{settings.REPORT_IDENTIFIER}{vrf_id}", +# "status": "success", +# "queue": "TCR" +# }, status=201) + +# except json.JSONDecodeError as e: +# logger.error(f"T API: Invalid JSON: {e}") +# return JsonResponse({ +# "error": "Invalid JSON in request body", +# "details": str(e), +# "status": "error" +# }, status=400) +# except Exception as e: +# logger.error(f"T API: Unexpected error: {e}") +# logger.error(traceback.format_exc()) +# return JsonResponse({ +# "error": "Internal server error processing T report", +# "details": str(e), +# "status": "error" +# }, status=500) + +# def _extract_t_metadata(self, data): +# """ +# Extract T-specific fields from request data and structure for metadata JSON. + +# Returns dict of T metadata fields. +# """ +# t_fields = { +# # Discovery fields +# "discovery_method_type": data.get("discovery_method_type"), +# "discovery_model_owner": data.get("discovery_model_owner"), +# "discovery_model_name": data.get("discovery_model_name"), +# "discovery_model_version": data.get("discovery_model_version"), +# "discovery_date": data.get("discovery_date"), + +# # Severity scoring +# "cvss_score": data.get("cvss_score"), +# "cvss_version": data.get("cvss_version"), +# "cvss_vector": data.get("cvss_vector"), +# "epss_score": data.get("epss_score"), +# "epss_date": data.get("epss_date"), + +# # Embargo +# "embargo_requested": data.get("embargo_requested"), +# "embargo_rationale": data.get("embargo_rationale"), + +# # Critical Infrastructure +# "ci_impact": data.get("ci_impact"), +# "ci_sectors": data.get("ci_sectors"), # Expecting list + +# # Validation +# "validation_status": data.get("validation_status"), +# "validation_evidence_summary": data.get("validation_evidence_summary"), +# "reproduction_steps": data.get("reproduction_steps"), +# "network_reachable": data.get("network_reachable"), +# "network_reachable_explanation": data.get("network_reachable_explanation"), +# "exploitability_assessed": data.get("exploitability_assessed"), +# "exploitability_evidence": data.get("exploitability_evidence"), + +# # Mitigation +# "mitigation_summary": data.get("mitigation_summary"), +# "workaround_available": data.get("workaround_available"), +# "workaround_steps": data.get("workaround_steps"), +# "config_mitigation": data.get("config_mitigation"), +# "fixed_version": data.get("fixed_version"), +# "recommended_version": data.get("recommended_version"), + +# # File references (if multiple files submitted as ZIP) +# "attached_files": data.get("attached_files"), # Dict or list of filenames +# } + +# # Remove None values to keep metadata clean +# return {k: v for k, v in t_fields.items() if v is not None} + +# def _build_form_data(self, data): +# """ +# Extract standard VINCE fields from request data for CaseRequestForm. + +# Returns dict suitable for CaseRequestForm initialization. +# """ +# form_data = { +# # Required fields +# "product_name": data.get("product_name", ""), +# "product_version": data.get("product_version", ""), +# "vul_description": data.get("vul_description", ""), +# "vul_exploit": data.get("vul_exploit", ""), +# "vul_impact": data.get("vul_impact", ""), +# "vul_discovery": data.get("vul_discovery", ""), +# "vul_public": data.get("vul_public", False), +# "vul_exploited": data.get("vul_exploited", False), +# "vul_disclose": data.get("vul_disclose", False), +# "share_release": data.get("share_release", True), +# "credit_release": data.get("credit_release", True), +# "comm_attempt": data.get("comm_attempt", False), +# "multiplevendors": data.get("multiplevendors", False), + +# # Optional contact fields +# "contact_name": data.get("contact_name", ""), +# "contact_org": data.get("contact_org", ""), +# "contact_email": data.get("contact_email", ""), +# "contact_phone": data.get("contact_phone", ""), + +# # Optional vendor fields +# "vendor_name": data.get("vendor_name", ""), +# "other_vendors": data.get("other_vendors", ""), +# "vendor_communication": data.get("vendor_communication", ""), + +# # Optional vuln fields +# "public_references": data.get("public_references", ""), +# "exploit_references": data.get("exploit_references", ""), +# "disclosure_plans": data.get("disclosure_plans", ""), + +# # Optional meta fields +# "tracking": data.get("tracking", ""), +# "comments": data.get("comments", ""), +# "ics_impact": data.get("ics_impact", False), +# "ai_ml_system": data.get("ai_ml_system", False), + +# # Optional coordination fields +# "why_no_attempt": data.get("why_no_attempt", ""), +# "please_explain": data.get("please_explain", ""), +# } + +# # Handle date field if provided +# if data.get("first_contact"): +# form_data["first_contact"] = data.get("first_contact") + +# return form_data class CasesAPIView(generics.ListAPIView):