-
Notifications
You must be signed in to change notification settings - Fork 2
Specs M6 Incidents Security Event
incidents.models.security_event.SecurityEvent
The register of reported information security events and weaknesses : ISO/IEC 27001:2022 A.6.8 (reporting of information security events) on the way in, and A.5.25 (assessment and decision on information security events) on the way out.
Every reported occurrence enters here, and it is not an incident until a named person decides it is. That single constraint is the whole point of the entity. It turns the promotion decision into an auditable, permissioned, comment-bearing lifecycle transition instead of an implicit data entry, and it is the only way to answer the question every ISO 27001 auditor asks : show me the events you decided were not incidents, and who decided. A design that jumps straight to an incident table with a status column cannot answer it at all, because the events that were correctly dismissed leave no trace.
File: incidents/models/security_event.py
ScopedModel subclass : UUID PK, sequential reference (prefix EVNT, e.g. EVNT-1), scopes M2M, tags, version, created_by, django-simple-history audit trail, and the dedicated security_event lifecycle. workflow_perm_namespace is overridden to incidents.security_event : the default app_label.model_name would spell incidents.securityevent, which matches no feature in PERMISSION_REGISTRY, and every transition would then be refused for everyone.
The three words are not synonyms, and A.6.8 and A.5.25 rest on keeping them apart:
- an event is an identified occurrence of a system, service or network state indicating a possible breach of policy, a failure of controls, or a previously unknown situation that may be security-relevant;
- a weakness is a reported flaw that has not been exploited : an unlocked door, an unpatched host, a shared credential, a mis-scoped bucket;
- an incident is one or more unwanted or unexpected events that a named person has assessed as having a significant probability of compromising business operations and threatening information security.
SecurityEvent carries the first two through event_class. The third is a separate entity, Incident, reachable only through the under_assessment -> confirmed_incident transition. A confirmed weakness is promoted into the existing risks.Vulnerability register, never into a parallel weakness table : two weakness registers would be two answers to what do we know is broken.
| Field | Type | Constraints | Description |
|---|---|---|---|
id |
UUID | PK, auto-generated | Unique identifier |
reference |
string | auto-generated EVNT-N, unique |
Business reference |
scopes |
relation | M2M -> context.Scope
|
ISMS scopes the event belongs to |
title |
string | required, max 255 | Short label of what was observed |
description |
text | optional, HTML | What was observed, in the reporter's own words. Never rewritten on promotion : the original report is part of the A.6.8 record. |
event_class |
enum | required, default event
|
SecurityEventClass. Governs which promotion targets are legal (RG-INC-03). |
category |
enum | optional, blank default |
risks.constants.ThreatCategory (23 values). Provisional classification, refined on promotion. |
detection_source |
enum | required, default other
|
DetectionSource : how the event surfaced |
source_reference |
string | optional, max 255, blank default | External identifier : SIEM alert id, ticket number, CERT bulletin reference |
occurred_at |
datetime | optional | Best estimate of when the occurrence started |
detected_at |
datetime | required, indexed | When it was detected. Base of the mean-time-to-detect KPI. |
reported_at |
datetime | required, indexed, >= detected_at
|
When it reached the incident response function. reported_at - detected_at is the A.6.8 reporting delay that the control's "as quickly as possible" is measured against. Enforced in clean(). |
reporter |
relation | FK -> User, SET_NULL, optional |
Internal reporter. Reverse accessor reported_security_events. Null when the report is anonymous or external. |
reporter_label |
string | optional, max 255, blank default | Free-text reporter identity for external or non-user reporters (customer, researcher, authority) |
is_anonymous |
boolean | required, default False
|
Reported through the anonymous channel A.6.8 requires. CheckConstraint event_anonymous_has_no_reporter : is_anonymous = False OR (reporter IS NULL AND reporter_label = ''). The database, not a form, is what guarantees the channel is actually anonymous. |
assessed_by |
relation | FK -> User, SET_NULL, optional |
Person who performed the A.5.25 assessment. Reverse accessor assessed_security_events. Stamped by the transition. |
assessed_at |
datetime | optional, write-once | When the assessment began. Stamped by the transition_to() override; never editable in a form, a serializer or an MCP writable list. |
assessment_notes |
text | optional, blank default | The reasoning behind the decision. Required non-blank to leave under_assessment by any route (RG-INC-05) : an undocumented assessment is not an assessment. |
triage_decision |
enum | optional, blank default |
EventTriageDecision. Mirrors the terminal step and is set by the transition_to() override, kept as a column so filters, list facets and MCP enums never have to read the lifecycle. |
workflow_state |
string | indexed, default draft
|
Lifecycle step (security_event) |
tags |
relation | M2M -> context.Tag
|
|
version |
int | auto-incremented | |
created_by |
relation | FK -> User | |
created_at / updated_at
|
datetime | auto | Timestamps |
| Name | Type | Target | Reverse accessor | Description |
|---|---|---|---|---|
incident |
FK, SET_NULL, optional |
Incident | source_events |
The incident this event was promoted into. Several events may feed one incident; an event promotes into at most one (RG-INC-06). |
vulnerability |
FK, SET_NULL, optional |
risks.Vulnerability |
source_events |
The vulnerability a confirmed weakness was promoted into. No parallel weakness register. |
duplicate_of |
FK -> self, SET_NULL, optional |
SecurityEvent | duplicates |
The earlier event this one repeats. Also the link used when a previously reported weakness is later exploited (RG-INC-03). |
reported_by_supplier |
FK, SET_NULL, optional |
assets.Supplier |
reported_security_events |
Third-party notification (NIS2 supply chain, GDPR Art. 33(2) inbound) |
affected_support_assets |
M2M | assets.SupportAsset |
security_events |
|
affected_essential_assets |
M2M | assets.EssentialAsset |
security_events |
|
affected_sites |
M2M | context.Site |
security_events |
reported_security_eventsis used as the reverse accessor on two different targets :assets.Supplier(forreported_by_supplier) andAUTH_USER_MODEL(forreporter). This is legal because the targets differ, but it is genuinely confusing to read :user.reported_security_eventsandsupplier.reported_security_eventsmean different things. Always name the model when writing either.
ordering = ["-reported_at"]-
CheckConstraint event_incident_decision_requires_incident:Q(triage_decision != "incident") | Q(incident__isnull=False) -
CheckConstraint event_weakness_decision_requires_vulnerability:Q(triage_decision != "weakness") | Q(vulnerability__isnull=False) CheckConstraint event_anonymous_has_no_reporter
The first two constraints are the database half of RG-INC-02 : the transition gate refuses the promotion, and the constraint refuses the row, so neither a raw SQL insert nor a QuerySet.update() can leave a "promoted" event that points at nothing.
| Value | Label |
|---|---|
event |
Event |
weakness |
Weakness |
| Value | Label |
|---|---|
incident |
Promoted to incident |
weakness |
Confirmed weakness |
duplicate |
Duplicate |
false_positive |
False positive |
no_action |
No action required |
Declared once in incidents/constants.py and shared with Incident.
| Value | Label |
|---|---|
internal_monitoring |
Internal monitoring |
soc_alert |
SOC or SIEM alert |
employee_report |
Employee report |
customer_report |
Customer report |
supplier_notification |
Supplier notification |
authority_notification |
Authority notification |
researcher |
External researcher |
audit |
Audit |
penetration_test |
Penetration test |
threat_intel |
Threat intelligence |
other |
Other |
LIFECYCLE_NAME = "security_event", layout="graph", generated by lifecycle_from_state_flags() in incidents/lifecycles.py from the state and transition constants in incidents/constants.py, and registered from IncidentsConfig.ready().
Unlike the incident and incident_evidence lifecycles, this one needs no step trigger, so it keeps the generated form the project's rules prescribe. It does, however, declare both bookend steps explicitly - see below.
| Code | Label | StepKind | In reports | Linkable | Deletable | Tone | Meaning |
|---|---|---|---|---|---|---|---|
draft |
Draft | DRAFT |
no | no | yes | neutral |
Being written up; not yet in the register |
reported |
Reported | INTERMEDIATE |
yes | no | yes | secondary |
In the A.6.8 register, awaiting assessment. Still deletable : a genuine mis-entry made two minutes ago should not need an approver to remove, and nothing downstream references it yet. |
under_assessment |
Under assessment | INTERMEDIATE |
yes | no | no | info |
The A.5.25 judgement is in progress; assessed_by and assessed_at are stamped |
confirmed_incident |
Promoted to incident |
ARCHIVED (terminal) |
yes | yes | no | danger |
Assessed as an incident; incident FK set |
confirmed_weakness |
Confirmed weakness |
ARCHIVED (terminal) |
yes | yes | no | warning |
Assessed as a real weakness; vulnerability FK set |
discarded |
Discarded |
ARCHIVED (terminal) |
no | no | no | muted |
Duplicate, false positive, or genuinely nothing to do. This is the step an auditor asks to see. |
archived |
Archived | ARCHIVED |
no | no | no | muted |
The generic exit, declared explicitly |
discarded keeps counts_in_reports=False because a false positive is not a security event of record; the number of discarded events is still reportable through a direct queryset, and the A.6.8 evidence an auditor wants is the individual rows with their assessment_notes, not a KPI.
permission_action is appended to workflow_perm_namespace (incidents.security_event).
| Verb | Transition | permission_action |
requires_comment |
Side effects |
|---|---|---|---|---|
| Report | draft -> reported |
update |
no | Enters the A.6.8 register |
| Start assessment | reported -> under_assessment |
update |
no | Stamps assessed_by (the acting user) and assessed_at
|
| Promote to incident | under_assessment -> confirmed_incident |
update |
no | Sets triage_decision = incident; copies detection_source, category and the affected-asset links onto the incident |
| Promote to vulnerability | under_assessment -> confirmed_weakness |
update |
no | Sets triage_decision = weakness
|
| Discard | under_assessment -> discarded |
approve |
yes | The comment is written into assessment_notes and into the immutable core.LifecycleEvent; triage_decision is set to duplicate, false_positive or no_action from the form |
| Reopen assessment | discarded -> under_assessment |
update |
yes | Clears triage_decision; the original discard stays in the lifecycle history |
| Archive | * -> archived |
approve |
yes | Hand-declared, not auto-wired |
| Restore | archived -> draft |
approve |
no | Hand-declared |
Both bookend edges are hand-declared, and so is
draft -> reported.lifecycle_from_state_flags()auto-wiresdraft -> <initial step>,ANY -> archivedandarchived -> draftonly when the corresponding step is absent from the state-flag list (core/lifecycle.pylifecycle_from_state_flags()). This lifecycle declaresdraftandarchivedexplicitly, precisely so nothing is auto-wired : the auto-wired archive and restore edges carry nopermission_actionand norequires_comment, anduser_can_perform()(core/lifecycle.pyuser_can_perform()) allows any transition whosepermission_actionis empty. Left generated, they would give any holder of the transition endpoint anarchive -> restore -> deletepath out of areportedorunder_assessmentevent, destroying an A.6.8 record. All three edges are therefore listed inSECURITY_EVENT_TRANSITIONSwith explicit actions.
Per RG-INC-08, every gate below lives in a transition_to() override on SecurityEvent, never in Transition.form_class, allowed_roles or allowed_users. lifecycle_to_json() (core/lifecycle.py lifecycle_to_json()) omits those three fields by design, lifecycle_from_json() rebuilds transitions without them, and get_lifecycle() prefers the post_migrate-seeded LifecycleDefinition row over the code default - so a gate declared that way is silently dead on every migrated database. All three write surfaces (core/workflow_views.py WorkflowTransitionView.post(), accounts/api/mixins.py _lifecycle_transition(), mcp/tools.py _transition_handler()) funnel through BaseModel.transition_to(), so the model override is the one place that binds web, API and MCP at once.
| Gate | Transition | Refused unless |
|---|---|---|
| G-01 Documented assessment (RG-INC-05) | every transition leaving under_assessment
|
assessment_notes is non-blank. This applies to promotion and to discarding, by any route including MCP. |
| G-02 Incident target (RG-INC-02) | under_assessment -> confirmed_incident |
The incident FK is non-null. Also a DB CheckConstraint. |
| G-03 Weakness cannot become an incident (RG-INC-03) | under_assessment -> confirmed_incident |
event_class != weakness. A weakness that has actually been exploited is a new event of class event, linked back through duplicate_of, so the original reporting history stays intact and the reporting delay of the exploitation is measured from its own detection. |
| G-04 Vulnerability target (RG-INC-02) | under_assessment -> confirmed_weakness |
The vulnerability FK is non-null. Also a DB CheckConstraint. |
| G-05 Named discard (RG-INC-04) | under_assessment -> discarded |
The actor holds incidents.security_event.approve and supplies a comment. The comment is persisted into assessment_notes, not only into the event ledger, so the register itself is readable without joining the history. |
| G-06 One decision only | any promotion transition |
triage_decision is blank or matches the target. A single event never carries two verdicts. |
| G-07 Write-once stamps (RG-INC-12) | all |
assessed_at is stamped by the override and never by a form, serializer or MCP field. |
BaseModel.save() calls _ensure_initial_step() (context/models/base.py BaseModel._ensure_initial_step()), and Lifecycle.initial_step (core/lifecycle.py Lifecycle.initial_step) returns the single StepKind.DRAFT step. The workflow_state field default is the literal "draft", which is a valid step here, so _ensure_initial_step() leaves it untouched and every new row lands in draft - not in reported.
No row in this module is ever "created in" a domain step. Any path that needs a SecurityEvent to arrive already reported (a bulk import, an inbound integration, the seed) must, inside one transaction.atomic():
event = SecurityEvent(...)
event.save()
event.transition_to("reported", user, enforce_permission=False)Assigning workflow_state="reported" at insert would stick - the snap only fires on a blank or unknown value - but it would leave no core.LifecycleEvent row, so the event would have no recorded entry into the A.6.8 register, which is exactly the evidence the register exists to hold.
Promotion is one atomic act, not a sequence a user can abandon halfway:
- The event is in
under_assessment,event_class = event, andassessment_notesis non-blank. - An Incident is created in
draftand immediately transitioned todetected(save()thentransition_to("detected", user, enforce_permission=False)), copyingdetection_source,category,affected_support_assets,affected_essential_assets,affected_sitesandscopesfrom the event.Incident.reporteris taken from the event'sreporterwhen set. - The event's
incidentFK is set,triage_decisionbecomesincident, and the event transitions toconfirmed_incident.
On the web surface this runs from the event detail page's stepper; on MCP it is the single bespoke declare_incident_from_event tool, which exists precisely so an agent cannot leave a half-promoted event behind.
Promotion to a vulnerability follows the same shape against the existing risks.Vulnerability register, which already carries cve_references and affected_assets. The event's assessment_notes are what justify the entry.
| ID | Rule |
|---|---|
| RG-INC-01 | A SecurityEvent is never an incident. An Incident exists only after an explicit, permissioned A.5.25 assessment transition on the event, or a direct declaration recorded with a detection_source and a named declarer. |
| RG-INC-02 | Exactly one triage decision per event. Reaching confirmed_incident requires a non-null incident FK; reaching confirmed_weakness requires a non-null vulnerability FK. Both are enforced by DB CheckConstraints as well as by the transition gate. |
| RG-INC-03 | An event with event_class = weakness can never be promoted to an incident. A weakness that has actually been exploited is a new event of class event, linked to the weakness through duplicate_of, so the reporting history stays intact. |
| RG-INC-04 | Discarding an event requires incidents.security_event.approve and a mandatory comment; the comment is written into assessment_notes and into the immutable core.LifecycleEvent. |
| RG-INC-05 |
assessment_notes must be non-blank to leave under_assessment by any route. An undocumented assessment is not an assessment. |
| RG-INC-06 | Several events may promote into one incident (Incident.source_events); an event promotes into at most one incident. |
| RG-INC-12 |
assessed_at is stamped by the transition_to() override only : excluded from every ModelForm, read_only in every serializer, absent from every MCP writable list. Write-once is prevented at application level and detected through HistoricalRecords; QuerySet.update(), bulk_update() and raw SQL bypass save(). |
| RG-INC-37 | Every report, KPI, calendar feed, kanban bucket and link picker filters through reportable() / linkable() / linkable_or_linked() / deletable_states(). No security_event state literal appears outside incidents/constants.py. |
| RG-INC-38 |
SecurityEvent is a ScopedModel and carries its own scopes, so ScopeFilterMixin and ScopeFilterAPIMixin filter it with no extra work. Scopes are copied onto the incident it promotes into. |
-
GET /api/v1/incidents/security-events/: list, filtered bySecurityEventFilter(status,event_class,triage_decision,detection_source,is_anonymous,reported_after/reported_before) -
POST /api/v1/incidents/security-events/andPOST /api/v1/incidents/security-events/batch/(max 100 items, non-atomic, per-item{index, status, id, reference}) GET/PUT/PATCH/DELETE /api/v1/incidents/security-events/<uuid>/-
GET/POST /api/v1/incidents/security-events/<uuid>/transition/:LifecycleAPIMixin, routed throughtransition_to(enforce_permission=True), so every gate above applies identically to an API caller -
GET /api/v1/incidents/security-events/<uuid>/history/:core.history.build_timeline
SecurityEventSerializer / SecurityEventListSerializer, with read_only_fields covering id, reference, created_by, created_at, updated_at, version, assessed_at, assessed_by and triage_decision. status is exposed as CharField(source="workflow_state", read_only=True). The viewset uses ModulePermission plus the module's _IncidentViewSet base (permission_module = "incidents", custom_action_map = {"transition": "update"}), following trust_center/api/views.py _ManagedViewSet.
-
_register_crud(server, "security_event", SecurityEvent, "incidents.security_event", ...)generateslist_security_events,get_security_event,create_security_event,batch_create_security_events,update_security_event,delete_security_event,transition_security_event,security_event_allowed_transitions,get_security_event_history. - Filters :
status,event_class,triage_decision,detection_source,is_anonymous. -
declare_incident_from_event(bespoke; requiresincidents.security_event.updateandincidents.incident.create) performs the full promotion atomically. -
triage_decision,assessed_byandassessed_atare absent fromwritable_fields: the decision is a transition, never a field write.
mcp/tools.py HELP_TEXT gains SecurityEvent=EVNT in the reference-prefix block, and assistant/catalog.py gains a read-only list_security_events ToolSpec with detail_route="incidents:security-event-detail".
| Codename | Description |
|---|---|
incidents.security_event.read |
List / read events and weaknesses |
incidents.security_event.create |
Report an event or a weakness |
incidents.security_event.update |
Edit an event, start an assessment, promote it, reopen a discarded one |
incidents.security_event.approve |
Discard an event (the A.5.25 "this was not an incident" verdict), archive, restore |
incidents.security_event.delete |
Delete a draft or reported event |
Promotion to an incident additionally requires incidents.incident.create.
-
List (
/incidents/events/) : the same house stack as the incident list, with predefined filters for Awaiting assessment (workflow_state = reported), Under assessment, Discarded and Weaknesses. The Awaiting assessment count is the A.6.8 backlog and is surfaced inlist_rail_kpis. -
Detail (
/incidents/events/<uuid>/) : a strict 2-column card layout, no nav-tabs. Left column : Observation (description, category, detection source, source reference, occurred / detected / reported stamps with the reporting delay computed and shown); Assessment (assessed_by,assessed_at,assessment_notes); Promotion targets (the incident or vulnerability link, and the duplicate-of link). Right column, sticky :{% workflow_badge %}, the triage decision badge, the reporter avatar - replaced by an "Anonymous report" badge whenis_anonymous, never by a blank - the reporting supplier, affected assets and sites, scopes, tags and the history trigger. - The triage decision is driven entirely by the stepper : there is no decision select anywhere on the page.
-
Stepper :
{% include "includes/lifecycle_stepper.html" %}fed byLifecycleStepperMixin. This lifecycle has fourStepKind.ARCHIVEDsteps (confirmed_incident,confirmed_weakness,discarded,archived), so the dagre renderer draws four detached exits : more than any existing Cairn lifecycle, and requiring an explicit visual check at desktop and mobile widths in both light and dark mode before merge. - The anonymous reporting channel A.6.8 requires is served by the create form : ticking Anonymous report clears and disables the reporter and reporter-label inputs client-side, and the
CheckConstraintenforces it server-side regardless.
Several of this entity's labels collide with msgids already present in locale/fr/LC_MESSAGES/django.po. A duplicate (msgctxt, msgid) pair makes compilemessages fail, and .github/workflows/tests.yml runs compilemessages before pytest.
Enum labels, field verbose names and template strings use pgettext_lazy("incident", ...) in Python and {% trans "..." context "incident" %} in templates, with a matching msgctxt "incident" block in the .po:
| String | Existing bare entry | Action |
|---|---|---|
SecurityEventClass.WEAKNESS "Weakness" |
django.po -> "Faiblesse" |
pgettext_lazy("incident", "Weakness") |
DetectionSource.AUDIT "Audit" |
django.po -> "Audit" |
pgettext_lazy("incident", "Audit") |
DetectionSource.OTHER "Other" |
django.po -> "Autre" |
pgettext_lazy("incident", "Other") |
| "Evidence" (evidence links on the detail page) |
django.po -> "Preuves" |
{% trans "Evidence" context "incident" %} |
Step and transition labels must never use pgettext_lazy. lifecycle_to_json() stringifies each label with str(...) and lifecycle_from_json() re-wraps the stored string with bare gettext_lazy (core/lifecycle.py lifecycle_from_json()), so a msgctxt carried in code is lost after the post_migrate DB round-trip and the label resolves to whatever the bare msgid maps to. This lifecycle is safe as declared : "Draft" (django.po -> "Brouillon") and "Archived" (django.po -> "Archivé") are reused from the core bookend steps with the correct French, "Archive" and "Restore" are the existing core transition labels, and every other label ("Reported", "Under assessment", "Promoted to incident", "Confirmed weakness", "Discarded", "Report", "Start assessment", "Promote to incident", "Promote to vulnerability", "Discard", "Reopen assessment") is a new, non-colliding bare msgid. Note in particular that the confirmed_weakness step is labelled "Confirmed weakness", not "Weakness", which is what keeps it clear of the collision that the enum label has to solve with a msgctxt.
After editing the .po, verify there is no duplicate msgid without a distinguishing msgctxt.
- ISO/IEC 27001:2022 A.6.8 (reporting of information security events), A.5.25 (assessment and decision on information security events)
- ISO/IEC 27035-1 / -2 : the detect and report and assess and decide phases
- ISO/IEC 27001:2022 A.8.8 (technical vulnerability management) : a confirmed weakness promotes into the existing vulnerability register
- Incident : what an event becomes when a named person decides it is one
-
IncidentResponsePlan :
reporting_channelsdocuments how events reach this register, including the anonymous channel - README.md : module business rules, permissions, notifications
- governance/workflow.md : the lifecycle framework this workflow plugs into
- Vulnerability, Supplier, Site
Built from docs/ at v0.36.0. Edits made here are overwritten by the next release : open a pull request against the source instead.
- Administration
- Ask Cairn
- Assets and suppliers
- Compliance
- The dashboard
- Finding your way
- Getting started
- Incidents
- How records move
- Organisational context
- Reports and management review
- Risks
- Trust Center
- Architecture
- Configuration
- Contributing
- The documentation system
- Installation
- Internationalisation
- Operations
- Release process
- Security
- Testing
- Adding an assistant provider
- Adding a dashboard widget
- Adding a domain entity
- Declaring a lifecycle
- Adding an MCP tool
- Adding a REST endpoint
- Adding a report
- Interface conventions
- Dashboard widgets
- Lifecycles
- MCP tools
- MCP tool parameters : Assets
- MCP tool parameters : Compliance
- MCP tool parameters : Governance and context
- MCP tool parameters : General
- MCP tool parameters : Incidents
- MCP tool parameters : Reports and management review
- MCP tool parameters : Risks
- MCP tool parameters : System and administration
- MCP tool parameters : Trust Center
- Management commands
- Models
- Permissions
- REST endpoints
- Environment variables
- MCP server
- REST API
- Assistant module (Ask Cairn)
- Module 0: User Management and Access Control
- Module 1: Context and Organization
- Module 2: Asset Management
- Module 3: Compliance
- Module 4: Risk Management
- Module 4 bis - EBIOS Risk Manager
- Module 5 : Trust Center
- Module 6 : Security Incident Management
- Management review : ISO 27001:2022 compliance (clause 9.3)