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.
+
+
+
+
+
+
+
+
+
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
- {% trans "Coordinator/Reporter Comments" %}
+ {% trans "Add a comment to communicate with the reporter/coordination team" %}
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..8419ab4 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