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
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:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user