Skip to content

Commit 1f0a30e

Browse files
authored
Merge pull request #348 from MISP/fix-misp-exports-first-last-seen
fix: export first_seen, last_seen in ISO format, drop null attributes
2 parents b523131 + 4a11b67 commit 1f0a30e

3 files changed

Lines changed: 49 additions & 1 deletion

File tree

api/app/repositories/exports.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,14 +283,46 @@ def _strip_ids(obj: dict) -> None:
283283
obj.pop("uuid", None)
284284
obj.pop("id", None)
285285

286+
# first_seen/last_seen are stored as epoch seconds; MISP serializes them as
287+
# microsecond-precision ISO-8601 strings (e.g. "2026-06-19T00:00:00.000000+00:00").
288+
def _format_seen(obj: dict) -> None:
289+
for field in ("first_seen", "last_seen"):
290+
value = obj.get(field)
291+
if value is None:
292+
continue
293+
try:
294+
obj[field] = datetime.fromtimestamp(
295+
int(value), tz=timezone.utc
296+
).isoformat(timespec="microseconds")
297+
except (ValueError, TypeError, OverflowError) as exc:
298+
logger.debug(
299+
"Unable to format %s value %r as timestamp in MISP export: %s",
300+
field,
301+
value,
302+
exc,
303+
)
304+
286305
event = payload.get("Event", {})
287306
_strip_ids(event)
288307
for attribute in event.get("Attribute", []):
289308
_strip_ids(attribute)
309+
_format_seen(attribute)
290310
for misp_object in event.get("Object", []):
291311
_strip_ids(misp_object)
312+
_format_seen(misp_object)
292313
for attribute in misp_object.get("Attribute", []):
293314
_strip_ids(attribute)
315+
_format_seen(attribute)
316+
317+
# Drop keys with null values so the exported MISP JSON stays compact.
318+
def _drop_nulls(obj):
319+
if isinstance(obj, dict):
320+
return {k: _drop_nulls(v) for k, v in obj.items() if v is not None}
321+
if isinstance(obj, list):
322+
return [_drop_nulls(v) for v in obj]
323+
return obj
324+
325+
payload = _drop_nulls(payload)
294326

295327
content = json.dumps(payload, default=str, indent=2).encode("utf-8")
296328
return content, "json", "application/json", len(attributes)

api/app/tests/api/test_exports.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,13 +351,15 @@ def test_run_export_misp_format(
351351

352352
# Two attributes from two different misp-workbench events — they should
353353
# be merged into a single MISP event named after the export.
354+
first_seen_epoch = int(datetime(2026, 6, 19, tzinfo=timezone.utc).timestamp())
354355
hits = [
355356
{
356357
"uuid": "11111111-1111-4111-8111-111111111111",
357358
"type": "ip-dst",
358359
"value": "1.2.3.4",
359360
"category": "Network activity",
360361
"event_uuid": "aaaaaaaa-1111-4111-8111-111111111111",
362+
"first_seen": first_seen_epoch,
361363
},
362364
{
363365
"uuid": "22222222-2222-4222-8222-222222222222",
@@ -396,6 +398,17 @@ def test_run_export_misp_format(
396398
assert "uuid" not in event and "id" not in event
397399
for attr in event["Attribute"]:
398400
assert "uuid" not in attr and "id" not in attr
401+
# null-valued fields are dropped from the MISP output.
402+
assert None not in attr.values()
403+
# first_seen is emitted as a microsecond ISO-8601 string.
404+
seen = {
405+
a["value"]: a.get("first_seen") for a in event["Attribute"]
406+
}
407+
assert seen["1.2.3.4"] == "2026-06-19T00:00:00.000000+00:00"
408+
# the attribute without first_seen has it dropped entirely.
409+
assert "first_seen" not in next(
410+
a for a in event["Attribute"] if a["value"] == "5.6.7.8"
411+
)
399412
finally:
400413
db.delete(export)
401414
db.commit()

docs/features/exports.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ can always fetch the same URL for the latest results.
3636
different misp-workbench events. It requires a **distribution** level and
3737
keeps correlation enabled. The `uuid` and `id` fields are stripped from the
3838
event and its attributes, so importing the file always creates fresh
39-
records rather than colliding with existing ones.
39+
records rather than colliding with existing ones. `first_seen`/`last_seen`
40+
are emitted as microsecond ISO-8601 strings
41+
(e.g. `2026-06-19T00:00:00.000000+00:00`), and any field with a `null`
42+
value is omitted to keep the output compact.
4043

4144
!!! note "STIX limits"
4245
STIX 2.1 conversion is CPU-intensive, so STIX exports are capped at

0 commit comments

Comments
 (0)