Files
rengine/shared/models/note.py
T
Yogesh OjhaandClaude Opus 5 0fd89332e2 feat(notes): recon notes anchored to an asset
2.x had `recon_note.TodoNote` — a title, a body and an optional scan. 3.0
dropped it and replaced it with nothing, so the operator's own reasoning had
nowhere to live: the lead to come back to, the dead end not worth retesting,
the reason behind a conclusion.

A note anchors to a durable asset identity rather than to a scan row. A note
written on api.example.com during scan 2 is still on that host in scan 9,
because the anchor is the host name, not the `subdomains.id` scan 2 created.
Identity per dimension: host name, endpoint signature, `ip:port`, address, and
vulnerability fingerprint.

That gives the rollup for free. An asset sheet shows its own notes across every
scan; a scan shows notes for assets it observed, plus what was written on it; a
target shows everything under it; /notes holds the project. The scan reach is a
per-dimension EXISTS over the `TABLES` map `surface_scope` already holds.

Every note carries at least one tag, enforced by `min_length=1` on the create
model and a disabled Save in the composer. Tags are the project vocabulary from
`shared/models/tag.py`, so a tag means the same thing on a note as on a target.

`dimension` is a `SurfaceDimension` value, so notes inherit the labels, nouns
and ordering the rest of the product uses. No second asset-kind enum.

Surfaces: a Notes section in the web asset, endpoint, service and vulnerability
sheets; a Notes tab on a scan and on a target; and /notes with tag, dimension,
status and free-text filters. The IP sheet is left out — an address group folds
across targets, so it has no single rollup anchor.

Also gives the two ScrollAreas added with the focused-scan work a definite
height; `max-h` alone does not clip, and a short list now renders without a
scroller rather than showing an empty track.

Row markers on the result tables are not built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01799buekQvDvH41ns8EdhdH
2026-09-11 22:21:13 +05:30

141 lines
4.7 KiB
Python

import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, model_validator
from sqlmodel import Field, SQLModel
from shared.definitions.notes import (
MAX_ASSET_KEY,
MAX_ASSET_LABEL,
MAX_NOTE_BODY,
MAX_NOTE_TAGS,
MAX_NOTE_TITLE,
NoteStatus,
)
from shared.definitions.surface import SURFACE_ORDER
from shared.models.tag import TagSummary
from shared.utils.datetime import utc_now
class NoteTag(SQLModel, table=True):
__tablename__ = "note_tags"
note_id: uuid.UUID = Field(
foreign_key="notes.id", primary_key=True, ondelete="CASCADE"
)
tag_id: uuid.UUID = Field(
foreign_key="tags.id", primary_key=True, ondelete="CASCADE"
)
class Note(SQLModel, table=True):
__tablename__ = "notes"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
project_id: uuid.UUID = Field(
foreign_key="projects.id", index=True, ondelete="CASCADE"
)
target_id: uuid.UUID = Field(
foreign_key="targets.id", index=True, ondelete="CASCADE"
)
scan_id: uuid.UUID | None = Field(
default=None, foreign_key="scans.id", index=True, ondelete="SET NULL"
)
dimension: str | None = Field(default=None, max_length=32, index=True)
asset_key: str | None = Field(default=None, max_length=MAX_ASSET_KEY, index=True)
asset_label: str | None = Field(default=None, max_length=MAX_ASSET_LABEL)
title: str | None = Field(default=None, max_length=MAX_NOTE_TITLE)
body: str = Field(max_length=MAX_NOTE_BODY)
status: str = Field(default=NoteStatus.OPEN.value, max_length=16, index=True)
created_by: uuid.UUID = Field(foreign_key="users.id")
created_at: datetime = Field(default_factory=utc_now, index=True)
updated_at: datetime = Field(default_factory=utc_now)
def _clean_anchor(
dimension: str | None, asset_key: str | None
) -> tuple[str | None, str | None]:
dimension = (dimension or "").strip() or None
asset_key = (asset_key or "").strip() or None
if dimension is not None and dimension not in SURFACE_ORDER:
msg = f"Unknown dimension '{dimension}'."
raise ValueError(msg)
if (dimension is None) != (asset_key is None):
msg = "An asset note needs both a dimension and an asset."
raise ValueError(msg)
return dimension, asset_key
class NoteCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
target_id: uuid.UUID
scan_id: uuid.UUID | None = None
dimension: str | None = Field(default=None, max_length=32)
asset_key: str | None = Field(default=None, max_length=MAX_ASSET_KEY)
asset_label: str | None = Field(default=None, max_length=MAX_ASSET_LABEL)
title: str | None = Field(default=None, max_length=MAX_NOTE_TITLE)
body: str = Field(min_length=1, max_length=MAX_NOTE_BODY)
status: str = Field(default=NoteStatus.OPEN.value, max_length=16)
tag_ids: list[uuid.UUID] = Field(min_length=1, max_length=MAX_NOTE_TAGS)
@model_validator(mode="after")
def _anchored_and_tagged(self):
self.dimension, self.asset_key = _clean_anchor(self.dimension, self.asset_key)
if not self.body.strip():
msg = "Write something in the note."
raise ValueError(msg)
self.body = self.body.strip()
self.tag_ids = list(dict.fromkeys(self.tag_ids))
return self
class NoteUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
title: str | None = Field(default=None, max_length=MAX_NOTE_TITLE)
body: str | None = Field(default=None, max_length=MAX_NOTE_BODY)
status: str | None = Field(default=None, max_length=16)
tag_ids: list[uuid.UUID] | None = Field(default=None, max_length=MAX_NOTE_TAGS)
@model_validator(mode="after")
def _keeps_a_tag(self):
if self.tag_ids is not None:
self.tag_ids = list(dict.fromkeys(self.tag_ids))
if not self.tag_ids:
msg = "A note needs at least one tag."
raise ValueError(msg)
if self.body is not None:
self.body = self.body.strip()
if not self.body:
msg = "Write something in the note."
raise ValueError(msg)
return self
class NoteRead(BaseModel):
id: uuid.UUID
project_id: uuid.UUID
target_id: uuid.UUID
target_value: str
scan_id: uuid.UUID | None = None
dimension: str | None = None
asset_key: str | None = None
asset_label: str | None = None
title: str | None = None
body: str
status: str
tags: list[TagSummary] = Field(default_factory=list)
created_by: uuid.UUID
author: str | None = None
created_at: datetime
updated_at: datetime
class NoteCount(BaseModel):
"""Note totals for one asset, scan or target."""
key: str
total: int = 0
open: int = 0