mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-20 23:31:31 +02:00
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
141 lines
4.7 KiB
Python
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
|