Skip to content

Commit b523131

Browse files
authored
Merge pull request #347 from MISP/fix-misp-format-export
fix: exports misp format, remove uuids, set distribution level
2 parents 156cb75 + 65dd4c6 commit b523131

16 files changed

Lines changed: 305 additions & 13 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""add export distribution
2+
3+
Revision ID: m6n7o8p9q0r1
4+
Revises: l5m6n7o8p9q0
5+
Create Date: 2026-06-12 00:00:00.000000
6+
7+
MISP-format exports build a single MISP event; the event distribution level is
8+
captured per export.
9+
10+
"""
11+
12+
import sqlalchemy as sa
13+
from alembic import op
14+
15+
revision = "m6n7o8p9q0r1"
16+
down_revision = "l5m6n7o8p9q0"
17+
branch_labels = None
18+
depends_on = None
19+
20+
21+
def upgrade():
22+
op.add_column("exports", sa.Column("distribution", sa.Integer(), nullable=True))
23+
24+
25+
def downgrade():
26+
op.drop_column("exports", "distribution")

api/app/models/export.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ class Export(Base):
3131
query = Column(Text, nullable=False)
3232
index_target = Column(String(50), nullable=False, default="attributes")
3333
format = Column(String(20), nullable=False, default="json")
34+
# Event distribution level for MISP-format exports (0–4); null otherwise.
35+
distribution = Column(Integer, nullable=True)
3436
status = Column(String(50), nullable=False, default="queued")
3537
storage_key = Column(String(512), nullable=True)
3638
file_size = Column(Integer, nullable=True)

api/app/repositories/exports.py

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,20 @@ def update_export_schedule(
159159
return db_export
160160

161161

162+
def requeue_export(
163+
db: Session, export_id: int, user_id: int
164+
) -> export_models.Export:
165+
"""Reset an existing export to ``queued`` so it can be re-run in place."""
166+
db_export = get_export_by_id(db, export_id, user_id)
167+
if db_export is None:
168+
return None
169+
db_export.status = "queued"
170+
db_export.error = None
171+
db.commit()
172+
db.refresh(db_export)
173+
return db_export
174+
175+
162176
def set_celery_task_id(db: Session, export_id: int, task_id: str) -> None:
163177
db_export = (
164178
db.query(export_models.Export)
@@ -211,7 +225,13 @@ def _fetch_hits(index: str, query: str) -> list[dict]:
211225
return hits
212226

213227

214-
def _to_misp_json(db: Session, hits: list[dict], index_target: str, name: str):
228+
def _to_misp_json(
229+
db: Session,
230+
hits: list[dict],
231+
index_target: str,
232+
name: str,
233+
distribution: int = None,
234+
):
215235
"""Serialize all matching attributes into a single MISP event.
216236
217237
Every matching attribute is collected into one synthetic MISP event named
@@ -221,7 +241,6 @@ def _to_misp_json(db: Session, hits: list[dict], index_target: str, name: str):
221241
output matches MISP's event API shape (``{"Event": {...}}``).
222242
"""
223243
import json
224-
import uuid as uuid_module
225244

226245
from app.schemas import attribute as attribute_schemas
227246
from app.schemas import event as event_schemas
@@ -248,19 +267,31 @@ def _to_misp_json(db: Session, hits: list[dict], index_target: str, name: str):
248267
except Exception as e: # skip malformed rows
249268
logger.warning("Skipping attribute in MISP export: %s", e)
250269

251-
# Stable UUID per export so re-runs (e.g. scheduled) update the same event
252-
# on re-import rather than creating a new one each time.
253-
event_uuid = uuid_module.uuid5(
254-
uuid_module.NAMESPACE_URL, f"misp-workbench:export:{name}"
255-
)
256270
misp_event = event_schemas.Event(
257271
info=name,
258-
uuid=event_uuid,
259272
timestamp=int(datetime.now(timezone.utc).timestamp()),
273+
distribution=distribution,
274+
disable_correlation=False,
260275
attributes=attributes,
261276
)
262277

263278
payload = misp_event.to_misp_format()
279+
280+
# Strip identifiers so importing the file always creates fresh records
281+
# rather than colliding with existing event/attribute uuids.
282+
def _strip_ids(obj: dict) -> None:
283+
obj.pop("uuid", None)
284+
obj.pop("id", None)
285+
286+
event = payload.get("Event", {})
287+
_strip_ids(event)
288+
for attribute in event.get("Attribute", []):
289+
_strip_ids(attribute)
290+
for misp_object in event.get("Object", []):
291+
_strip_ids(misp_object)
292+
for attribute in misp_object.get("Attribute", []):
293+
_strip_ids(attribute)
294+
264295
content = json.dumps(payload, default=str, indent=2).encode("utf-8")
265296
return content, "json", "application/json", len(attributes)
266297

@@ -293,7 +324,11 @@ def run_export(db: Session, export_id: int) -> None:
293324
# MISP format merges all matches into one event via the server-push
294325
# serializer; record_count reflects the attributes in that event.
295326
content, extension, _content_type, record_count = _to_misp_json(
296-
db, hits, db_export.index_target, db_export.name
327+
db,
328+
hits,
329+
db_export.index_target,
330+
db_export.name,
331+
db_export.distribution,
297332
)
298333
else:
299334
content, extension, _content_type = converters.convert(

api/app/routers/exports.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,27 @@ async def create_export(
6161
return db_export
6262

6363

64+
@router.post("/exports/{export_id}/run", response_model=export_schemas.Export)
65+
async def run_export_now(
66+
export_id: int,
67+
db: Session = Depends(get_db),
68+
user: user_schemas.User = Security(
69+
get_current_active_user, scopes=["exports:create"]
70+
),
71+
):
72+
db_export = exports_repository.requeue_export(
73+
db, export_id=export_id, user_id=user.id
74+
)
75+
if db_export is None:
76+
raise HTTPException(
77+
status_code=status.HTTP_404_NOT_FOUND, detail="Export not found"
78+
)
79+
result = tasks.run_export.delay(db_export.id)
80+
exports_repository.set_celery_task_id(db, db_export.id, result.id)
81+
db.refresh(db_export)
82+
return db_export
83+
84+
6485
@router.get("/exports/{export_id}", response_model=export_schemas.Export)
6586
async def get_export_by_id(
6687
export_id: int,

api/app/schemas/export.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from datetime import datetime
22
from typing import Literal, Optional
33

4-
from pydantic import BaseModel, ConfigDict
4+
from pydantic import BaseModel, ConfigDict, model_validator
55

66
from app.schemas.task import ScheduleTaskSchedule
77

@@ -19,12 +19,20 @@ class ExportBase(BaseModel):
1919
query: str
2020
index_target: ExportIndexTarget = "attributes"
2121
format: ExportFormat = "json"
22+
# Event distribution level (0–4) — required for MISP-format exports.
23+
distribution: Optional[int] = None
2224

2325

2426
class ExportCreate(ExportBase):
2527
schedule: Optional[ScheduleTaskSchedule] = None
2628
schedule_enabled: bool = False
2729

30+
@model_validator(mode="after")
31+
def _require_distribution_for_misp(self):
32+
if self.format == "misp" and self.distribution is None:
33+
raise ValueError("distribution is required for MISP-format exports")
34+
return self
35+
2836

2937
class ExportScheduleUpdate(BaseModel):
3038
# ``schedule=None`` clears the schedule (unschedule). ``schedule_enabled``

api/app/tests/api/test_exports.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,11 @@ def test_run_export_misp_format(
391391
assert len(event["Attribute"]) == 2
392392
values = {a["value"] for a in event["Attribute"]}
393393
assert values == {"1.2.3.4", "5.6.7.8"}
394+
# uuid/id are stripped from the event and every attribute so
395+
# importing creates fresh records.
396+
assert "uuid" not in event and "id" not in event
397+
for attr in event["Attribute"]:
398+
assert "uuid" not in attr and "id" not in attr
394399
finally:
395400
db.delete(export)
396401
db.commit()

docs/features/api/openapi.json

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8867,6 +8867,55 @@
88678867
}
88688868
}
88698869
},
8870+
"/exports/{export_id}/run": {
8871+
"post": {
8872+
"tags": [
8873+
"Exports"
8874+
],
8875+
"summary": "Run Export Now",
8876+
"operationId": "run_export_now_exports__export_id__run_post",
8877+
"security": [
8878+
{
8879+
"OAuth2PasswordBearer": [
8880+
"exports:create"
8881+
]
8882+
}
8883+
],
8884+
"parameters": [
8885+
{
8886+
"name": "export_id",
8887+
"in": "path",
8888+
"required": true,
8889+
"schema": {
8890+
"type": "integer",
8891+
"title": "Export Id"
8892+
}
8893+
}
8894+
],
8895+
"responses": {
8896+
"200": {
8897+
"description": "Successful Response",
8898+
"content": {
8899+
"application/json": {
8900+
"schema": {
8901+
"$ref": "#/components/schemas/Export"
8902+
}
8903+
}
8904+
}
8905+
},
8906+
"422": {
8907+
"description": "Validation Error",
8908+
"content": {
8909+
"application/json": {
8910+
"schema": {
8911+
"$ref": "#/components/schemas/HTTPValidationError"
8912+
}
8913+
}
8914+
}
8915+
}
8916+
}
8917+
}
8918+
},
88708919
"/exports/{export_id}": {
88718920
"get": {
88728921
"tags": [
@@ -13327,6 +13376,17 @@
1332713376
"title": "Format",
1332813377
"default": "json"
1332913378
},
13379+
"distribution": {
13380+
"anyOf": [
13381+
{
13382+
"type": "integer"
13383+
},
13384+
{
13385+
"type": "null"
13386+
}
13387+
],
13388+
"title": "Distribution"
13389+
},
1333013390
"id": {
1333113391
"type": "integer",
1333213392
"title": "Id"
@@ -13509,6 +13569,17 @@
1350913569
"title": "Format",
1351013570
"default": "json"
1351113571
},
13572+
"distribution": {
13573+
"anyOf": [
13574+
{
13575+
"type": "integer"
13576+
},
13577+
{
13578+
"type": "null"
13579+
}
13580+
],
13581+
"title": "Distribution"
13582+
},
1351213583
"schedule": {
1351313584
"anyOf": [
1351413585
{

docs/features/exports.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,17 @@ can always fetch the same URL for the latest results.
2727
|---|---|---|
2828
| `json` | Raw OpenSearch `_source` documents | Passthrough dump |
2929
| `csv` | Flattened rows of the most useful fields | Tags joined with `|` |
30-
| `misp` | MISP schema compatible JSON format | [MISP schema](https://github.com/MISP/misp-rfc) |
30+
| `misp` | A single [MISP-schema](https://github.com/MISP/misp-rfc) event | All matches are merged into one event named after the export |
3131
| `stix` | A STIX 2.1 bundle | Attributes are grouped into events and converted via the [misp-stix](https://github.com/MISP/misp-stix) library |
3232

33+
!!! note "MISP format"
34+
The `misp` format collects every matching attribute into a **single MISP
35+
event** (named after the export), even when the attributes come from
36+
different misp-workbench events. It requires a **distribution** level and
37+
keeps correlation enabled. The `uuid` and `id` fields are stripped from the
38+
event and its attributes, so importing the file always creates fresh
39+
records rather than colliding with existing ones.
40+
3341
!!! note "STIX limits"
3442
STIX 2.1 conversion is CPU-intensive, so STIX exports are capped at
3543
**10,000 records** — narrow the query (or use JSON/CSV) for larger result
@@ -50,6 +58,9 @@ can always fetch the same URL for the latest results.
5058
polls until it settles to `completed` or `failed`.
5159
4. Once `completed`, click ***Download*** to fetch the file.
5260

61+
Use ***Run now*** on any row to re-run an export on demand — it re-executes the
62+
stored query and overwrites the existing file in place.
63+
5364
Alternatively, from the ***explore*** view use the ***Save as export…*** option
5465
in the Download menu — it opens the export dialog pre-filled with the current
5566
query.
@@ -156,6 +167,7 @@ Required scopes: `exports:create`
156167
|---|---|---|---|
157168
| `GET` | `/exports/` | List all exports (paginated) | `exports:read` |
158169
| `POST` | `/exports/` | Create an export (runs immediately) | `exports:create` |
170+
| `POST` | `/exports/{id}/run` | Re-run an export in place (overwrites its file) | `exports:create` |
159171
| `GET` | `/exports/{id}` | Get an export | `exports:read` |
160172
| `PATCH` | `/exports/{id}/schedule` | Set, pause/resume, or clear the schedule | `exports:create` |
161173
| `GET` | `/exports/{id}/download` | Download the stored artifact | `exports:read` |
-8.69 KB
Loading
-5.84 KB
Loading

0 commit comments

Comments
 (0)