diff --git a/backend/alembic/versions/b060_data_classification_matches_jira.py b/backend/alembic/versions/b060_data_classification_matches_jira.py new file mode 100644 index 0000000..6bd927a --- /dev/null +++ b/backend/alembic/versions/b060_data_classification_matches_jira.py @@ -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") diff --git a/backend/app/domain/enums.py b/backend/app/domain/enums.py index f453635..ddf2a1a 100644 --- a/backend/app/domain/enums.py +++ b/backend/app/domain/enums.py @@ -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" diff --git a/backend/app/models/campaign.py b/backend/app/models/campaign.py index c38b761..d2bdf03 100644 --- a/backend/app/models/campaign.py +++ b/backend/app/models/campaign.py @@ -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) diff --git a/backend/app/models/evidence.py b/backend/app/models/evidence.py index 87c9d32..0f4a95e 100644 --- a/backend/app/models/evidence.py +++ b/backend/app/models/evidence.py @@ -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") diff --git a/backend/app/models/test.py b/backend/app/models/test.py index 8695781..7df0710 100644 --- a/backend/app/models/test.py +++ b/backend/app/models/test.py @@ -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") diff --git a/backend/app/schemas/test.py b/backend/app/schemas/test.py index e6bc423..d59e419 100644 --- a/backend/app/schemas/test.py +++ b/backend/app/schemas/test.py @@ -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 diff --git a/backend/app/services/jira_service.py b/backend/app/services/jira_service.py index 32e0506..b8917fc 100644 --- a/backend/app/services/jira_service.py +++ b/backend/app/services/jira_service.py @@ -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", "") diff --git a/backend/app/services/test_crud_service.py b/backend/app/services/test_crud_service.py index 6ab51bc..e5bfea0 100644 --- a/backend/app/services/test_crud_service.py +++ b/backend/app/services/test_crud_service.py @@ -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( diff --git a/backend/tests/test_data_classification.py b/backend/tests/test_data_classification.py index 0c8274c..03ecf86 100644 --- a/backend/tests/test_data_classification.py +++ b/backend/tests/test_data_classification.py @@ -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" diff --git a/backend/tests/test_jira_service.py b/backend/tests/test_jira_service.py index e3d65e5..94dae06 100644 --- a/backend/tests/test_jira_service.py +++ b/backend/tests/test_jira_service.py @@ -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) diff --git a/frontend/src/components/test-detail/DataClassificationBadge.tsx b/frontend/src/components/test-detail/DataClassificationBadge.tsx index 891316f..c06653f 100644 --- a/frontend/src/components/test-detail/DataClassificationBadge.tsx +++ b/frontend/src/components/test-detail/DataClassificationBadge.tsx @@ -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 ────────────────────────────────────────────────────────── diff --git a/frontend/src/types/models.ts b/frontend/src/types/models.ts index 45efafa..b0eef2b 100644 --- a/frontend/src/types/models.ts +++ b/frontend/src/types/models.ts @@ -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";