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): class DataClassification(str, enum.Enum):
"""Data sensitivity classification levels for compliance and retention policies. """Data sensitivity classification levels for compliance and retention policies.
Matches the organization's data protection scheme: Matches Jira's "Data Sensitivity" field (customfield_11814) exactly, since
- public_release: approved for external/public release that is the organization's actual data protection scheme:
- public: approved for external/public release
- general_use: general internal information, no special restriction - general_use: general internal information, no special restriction
- confidential: internal use only, or shared with trusted people - internal_use_only: internal use only
- restricted: trusted people, customer content, and/or PII - 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" general_use = "general_use"
confidential = "confidential" internal_use_only = "internal_use_only"
restricted = "restricted" 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=[]) tags = Column(JSONB, nullable=True, default=[])
# Assign created_at = Column(DateTime(timezone=True), server_default=func.now()) # Assign created_at = Column(DateTime(timezone=True), server_default=func.now())
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") # Assign data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
data_classification = Column(String(20), nullable=False, server_default="confidential") data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
# Recurring scheduling fields # Recurring scheduling fields
is_recurring = Column(Boolean, default=False) 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) team = Column(Enum(TeamSide, name="teamside"), nullable=False, default=TeamSide.red)
# Assign notes = Column(Text, nullable=True) # Assign notes = Column(Text, nullable=True)
notes = Column(Text, nullable=True) notes = Column(Text, nullable=True)
# Assign data_classification = Column(String(20), nullable=False, server_default="internal") # Assign data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
data_classification = Column(String(20), nullable=False, server_default="confidential") data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
# Relationships # Relationships
test = relationship("Test", back_populates="evidences") 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) retest_of = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=True)
# Assign retest_count = Column(Integer, default=0) # Assign retest_count = Column(Integer, default=0)
retest_count = Column(Integer, default=0) retest_count = Column(Integer, default=0)
# Assign data_classification = Column(String(20), nullable=False, server_default="internal") # Assign data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
data_classification = Column(String(20), nullable=False, server_default="confidential") data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
# ── Relationships ─────────────────────────────────────────────── # ── Relationships ───────────────────────────────────────────────
technique = relationship("Technique", back_populates="tests") technique = relationship("Technique", back_populates="tests")
+2 -2
View File
@@ -299,8 +299,8 @@ class TestOut(BaseModel):
retest_of: uuid.UUID | None = None retest_of: uuid.UUID | None = None
# Assign retest_count = 0 # Assign retest_count = 0
retest_count: int = 0 retest_count: int = 0
# Assign data_classification = "confidential" # Assign data_classification = "internal_use_only"
data_classification: str = "confidential" data_classification: str = "internal_use_only"
# Technique info (populated when joined) # Technique info (populated when joined)
technique_mitre_id: str | None = None technique_mitre_id: str | None = None
+22 -4
View File
@@ -79,7 +79,7 @@ JIRA_FIELD_ATTACK_SUCCESS = "customfield_11815"
JIRA_FIELD_ATTACK_DETECTED = "customfield_11809" JIRA_FIELD_ATTACK_DETECTED = "customfield_11809"
JIRA_FIELD_TIME_TO_DETECT = "customfield_11810" JIRA_FIELD_TIME_TO_DETECT = "customfield_11810"
JIRA_FIELD_TIME_TO_CONTAIN = "customfield_11811" 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" JIRA_FIELD_ATTACK_CONTAINED = "customfield_11885"
_ATTACK_SUCCESS_TO_JIRA = { _ATTACK_SUCCESS_TO_JIRA = {
@@ -101,10 +101,12 @@ _CONTAINMENT_TO_JIRA = {
} }
_DATA_CLASSIFICATION_TO_JIRA = { _DATA_CLASSIFICATION_TO_JIRA = {
"public_release": "Public Release", "public": "Public",
"general_use": "General Use", "general_use": "General Use",
"confidential": "Confidential", "internal_use_only": "Internal Use Only",
"restricted": "Restricted", "trusted_people": "Trusted People",
"customer_content": "Customer Content",
"pii": "PII",
} }
# Assign logger = logging.getLogger(__name__) # Assign logger = logging.getLogger(__name__)
@@ -720,7 +722,23 @@ def auto_create_test_issue(
if parent: if parent:
fields["parent"] = {"key": parent} fields["parent"] = {"key": parent}
try:
result = jira.issue_create(fields=fields) 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_key = result["key"]
issue_id = result.get("id", "") 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 # Tactics whose test data is more likely to touch customer content, PII, or
# high-impact operational detail — these get an initial classification of # 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. # point only; the assigned operator or lead can always correct it.
_RESTRICTED_TACTICS = frozenset({ _RESTRICTED_TACTICS = frozenset({
"exfiltration", "collection", "credential-access", "impact", "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. """Best-effort initial data classification for a new test.
Every security test reveals internal attack/defense posture, so the Every security test reveals internal attack/defense posture, so the
baseline is 'confidential' (internal use only) — never public or baseline is 'internal_use_only' — never public or general use by
general use by default. Escalated to 'restricted' when the technique's default. Escalated to 'pii' when the technique's tactic implies
tactic implies customer data, PII, or destructive impact. customer data, PII, or destructive impact.
""" """
if technique and technique.tactic and technique.tactic.lower() in _RESTRICTED_TACTICS: if technique and technique.tactic and technique.tactic.lower() in _RESTRICTED_TACTICS:
return DataClassification.restricted.value return DataClassification.pii.value
return DataClassification.confidential.value return DataClassification.internal_use_only.value
def _build_test_query( def _build_test_query(
+17 -17
View File
@@ -34,7 +34,7 @@ def _seed_technique(db, tactic="execution") -> Technique:
return 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 """A Test() constructed without going through the create_test service
still gets a safe classification via the column's server_default.""" still gets a safe classification via the column's server_default."""
technique = _seed_technique(db) 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.add(test)
db.commit() db.commit()
db.refresh(test) 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): 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 assert resp.status_code == 201, resp.text
# The shared `technique` fixture (see conftest/test_tests.py) doesn't set # The shared `technique` fixture (see conftest/test_tests.py) doesn't set
# a restricted-tier tactic, so this should land on the baseline. # a pii-tier tactic, so this should land on the baseline.
assert resp.json()["data_classification"] == "confidential" assert resp.json()["data_classification"] == "internal_use_only"
def test_admin_can_update_classification(client, db, admin_user, admin_token, red_lead_user): 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( response = client.patch(
f"/api/v1/tests/{test.id}/classification", f"/api/v1/tests/{test.id}/classification",
json={"data_classification": "restricted"}, json={"data_classification": "pii"},
headers={"Authorization": f"Bearer {admin_token}"}, headers={"Authorization": f"Bearer {admin_token}"},
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["data_classification"] == "restricted" assert response.json()["data_classification"] == "pii"
db.refresh(test) 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): 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( response = client.patch(
f"/api/v1/tests/{test.id}/classification", f"/api/v1/tests/{test.id}/classification",
json={"data_classification": "restricted"}, json={"data_classification": "pii"},
headers={"Authorization": f"Bearer {viewer_token}"}, headers={"Authorization": f"Bearer {viewer_token}"},
) )
assert response.status_code == 403 assert response.status_code == 403
@@ -142,15 +142,15 @@ def _technique_stub(tactic):
return t return t
def test_determine_initial_classification_defaults_to_confidential(): def test_determine_initial_classification_defaults_to_internal_use_only():
assert determine_initial_classification(_technique_stub("execution")) == "confidential" assert determine_initial_classification(_technique_stub("execution")) == "internal_use_only"
assert determine_initial_classification(_technique_stub(None)) == "confidential" assert determine_initial_classification(_technique_stub(None)) == "internal_use_only"
assert determine_initial_classification(None) == "confidential" assert determine_initial_classification(None) == "internal_use_only"
def test_determine_initial_classification_escalates_for_sensitive_tactics(): def test_determine_initial_classification_escalates_for_sensitive_tactics():
assert determine_initial_classification(_technique_stub("exfiltration")) == "restricted" assert determine_initial_classification(_technique_stub("exfiltration")) == "pii"
assert determine_initial_classification(_technique_stub("collection")) == "restricted" assert determine_initial_classification(_technique_stub("collection")) == "pii"
assert determine_initial_classification(_technique_stub("credential-access")) == "restricted" assert determine_initial_classification(_technique_stub("credential-access")) == "pii"
assert determine_initial_classification(_technique_stub("impact")) == "restricted" assert determine_initial_classification(_technique_stub("impact")) == "pii"
assert determine_initial_classification(_technique_stub("Exfiltration")) == "restricted" 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() 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): def _make_user(**overrides):
from app.models.user import User from app.models.user import User
u = MagicMock(spec=User) u = MagicMock(spec=User)
@@ -5,10 +5,12 @@ import type { DataClassification } from "../../types/models";
// ── Options ──────────────────────────────────────────────────────── // ── Options ────────────────────────────────────────────────────────
const CLASSIFICATION_OPTIONS: { value: DataClassification; label: string; color: string }[] = [ 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: "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: "internal_use_only", label: "Internal Use Only", 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: "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 ────────────────────────────────────────────────────────── // ── 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 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"; export type ValidationStatus = "pending" | "approved" | "rejected";