fix(jira): correct Data Sensitivity field ID and replicate Jira's real classification scheme
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled

The auto-create-ticket call was sending customfield_11233 (a field that
doesn't exist in the project) instead of the real Data Sensitivity field
customfield_11814, which made Jira reject the WHOLE issue and silently
block ticket creation for every new test.

Also replaces Aegis's invented 4-tier data_classification scheme
(public_release/general_use/confidential/restricted) with the 6 values
Jira's Data Sensitivity field actually uses (public/general_use/
internal_use_only/trusted_people/customer_content/pii), since that is
the classification the organization actually applies. Includes a data
migration remapping existing rows and a defensive retry if Jira ever
rejects an unscreened custom field again.
This commit is contained in:
kitos
2026-07-09 16:33:41 +02:00
parent 512b682cc5
commit d726e3adfe
12 changed files with 229 additions and 47 deletions
@@ -0,0 +1,47 @@
"""Rename data_classification values to match Jira's real Data Sensitivity field.
Jira's actual "Data Sensitivity" custom field (customfield_11814) has 6
options — Public, General Use, Internal Use Only, Trusted People, Customer
Content, PII — not the 4-tier scheme Aegis invented independently. This
migration replicates Jira's scheme so ticket sync stops guessing at a
mapping. Applies to tests, campaigns, and evidences tables (all plain
String(20) columns — no native enum type to alter).
public_release -> public, confidential -> internal_use_only,
restricted -> pii. general_use is unchanged.
Revision ID: b060
Revises: b059
Create Date: 2026-07-09
"""
from alembic import op
revision = "b060"
down_revision = "b059"
branch_labels = None
depends_on = None
_TABLES = ("tests", "campaigns", "evidences")
_RENAMES_UP = {
"public_release": "public",
"confidential": "internal_use_only",
"restricted": "pii",
}
_RENAMES_DOWN = {v: k for k, v in _RENAMES_UP.items()}
def upgrade() -> None:
for table in _TABLES:
for old, new in _RENAMES_UP.items():
op.execute(f"UPDATE {table} SET data_classification = '{new}' WHERE data_classification = '{old}'")
op.alter_column(table, "data_classification", server_default="internal_use_only")
def downgrade() -> None:
for table in _TABLES:
for new, old in _RENAMES_DOWN.items():
op.execute(f"UPDATE {table} SET data_classification = '{old}' WHERE data_classification = '{new}'")
op.alter_column(table, "data_classification", server_default="confidential")
+12 -7
View File
@@ -93,14 +93,19 @@ class AttackSuccessResult(str, enum.Enum):
class DataClassification(str, enum.Enum):
"""Data sensitivity classification levels for compliance and retention policies.
Matches the organization's data protection scheme:
- public_release: approved for external/public release
Matches Jira's "Data Sensitivity" field (customfield_11814) exactly, since
that is the organization's actual data protection scheme:
- public: approved for external/public release
- general_use: general internal information, no special restriction
- confidential: internal use only, or shared with trusted people
- restricted: trusted people, customer content, and/or PII
- internal_use_only: internal use only
- trusted_people: shared only with a limited/trusted audience
- customer_content: contains customer data
- pii: contains personally identifiable information
"""
public_release = "public_release"
public = "public"
general_use = "general_use"
confidential = "confidential"
restricted = "restricted"
internal_use_only = "internal_use_only"
trusted_people = "trusted_people"
customer_content = "customer_content"
pii = "pii"
+2 -2
View File
@@ -90,8 +90,8 @@ class Campaign(Base):
tags = Column(JSONB, nullable=True, default=[])
# Assign created_at = Column(DateTime(timezone=True), server_default=func.now())
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Assign data_classification = Column(String(20), nullable=False, server_default="internal")
data_classification = Column(String(20), nullable=False, server_default="confidential")
# Assign data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
# Recurring scheduling fields
is_recurring = Column(Boolean, default=False)
+2 -2
View File
@@ -50,8 +50,8 @@ class Evidence(Base):
team = Column(Enum(TeamSide, name="teamside"), nullable=False, default=TeamSide.red)
# Assign notes = Column(Text, nullable=True)
notes = Column(Text, nullable=True)
# Assign data_classification = Column(String(20), nullable=False, server_default="internal")
data_classification = Column(String(20), nullable=False, server_default="confidential")
# Assign data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
# Relationships
test = relationship("Test", back_populates="evidences")
+2 -2
View File
@@ -119,8 +119,8 @@ class Test(Base):
retest_of = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=True)
# Assign retest_count = Column(Integer, default=0)
retest_count = Column(Integer, default=0)
# Assign data_classification = Column(String(20), nullable=False, server_default="internal")
data_classification = Column(String(20), nullable=False, server_default="confidential")
# Assign data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
# ── Relationships ───────────────────────────────────────────────
technique = relationship("Technique", back_populates="tests")
+2 -2
View File
@@ -299,8 +299,8 @@ class TestOut(BaseModel):
retest_of: uuid.UUID | None = None
# Assign retest_count = 0
retest_count: int = 0
# Assign data_classification = "confidential"
data_classification: str = "confidential"
# Assign data_classification = "internal_use_only"
data_classification: str = "internal_use_only"
# Technique info (populated when joined)
technique_mitre_id: str | None = None
+23 -5
View File
@@ -79,7 +79,7 @@ JIRA_FIELD_ATTACK_SUCCESS = "customfield_11815"
JIRA_FIELD_ATTACK_DETECTED = "customfield_11809"
JIRA_FIELD_TIME_TO_DETECT = "customfield_11810"
JIRA_FIELD_TIME_TO_CONTAIN = "customfield_11811"
JIRA_FIELD_DATA_SENSITIVITY = "customfield_11233"
JIRA_FIELD_DATA_SENSITIVITY = "customfield_11814"
JIRA_FIELD_ATTACK_CONTAINED = "customfield_11885"
_ATTACK_SUCCESS_TO_JIRA = {
@@ -101,10 +101,12 @@ _CONTAINMENT_TO_JIRA = {
}
_DATA_CLASSIFICATION_TO_JIRA = {
"public_release": "Public Release",
"public": "Public",
"general_use": "General Use",
"confidential": "Confidential",
"restricted": "Restricted",
"internal_use_only": "Internal Use Only",
"trusted_people": "Trusted People",
"customer_content": "Customer Content",
"pii": "PII",
}
# Assign logger = logging.getLogger(__name__)
@@ -720,7 +722,23 @@ def auto_create_test_issue(
if parent:
fields["parent"] = {"key": parent}
result = jira.issue_create(fields=fields)
try:
result = jira.issue_create(fields=fields)
except Exception as exc:
# Jira rejects the whole issue when a custom field isn't on the
# project's create screen ("cannot be set ... not on the
# appropriate screen"). Don't let a screen-config gap on an
# optional field (data sensitivity) block ticket creation
# entirely — drop it and retry once.
if JIRA_FIELD_DATA_SENSITIVITY in fields and JIRA_FIELD_DATA_SENSITIVITY in str(exc):
logger.warning(
"Jira rejected %s (not on create screen); retrying without it for test %s",
JIRA_FIELD_DATA_SENSITIVITY, test.id,
)
fields.pop(JIRA_FIELD_DATA_SENSITIVITY)
result = jira.issue_create(fields=fields)
else:
raise
issue_key = result["key"]
issue_id = result.get("id", "")
+6 -6
View File
@@ -29,7 +29,7 @@ from app.utils import escape_like
# Tactics whose test data is more likely to touch customer content, PII, or
# high-impact operational detail — these get an initial classification of
# 'restricted' rather than the 'confidential' baseline. This is a starting
# 'pii' rather than the 'internal_use_only' baseline. This is a starting
# point only; the assigned operator or lead can always correct it.
_RESTRICTED_TACTICS = frozenset({
"exfiltration", "collection", "credential-access", "impact",
@@ -40,13 +40,13 @@ def determine_initial_classification(technique: Technique | None) -> str:
"""Best-effort initial data classification for a new test.
Every security test reveals internal attack/defense posture, so the
baseline is 'confidential' (internal use only) — never public or
general use by default. Escalated to 'restricted' when the technique's
tactic implies customer data, PII, or destructive impact.
baseline is 'internal_use_only' — never public or general use by
default. Escalated to 'pii' when the technique's tactic implies
customer data, PII, or destructive impact.
"""
if technique and technique.tactic and technique.tactic.lower() in _RESTRICTED_TACTICS:
return DataClassification.restricted.value
return DataClassification.confidential.value
return DataClassification.pii.value
return DataClassification.internal_use_only.value
def _build_test_query(
+17 -17
View File
@@ -34,7 +34,7 @@ def _seed_technique(db, tactic="execution") -> Technique:
return technique
def test_new_test_defaults_to_confidential_via_db_default(db, red_lead_user):
def test_new_test_defaults_to_internal_use_only_via_db_default(db, red_lead_user):
"""A Test() constructed without going through the create_test service
still gets a safe classification via the column's server_default."""
technique = _seed_technique(db)
@@ -46,7 +46,7 @@ def test_new_test_defaults_to_confidential_via_db_default(db, red_lead_user):
db.add(test)
db.commit()
db.refresh(test)
assert test.data_classification == "confidential"
assert test.data_classification == "internal_use_only"
def test_create_test_endpoint_uses_tactic_heuristic(client, db, api, auth_headers, technique):
@@ -57,8 +57,8 @@ def test_create_test_endpoint_uses_tactic_heuristic(client, db, api, auth_header
)
assert resp.status_code == 201, resp.text
# The shared `technique` fixture (see conftest/test_tests.py) doesn't set
# a restricted-tier tactic, so this should land on the baseline.
assert resp.json()["data_classification"] == "confidential"
# a pii-tier tactic, so this should land on the baseline.
assert resp.json()["data_classification"] == "internal_use_only"
def test_admin_can_update_classification(client, db, admin_user, admin_token, red_lead_user):
@@ -74,14 +74,14 @@ def test_admin_can_update_classification(client, db, admin_user, admin_token, re
response = client.patch(
f"/api/v1/tests/{test.id}/classification",
json={"data_classification": "restricted"},
json={"data_classification": "pii"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
assert response.json()["data_classification"] == "restricted"
assert response.json()["data_classification"] == "pii"
db.refresh(test)
assert test.data_classification == "restricted"
assert test.data_classification == "pii"
def test_operator_can_update_classification(client, db, admin_user, red_lead_token, red_lead_user):
@@ -130,7 +130,7 @@ def test_viewer_cannot_update_classification(client, db, admin_user, red_lead_us
response = client.patch(
f"/api/v1/tests/{test.id}/classification",
json={"data_classification": "restricted"},
json={"data_classification": "pii"},
headers={"Authorization": f"Bearer {viewer_token}"},
)
assert response.status_code == 403
@@ -142,15 +142,15 @@ def _technique_stub(tactic):
return t
def test_determine_initial_classification_defaults_to_confidential():
assert determine_initial_classification(_technique_stub("execution")) == "confidential"
assert determine_initial_classification(_technique_stub(None)) == "confidential"
assert determine_initial_classification(None) == "confidential"
def test_determine_initial_classification_defaults_to_internal_use_only():
assert determine_initial_classification(_technique_stub("execution")) == "internal_use_only"
assert determine_initial_classification(_technique_stub(None)) == "internal_use_only"
assert determine_initial_classification(None) == "internal_use_only"
def test_determine_initial_classification_escalates_for_sensitive_tactics():
assert determine_initial_classification(_technique_stub("exfiltration")) == "restricted"
assert determine_initial_classification(_technique_stub("collection")) == "restricted"
assert determine_initial_classification(_technique_stub("credential-access")) == "restricted"
assert determine_initial_classification(_technique_stub("impact")) == "restricted"
assert determine_initial_classification(_technique_stub("Exfiltration")) == "restricted"
assert determine_initial_classification(_technique_stub("exfiltration")) == "pii"
assert determine_initial_classification(_technique_stub("collection")) == "pii"
assert determine_initial_classification(_technique_stub("credential-access")) == "pii"
assert determine_initial_classification(_technique_stub("impact")) == "pii"
assert determine_initial_classification(_technique_stub("Exfiltration")) == "pii"
+104
View File
@@ -279,6 +279,110 @@ def test_search_jira_issues_maps_fields(mock_get_client):
mock_jira.jql.assert_called_once()
def _make_technique(**overrides):
from app.models.technique import Technique
tech = MagicMock(spec=Technique)
tech.mitre_id = "T1003.006"
tech.name = "DCSync"
tech.tactic = "credential-access"
tech.severity = "high"
for k, v in overrides.items():
setattr(tech, k, v)
return tech
def _make_test_for_issue(**overrides):
from app.models.test import Test
t = MagicMock(spec=Test)
t.id = uuid4()
t.name = "Run DSInternals Get-ADReplAccount"
t.technique_id = uuid4()
t.procedure_text = "Invoke-DSInternals"
t.description = "desc"
t.platform = "Windows"
t.tool_used = "DSInternals"
t.data_classification = "pii"
for k, v in overrides.items():
setattr(t, k, v)
return t
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_jira_project_key", return_value="SEC")
@patch("app.services.jira_service.get_jira_parent_ticket_standalone", return_value=None)
@patch("app.services.jira_service.get_admin_jira_client")
def test_auto_create_test_issue_retries_without_unscreened_data_sensitivity_field(
mock_get_client, mock_parent, mock_project_key, mock_configured, db
):
# Regression: Jira rejects the WHOLE issue when the data sensitivity
# field isn't on the project's create screen, which would silently
# block every test's ticket creation. Must retry once without that field
# instead of losing the ticket.
mock_jira = MagicMock()
mock_jira.issue_create.side_effect = [
Exception(
f"Field '{jira_service.JIRA_FIELD_DATA_SENSITIVITY}' cannot be set. "
"It is not on the appropriate screen, or unknown."
),
{"key": "SEC-42", "id": "10042"},
]
mock_get_client.return_value = mock_jira
from app.models.user import User
test = _make_test_for_issue()
technique = _make_technique()
actor = User(username="gerardo-ruiz", email="gerardo.ruiz@kaseya.com", hashed_password="x")
db.add(actor)
db.commit()
issue_key = jira_service.auto_create_test_issue(db, test, actor, technique=technique)
assert issue_key == "SEC-42"
assert mock_jira.issue_create.call_count == 2
# Both calls share the same `fields` dict object (mutated in place), so
# only the final state is inspectable here — confirming the retry
# dropped the field is what matters.
final_call_fields = mock_jira.issue_create.call_args_list[1].kwargs["fields"]
assert jira_service.JIRA_FIELD_DATA_SENSITIVITY not in final_call_fields
@pytest.mark.parametrize(
"data_classification,expected_jira_value",
[
("public", "Public"),
("general_use", "General Use"),
("internal_use_only", "Internal Use Only"),
("trusted_people", "Trusted People"),
("customer_content", "Customer Content"),
("pii", "PII"),
],
)
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_jira_project_key", return_value="SEC")
@patch("app.services.jira_service.get_jira_parent_ticket_standalone", return_value=None)
@patch("app.services.jira_service.get_admin_jira_client")
def test_auto_create_test_issue_maps_data_classification_to_real_jira_field(
mock_get_client, mock_parent, mock_project_key, mock_configured, db,
data_classification, expected_jira_value,
):
# Regression: the field ID was wrong (customfield_11233, which doesn't
# exist in the project) and the classification scheme didn't match
# Jira's actual 6-option "Data Sensitivity" field at all.
mock_jira = MagicMock()
mock_jira.issue_create.return_value = {"key": "SEC-1", "id": "1"}
mock_get_client.return_value = mock_jira
from app.models.user import User
test = _make_test_for_issue(data_classification=data_classification)
technique = _make_technique()
actor = User(username="gerardo-ruiz", email="gerardo.ruiz@kaseya.com", hashed_password="x")
db.add(actor)
db.commit()
jira_service.auto_create_test_issue(db, test, actor, technique=technique)
fields = mock_jira.issue_create.call_args.kwargs["fields"]
assert fields["customfield_11814"] == expected_jira_value
def _make_user(**overrides):
from app.models.user import User
u = MagicMock(spec=User)
@@ -5,10 +5,12 @@ import type { DataClassification } from "../../types/models";
// ── Options ────────────────────────────────────────────────────────
const CLASSIFICATION_OPTIONS: { value: DataClassification; label: string; color: string }[] = [
{ value: "public_release", label: "Public Release", color: "border-gray-500/30 bg-gray-800/50 text-gray-400" },
{ value: "public", label: "Public", color: "border-gray-500/30 bg-gray-800/50 text-gray-400" },
{ value: "general_use", label: "General Use", color: "border-blue-500/30 bg-blue-900/30 text-blue-400" },
{ value: "confidential", label: "Confidential", color: "border-amber-500/30 bg-amber-900/30 text-amber-400" },
{ value: "restricted", label: "Restricted", color: "border-red-500/30 bg-red-900/30 text-red-400" },
{ value: "internal_use_only", label: "Internal Use Only", color: "border-amber-500/30 bg-amber-900/30 text-amber-400" },
{ value: "trusted_people", label: "Trusted People", color: "border-orange-500/30 bg-orange-900/30 text-orange-400" },
{ value: "customer_content", label: "Customer Content", color: "border-pink-500/30 bg-pink-900/30 text-pink-400" },
{ value: "pii", label: "PII", color: "border-red-500/30 bg-red-900/30 text-red-400" },
];
// ── Props ──────────────────────────────────────────────────────────
+7 -1
View File
@@ -62,7 +62,13 @@ export type ContainmentResult = "contained" | "partially_contained" | "not_conta
export type AttackSuccessResult = "successful" | "not_successful" | "partially_successful";
export type DataClassification = "public_release" | "general_use" | "confidential" | "restricted";
export type DataClassification =
| "public"
| "general_use"
| "internal_use_only"
| "trusted_people"
| "customer_content"
| "pii";
export type ValidationStatus = "pending" | "approved" | "rejected";