diff --git a/.env.example b/.env.example index b27551c..f535a65 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,23 @@ DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS=0 DJANGO_SECURE_HSTS_PRELOAD=0 DATABASE_URL=postgres://goggles:replace-with-long-random-database-password@db:5432/goggles GOGGLES_MAX_DUMP_BYTES=52428800 +GOGGLES_MAX_DUMP_RECORDS=50000 +GOGGLES_MAX_JSONL_LINE_BYTES=2097152 +GOGGLES_MAX_ACTION_EVENTS_PER_REQUEST=50000 +GOGGLES_AGENT_EXPORT_MAX_EVENTS=50000 +GOGGLES_FILE_UPLOAD_MEMORY_BYTES=1048576 +GOGGLES_UPLOADS_ENABLED=1 +GOGGLES_EXPORTS_ENABLED=1 +GOGGLES_WEB_MEMORY_LIMIT=16g +GOGGLES_WEB_CPUS=2.0 +GOGGLES_WEB_PIDS_LIMIT=256 +GOGGLES_WEB_LOG_MAX_SIZE=20m +GOGGLES_WEB_LOG_MAX_FILES=5 +GOGGLES_WEB_WORKERS=3 +GOGGLES_WEB_THREADS=4 +GOGGLES_WEB_TIMEOUT_SECONDS=300 +GOGGLES_WEB_MAX_REQUESTS=500 +GOGGLES_WEB_MAX_REQUESTS_JITTER=50 GLITCHTIP_DSN=https://d550950965a64eb689f5e289416faa42@glitch.ipf.dev/1 GLITCHTIP_SECURITY_ENDPOINT=https://glitch.ipf.dev/api/1/security/?glitchtip_key=d550950965a64eb689f5e289416faa42 GLITCHTIP_ENVIRONMENT=production diff --git a/README.md b/README.md index d049874..2529fe4 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,23 @@ DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS=0 DJANGO_SECURE_HSTS_PRELOAD=0 DATABASE_URL=postgres://goggles:replace-with-long-random-database-password@db:5432/goggles GOGGLES_MAX_DUMP_BYTES=52428800 +GOGGLES_MAX_DUMP_RECORDS=50000 +GOGGLES_MAX_JSONL_LINE_BYTES=2097152 +GOGGLES_MAX_ACTION_EVENTS_PER_REQUEST=50000 +GOGGLES_AGENT_EXPORT_MAX_EVENTS=50000 +GOGGLES_FILE_UPLOAD_MEMORY_BYTES=1048576 +GOGGLES_UPLOADS_ENABLED=1 +GOGGLES_EXPORTS_ENABLED=1 +GOGGLES_WEB_MEMORY_LIMIT=16g +GOGGLES_WEB_CPUS=2.0 +GOGGLES_WEB_PIDS_LIMIT=256 +GOGGLES_WEB_LOG_MAX_SIZE=20m +GOGGLES_WEB_LOG_MAX_FILES=5 +GOGGLES_WEB_WORKERS=3 +GOGGLES_WEB_THREADS=4 +GOGGLES_WEB_TIMEOUT_SECONDS=300 +GOGGLES_WEB_MAX_REQUESTS=500 +GOGGLES_WEB_MAX_REQUESTS_JITTER=50 GLITCHTIP_DSN=https://d550950965a64eb689f5e289416faa42@glitch.ipf.dev/1 GLITCHTIP_SECURITY_ENDPOINT=https://glitch.ipf.dev/api/1/security/?glitchtip_key=d550950965a64eb689f5e289416faa42 GLITCHTIP_ENVIRONMENT=production @@ -135,6 +152,18 @@ POSTGRES_USER=goggles POSTGRES_PASSWORD=replace-with-long-random-database-password ``` +Compose reads container resource and logging limits while it parses the Compose +file, before a service's `env_file` is applied. The default `.env` works for +both purposes automatically. When using a custom file, supply it through both +mechanisms so values such as `GOGGLES_WEB_MEMORY_LIMIT` are not silently left at +their defaults: + +```sh +export GOGGLES_ENV_FILE=/absolute/path/to/goggles.env +docker compose --env-file "$GOGGLES_ENV_FILE" config +docker compose --env-file "$GOGGLES_ENV_FILE" up -d --build +``` + `GLITCHTIP_DSN` enables server-side exception reporting and 5% performance tracing by default. `GLITCHTIP_SECURITY_ENDPOINT` enables report-only CSP violation reporting in browsers; that endpoint contains a public project key and @@ -285,7 +314,17 @@ docker compose exec web python manage.py shell -c "from forensics.models import - Audit logs preserve raw engine ids, group refs, message ids, digests, payload metadata, raw lines, raw uploaded text, user agents, and source IPs; protect the database and backups accordingly. - Brain disk encryption is the expected at-rest protection for v1. -- Upload size defaults to 50 MiB via `GOGGLES_MAX_DUMP_BYTES`. +- Upload size defaults to 50 MiB via `GOGGLES_MAX_DUMP_BYTES`. Processing is + additionally bounded to 50,000 non-empty records and 2 MiB per JSONL line; + multipart bodies spool to disk after 1 MiB. Over-complex uploads are retained + as one quarantined raw artifact instead of being expanded into per-line ORM + objects. +- Projection APIs default to 100 rows and cap requests at 500. Action-history + scans and synchronous agent exports have separate 50,000-event safety caps. +- The Compose web service defaults to a configurable 16 GiB no-swap cgroup + limit (`GOGGLES_WEB_MEMORY_LIMIT`) and recycles Gunicorn workers after a + jittered request budget. Keep those host-protection limits in place even if + application limits are raised. - Purge stored audit data without removing users or upload tokens with `manage.py purge_audit_data`. Run it with `--dry-run` first, then `--confirm-delete-audit-data` to perform a deployment cutover. Rebuild the diff --git a/config/settings.py b/config/settings.py index 8dab1b9..bca04f2 100644 --- a/config/settings.py +++ b/config/settings.py @@ -276,19 +276,38 @@ def scrub_glitchtip_event(event, _hint): LOGOUT_REDIRECT_URL = "login" GOGGLES_MAX_DUMP_BYTES = int(os.environ.get("GOGGLES_MAX_DUMP_BYTES", 50 * 1024 * 1024)) +GOGGLES_MAX_DUMP_RECORDS = int(os.environ.get("GOGGLES_MAX_DUMP_RECORDS", 50_000)) +GOGGLES_MAX_JSONL_LINE_BYTES = int(os.environ.get("GOGGLES_MAX_JSONL_LINE_BYTES", 2 * 1024 * 1024)) +GOGGLES_MAX_ACTION_EVENTS_PER_REQUEST = int( + os.environ.get("GOGGLES_MAX_ACTION_EVENTS_PER_REQUEST", 50_000) +) +GOGGLES_AGENT_EXPORT_MAX_EVENTS = int(os.environ.get("GOGGLES_AGENT_EXPORT_MAX_EVENTS", 50_000)) GOGGLES_UPLOADS_ENABLED = env_bool("GOGGLES_UPLOADS_ENABLED", True) # Operational kill-switch for the streaming group-export endpoint, mirroring the # upload toggle. Lets an operator shed a resource-intensive read surface without a # redeploy. GOGGLES_EXPORTS_ENABLED = env_bool("GOGGLES_EXPORTS_ENABLED", True) DATA_UPLOAD_MAX_MEMORY_SIZE = GOGGLES_MAX_DUMP_BYTES -FILE_UPLOAD_MAX_MEMORY_SIZE = GOGGLES_MAX_DUMP_BYTES +FILE_UPLOAD_MAX_MEMORY_SIZE = min( + GOGGLES_MAX_DUMP_BYTES, + int(os.environ.get("GOGGLES_FILE_UPLOAD_MEMORY_BYTES", 1024 * 1024)), +) +for setting_name in ( + "GOGGLES_MAX_DUMP_BYTES", + "GOGGLES_MAX_DUMP_RECORDS", + "GOGGLES_MAX_JSONL_LINE_BYTES", + "GOGGLES_MAX_ACTION_EVENTS_PER_REQUEST", + "GOGGLES_AGENT_EXPORT_MAX_EVENTS", + "FILE_UPLOAD_MAX_MEMORY_SIZE", +): + if globals()[setting_name] <= 0: + raise ImproperlyConfigured(f"{setting_name} must be a positive integer.") # The upload endpoint only ever ingests a single file part, and every part at -# or under FILE_UPLOAD_MAX_MEMORY_SIZE is buffered in RAM. Capping the number -# of files at 1 stops a multipart request from accumulating many sub-threshold -# parts (each passing the per-file size check) into multiple gigabytes of -# resident memory. MaxDumpSizeUploadHandler additionally bounds the cumulative -# bytes across parts as defense in depth. +# or under FILE_UPLOAD_MAX_MEMORY_SIZE is buffered in RAM. Keep that threshold +# much smaller than the accepted dump size so normal multipart uploads spool to +# a temporary file before ingestion. Capping the number of files at 1 stops a +# multipart request from accumulating many sub-threshold parts, while +# MaxDumpSizeUploadHandler additionally bounds cumulative bytes across parts. DATA_UPLOAD_MAX_NUMBER_FILES = 1 SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") diff --git a/docker-compose.yml b/docker-compose.yml index 00cf924..8b87c44 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,14 @@ services: build: . init: true restart: unless-stopped + # Keep a pathological request inside the Goggles cgroup instead of letting + # it exhaust host RAM and swap. Equal memory/swap limits disable additional + # swap allocation for this container. + mem_limit: ${GOGGLES_WEB_MEMORY_LIMIT:-16g} + memswap_limit: ${GOGGLES_WEB_MEMORY_LIMIT:-16g} + mem_swappiness: 0 + cpus: ${GOGGLES_WEB_CPUS:-2.0} + pids_limit: ${GOGGLES_WEB_PIDS_LIMIT:-256} env_file: ${GOGGLES_ENV_FILE:-.env} depends_on: db: @@ -45,9 +53,18 @@ services: command: > sh -c "python manage.py migrate --noinput && python manage.py collectstatic --noinput && - gunicorn config.wsgi:application --bind 0.0.0.0:8000 - --workers 3 --threads 4 --timeout 300 - --max-requests 500 --max-requests-jitter 50" + exec gunicorn config.wsgi:application + --bind 0.0.0.0:8000 + --workers $${GOGGLES_WEB_WORKERS:-3} + --threads $${GOGGLES_WEB_THREADS:-4} + --timeout $${GOGGLES_WEB_TIMEOUT_SECONDS:-300} + --max-requests $${GOGGLES_WEB_MAX_REQUESTS:-500} + --max-requests-jitter $${GOGGLES_WEB_MAX_REQUESTS_JITTER:-50}" + logging: + driver: json-file + options: + max-size: ${GOGGLES_WEB_LOG_MAX_SIZE:-20m} + max-file: ${GOGGLES_WEB_LOG_MAX_FILES:-5} healthcheck: test: [ diff --git a/docs/api-v1.md b/docs/api-v1.md index eb4e100..55c8276 100644 --- a/docs/api-v1.md +++ b/docs/api-v1.md @@ -44,15 +44,19 @@ Projection endpoints support these filters where the field applies: - `epoch` - `from_ms` - `to_ms` -- `limit`: defaults to `500`, capped at `5000` +- `limit`: defaults to `100`, capped at `500` - `offset`: defaults to `0` +Compatibility note: releases before the memory-pressure hardening defaulted to +500 rows and allowed up to 5,000. API consumers that relied on those larger +pages must now follow `has_more` and advance `offset` in batches of at most 500. + Paginated responses include: ```json { "pagination": { - "limit": 500, + "limit": 100, "offset": 0, "returned": 10, "has_more": false, @@ -68,6 +72,10 @@ Action attribution endpoints also support: - `origin`: for example `local_user`, `system`, or `observed_group_event` - `action`: the normalized human-action name, such as `send_message` +Action pagination includes `scan_truncated` and `scan_limit`. If +`scan_truncated` is true, Goggles bounded the request to the newest safe action +window; use narrower filters rather than requesting an unbounded group history. + ## Sensitivity Metadata Derived objects that may contain decrypted content, full author identifiers, raw diff --git a/docs/deployment.md b/docs/deployment.md index 4e0516c..d6ff745 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -54,3 +54,43 @@ Capacity model — size the database for it: The edge proxy (Caddy) streams `reverse_proxy` responses by default, so no proxy change is required; `nginx` serves only static assets and is not in the export request path. + +## Memory-pressure deployment + +The performance hardening release does **not** require purging audit data. +Deploy it with uploads paused so no old worker continues a high-memory ingest: + +Set the Compose environment source once before running the commands below. This +explicit `--env-file` is required for Compose-time resource and logging limits; +the service-level `env_file` alone only populates the container environment. + +```sh +export GOGGLES_ENV_FILE="${GOGGLES_ENV_FILE:-.env}" +``` + +1. Set `GOGGLES_UPLOADS_ENABLED=0` in the production environment. +2. Recreate the web service so the changed environment and Compose resource + limits take effect: + `docker compose --env-file "$GOGGLES_ENV_FILE" up -d --build --force-recreate web`. +3. Confirm the container has the expected 16 GiB memory/no-swap boundary (or + the value set in `GOGGLES_WEB_MEMORY_LIMIT`) by resolving the actual Compose + container rather than assuming a project-specific name: + `web_container_id="$(docker compose --env-file "$GOGGLES_ENV_FILE" ps -q web)"; test -n "$web_container_id"; docker inspect "$web_container_id"`. + Wait for the health check to pass. +4. While uploads remain paused, exercise an authenticated group overview, + delivery tab, and evidence tab while watching `docker stats`. +5. Set `GOGGLES_UPLOADS_ENABLED=1` and recreate the web service again with the + same `--env-file` command from step 2. +6. Perform a representative upload while watching `docker stats`, then recheck + the group overview, delivery tab, and evidence tab. + +Do not use `purge_audit_data` for this deployment. The query changes avoid +hydrating stored raw bodies without changing their schema or deleting evidence. + +The Compose service keeps three threaded workers by default, recycles each +after a jittered 500-request budget, and constrains the whole web container to +a configurable 16 GiB default (`GOGGLES_WEB_MEMORY_LIMIT`) with no additional +swap. CPU, PID, and Docker log rotation limits are configurable through the +adjacent `GOGGLES_WEB_*` settings. During an incident, set +`GOGGLES_WEB_WORKERS=1` before the recreate to prevent concurrent amplification; +restore the measured production worker count only after memory remains stable. diff --git a/forensics/admin.py b/forensics/admin.py index b99e23b..d6120b9 100644 --- a/forensics/admin.py +++ b/forensics/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin +from django.db.models.functions import Length, Substr from django.urls import reverse from django.utils.html import format_html from django.utils.http import urlencode @@ -97,6 +98,12 @@ class AuditFileAdmin(admin.ModelAdmin): "events_link", ) + def get_queryset(self, request): + # Admin changelists/autocomplete render metadata only. Keep the complete + # upload body out of those page-sized query results; the change-page + # preview intentionally resolves the one deferred body it displays. + return super().get_queryset(request).defer("raw_text", "user_agent") + @admin.display(description="Raw text (preview)") def raw_text_preview(self, obj): """Bounded, read-only preview of the uploaded JSONL. @@ -167,6 +174,78 @@ class AuditEventAdmin(admin.ModelAdmin): ) autocomplete_fields = ("audit_file", "group") show_full_result_count = False + # Raw evidence is immutable forensic input and can be as large as the + # complete quarantined upload. Keep it out of Django's generated form and + # expose one bounded line preview plus an intentional link to the complete + # evidence response. + exclude = ( + "raw_line", + "raw_event", + "raw_kind", + "raw_context", + "context_human_action", + "context_transport", + "context_engine", + "context_group", + "context_convergence", + "context_source", + ) + readonly_fields = ("raw_line_preview", "evidence_link") + + def get_queryset(self, request): + return ( + super() + .get_queryset(request) + .defer( + "raw_line", + "raw_event", + "raw_kind", + "raw_context", + "context_human_action", + "context_transport", + "context_engine", + "context_group", + "context_convergence", + "context_source", + ) + ) + + @admin.display(description="Raw line (preview)") + def raw_line_preview(self, obj): + if obj is None or obj.pk is None: + return "\u2014" + # Slice in SQL: resolving the deferred TextField first would still pull + # a quarantined 50 MiB upload into the worker merely to render 2,000 + # characters of it. + row = ( + AuditEvent.objects.filter(pk=obj.pk) + .annotate( + preview=Substr("raw_line", 1, RAW_TEXT_PREVIEW_CHARS), + total_chars=Length("raw_line"), + ) + .values("preview", "total_chars") + .get() + ) + preview = row["preview"] or "" + total_chars = row["total_chars"] or 0 + notice = ( + f"Showing first {RAW_TEXT_PREVIEW_CHARS} of {total_chars} characters." + if total_chars > RAW_TEXT_PREVIEW_CHARS + else f"{total_chars} characters." + ) + return format_html( + '

{}

{}
', + notice, + preview, + ) + + @admin.display(description="Complete evidence") + def evidence_link(self, obj): + if obj is None or obj.pk is None: + return "\u2014" + url = reverse("api-event-evidence", kwargs={"event_id": obj.pk}) + return format_html('Open JSON evidence', url) def lookup_allowed(self, lookup, value, request): if lookup == "audit_file__id__exact": @@ -181,6 +260,9 @@ class AnalysisRunAdmin(admin.ModelAdmin): autocomplete_fields = ("group", "created_by") readonly_fields = ("created_at",) + def get_queryset(self, request): + return super().get_queryset(request).defer("report_json") + @admin.register(DeliveryArtifact) class DeliveryArtifactAdmin(admin.ModelAdmin): diff --git a/forensics/analysis.py b/forensics/analysis.py index 5c2e9d0..f9df396 100644 --- a/forensics/analysis.py +++ b/forensics/analysis.py @@ -128,6 +128,11 @@ def audit_files_for_group(group): # both the groups (M2M) and events relations. return ( AuditFile.objects.filter(groups=group) + # ``raw_text`` can be tens of MiB and none of the group metadata views + # consume it. Loading it here made every overview/evidence/export + # metadata query retain the full body of every related upload. The raw + # body remains available through the dedicated detail/download paths. + .defer("raw_text", "user_agent") .annotate( group_event_count=Count("events", filter=Q(events__group=group), distinct=True), ) diff --git a/forensics/ingest.py b/forensics/ingest.py index b8f73b1..c967e06 100644 --- a/forensics/ingest.py +++ b/forensics/ingest.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from typing import Any +from django.conf import settings from django.db import IntegrityError, transaction from django.db.backends.base.operations import BaseDatabaseOperations from django.db.models import Case, IntegerField, Value, When @@ -128,6 +129,10 @@ class ParsedLine: errors: list[str] +class AuditLogComplexityError(ValueError): + """The upload is byte-bounded but too complex to materialize safely.""" + + def iter_jsonl_record_lines(raw_text: str) -> Iterator[str]: """Yield JSONL records split only on the LF record delimiter. @@ -190,11 +195,37 @@ def ingest_audit_log_bytes( ) file_sha256 = hashlib.sha256(dump_bytes).hexdigest() - existing = AuditFile.objects.filter(file_sha256=file_sha256).first() + existing = ( + AuditFile.objects.defer("raw_text", "user_agent").filter(file_sha256=file_sha256).first() + ) if existing is not None: return IngestionResult(audit_file=existing, created=False) - parsed_lines = parse_jsonl(raw_text) + try: + parsed_lines = parse_jsonl(raw_text) + except AuditLogComplexityError as exc: + return save_invalid_upload( + fallback_group_slug=fallback_group_slug, + fallback_group_name=fallback_group_name, + upload_token=upload_token, + uploaded_by=uploaded_by, + source_ip=source_ip, + user_agent=user_agent, + source_name=source_name, + source_account_label=source_account_label, + source_device_label=source_device_label, + source_device_id=source_device_id, + source_device_name=source_device_name, + source_platform=source_platform, + source_app_version=source_app_version, + source_upload_trigger=source_upload_trigger, + source_account_pubkey_hex=source_account_pubkey_hex, + source_account_npub=source_account_npub, + content_type=content_type, + dump_bytes=dump_bytes, + raw_text=raw_text, + error=f"audit log exceeds safe processing limits: {exc}", + ) metadata = file_metadata(parsed_lines) # Account identity is sourced from the JSONL body (source_context), not from # upload headers. Backfill the per-file account fields so file-level views @@ -257,7 +288,7 @@ def ingest_audit_log_bytes( refresh_group_rollups(locked_group_ids) return IngestionResult(audit_file=audit_file, created=True) except IntegrityError: - audit_file = AuditFile.objects.get(file_sha256=file_sha256) + audit_file = AuditFile.objects.defer("raw_text", "user_agent").get(file_sha256=file_sha256) return IngestionResult(audit_file=audit_file, created=False) except Exception as exc: # Defense-in-depth: any *other* failure while creating events (e.g. a @@ -323,7 +354,9 @@ def save_invalid_upload( error: str, ) -> IngestionResult: file_sha256 = hashlib.sha256(dump_bytes).hexdigest() - existing = AuditFile.objects.filter(file_sha256=file_sha256).first() + existing = ( + AuditFile.objects.defer("raw_text", "user_agent").filter(file_sha256=file_sha256).first() + ) if existing is not None: return IngestionResult(audit_file=existing, created=False) fallback_group = group_for_slug(fallback_group_slug, fallback_group_name) @@ -367,16 +400,32 @@ def save_invalid_upload( AuditGroup.objects.filter(id=fallback_group.id).update(updated_at=timezone.now()) return IngestionResult(audit_file=audit_file, created=True) except IntegrityError: - audit_file = AuditFile.objects.get(file_sha256=file_sha256) + audit_file = AuditFile.objects.defer("raw_text", "user_agent").get(file_sha256=file_sha256) return IngestionResult(audit_file=audit_file, created=False) def parse_jsonl(raw_text: str) -> list[ParsedLine]: parsed_lines = [] for line_number, raw_line in enumerate(iter_jsonl_record_lines(raw_text), start=1): + # UTF-8 can use more than one byte per character. Avoid encoding an + # already-obviously-oversized line, then enforce the exact byte ceiling. + # Check before stripping/skipping so a whitespace-only line cannot + # bypass the per-record allocation bound. + if ( + len(raw_line) > settings.GOGGLES_MAX_JSONL_LINE_BYTES + or len(raw_line.encode("utf-8")) > settings.GOGGLES_MAX_JSONL_LINE_BYTES + ): + raise AuditLogComplexityError( + f"line {line_number} exceeds maximum of " + f"{settings.GOGGLES_MAX_JSONL_LINE_BYTES} UTF-8 bytes" + ) raw_line = raw_line.strip() if not raw_line: continue + if len(parsed_lines) >= settings.GOGGLES_MAX_DUMP_RECORDS: + raise AuditLogComplexityError( + f"record count exceeds maximum of {settings.GOGGLES_MAX_DUMP_RECORDS}" + ) data = None errors = [] try: diff --git a/forensics/management/commands/rebuild_audit_projections.py b/forensics/management/commands/rebuild_audit_projections.py index 2dad5a5..a50f1f5 100644 --- a/forensics/management/commands/rebuild_audit_projections.py +++ b/forensics/management/commands/rebuild_audit_projections.py @@ -100,7 +100,7 @@ def groups_from_audit_file_ids(audit_file_ids: list[int]) -> list[AuditGroup]: seen_group_ids = set() for audit_file_id in audit_file_ids: try: - audit_file = AuditFile.objects.get(id=audit_file_id) + audit_file = AuditFile.objects.defer("raw_text", "user_agent").get(id=audit_file_id) except AuditFile.DoesNotExist as exc: raise CommandError("No audit file matched one of the requested ids.") from exc group_ids = set(audit_file.groups.values_list("id", flat=True)) diff --git a/forensics/projections.py b/forensics/projections.py index 5526863..5c70dea 100644 --- a/forensics/projections.py +++ b/forensics/projections.py @@ -54,6 +54,53 @@ class MessageRef: "unrecoverable", } +# Projection work must never hydrate the verbatim per-line JSON or the parent +# AuditFile row. In particular, ``select_related("audit_file")`` repeats the +# complete AuditFile.raw_text once for every event in the SQL result: a 50 MiB +# upload with 1,000 events becomes roughly 50 GiB before projection even starts. +# Keep this list explicit so projection helpers can use normal model attributes +# without triggering deferred-field queries while the heavy evidence columns +# remain outside the worker heap. +PROJECTION_EVENT_FIELDS = ( + "id", + "audit_file_id", + "group_id", + "line_number", + "wall_time_ms", + "engine_id", + "account_ref", + "audit_data_mode", + "event_type", + "context_convergence", + "context_transport", + "human_action_message_ids", + "msg_id", + "outbound_msg_id", + "outbound_welcome_msg_ids", + "invalidated_msg_id", + "raw_kind", + "relay_urls", + "accepted_relay_urls", + "failed_relays", + "required_acks", + "met_required_acks", + "epoch", + "pending_epoch", + "payload_len", + "payload_digest", + "outcome", + "outcome_kind", + "reason", + "new_state", + "pending_kind", + "result_kind", + "selected_branch_id", + "selected_fork_epoch", + "selected_tip_epoch", + "current_tip_epoch", + "max_rewind_commits", +) + @dataclass class ProjectionState: @@ -98,7 +145,7 @@ def project_file_events(audit_file: AuditFile, group_ids: list[int]) -> None: AuditGroup.objects.select_for_update().filter(id__in=group_ids).order_by("id") ) events = list( - AuditEvent.objects.select_related("audit_file") + AuditEvent.objects.only(*PROJECTION_EVENT_FIELDS) .filter( structural_quarantine_exclusion(), audit_file=audit_file, @@ -168,7 +215,7 @@ def groups_with_out_of_order_convergence_backfill( "context_convergence", ) ) - for event in stored: + for event in stored.iterator(chunk_size=2_000): if event.group_id in backfilled: continue if not event_may_need_inferred_convergence_state(event): @@ -242,12 +289,23 @@ def run_is_active_inferred(run: ConvergenceRun) -> bool: # Only the run's most recent evidence row decides whether it is still open, # so fetch just that row instead of loading the whole evidence set. NULL # ``wall_time_ms`` sorts last to match the projection ordering above. - last_event = run.evidence_events.order_by( - F("wall_time_ms").asc(nulls_last=True), - "engine_id", - "line_number", - "id", - ).last() + last_event = ( + run.evidence_events.only( + "id", + "event_type", + "new_state", + "wall_time_ms", + "engine_id", + "line_number", + ) + .order_by( + F("wall_time_ms").asc(nulls_last=True), + "engine_id", + "line_number", + "id", + ) + .last() + ) if last_event is None: return True return not inferred_convergence_event_is_terminal(last_event, run.phase) @@ -266,7 +324,7 @@ def rebuild_locked_group_projections(group: AuditGroup) -> None: """ clear_group_projections(group) events = ( - AuditEvent.objects.select_related("audit_file") + AuditEvent.objects.only(*PROJECTION_EVENT_FIELDS) .filter( structural_quarantine_exclusion(), group=group, @@ -275,7 +333,7 @@ def rebuild_locked_group_projections(group: AuditGroup) -> None: .order_by("wall_time_ms", "engine_id", "line_number", "id") ) state = ProjectionState(active_inferred_convergence_runs={}) - for event in events: + for event in events.iterator(chunk_size=500): project_event(event, state) @@ -465,7 +523,7 @@ def project_network_observation( wire = wire_for_event(event, kind) message_id = artifact.artifact_id if artifact else str(kind.get("msg_id") or event.msg_id or "") NetworkObservation.objects.create( - group=event.group, + group_id=event.group_id, artifact=artifact, audit_event=event, direction=network_direction(event.event_type), @@ -575,7 +633,7 @@ def project_convergence(event: AuditEvent, state: ProjectionState | None = None) if state: state.active_inferred_convergence_runs.pop(inferred_convergence_key(event), None) run, _created = ConvergenceRun.objects.get_or_create( - group=event.group, + group_id=event.group_id, engine_id=event.engine_id, run_id=run_id, defaults={"account_ref": event.account_ref, "inferred": False}, @@ -600,7 +658,7 @@ def inferred_convergence_run_for_event( if active is not None: return active run = ConvergenceRun.objects.create( - group=event.group, + group_id=event.group_id, engine_id=event.engine_id, run_id=f"inferred-{event.engine_id}-{event.id}", account_ref=event.account_ref, @@ -730,7 +788,7 @@ def project_convergence_decision(run: ConvergenceRun, kind: dict[str, Any]) -> N def project_state_delta(event: AuditEvent) -> None: kind = event.raw_kind if isinstance(event.raw_kind, dict) else {} StateDelta.objects.create( - group=event.group, + group_id=event.group_id, audit_event=event, epoch=event.epoch, change_kind=str(kind.get("change_kind") or event.outcome_kind or ""), @@ -750,7 +808,7 @@ def project_state_delta(event: AuditEvent) -> None: def project_epoch_transition(event: AuditEvent) -> None: EpochStateTransition.objects.create( - group=event.group, + group_id=event.group_id, audit_event=event, engine_id=event.engine_id, account_ref=event.account_ref, diff --git a/forensics/templates/forensics/partials/group_files.html b/forensics/templates/forensics/partials/group_files.html index 5368a52..88f4bd1 100644 --- a/forensics/templates/forensics/partials/group_files.html +++ b/forensics/templates/forensics/partials/group_files.html @@ -1,6 +1,9 @@
Audit files
+ {% if audit_files_limited %} +

Showing the first {{ tab_event_limit }} audit files.

+ {% endif %} {% if audit_files %} diff --git a/forensics/templates/forensics/partials/group_overview.html b/forensics/templates/forensics/partials/group_overview.html index ec011d6..1c07067 100644 --- a/forensics/templates/forensics/partials/group_overview.html +++ b/forensics/templates/forensics/partials/group_overview.html @@ -78,6 +78,9 @@ user intent separated from engine/system stamps
+ {% if overview.action_events_limited %} +

Action attribution is limited to the newest safe processing window. Use narrower filters in the JSON API.

+ {% endif %}
{% for row in overview.action_origin_counts %}
diff --git a/forensics/tests.py b/forensics/tests.py index d3ee21c..f89ff91 100644 --- a/forensics/tests.py +++ b/forensics/tests.py @@ -95,6 +95,36 @@ DIGEST_A = "aa" * 32 DIGEST_B = "bb" * 32 +HEAVY_EVENT_SELECT_COLUMNS = { + field: f'"forensics_auditevent"."{field}"' + for field in ( + "raw_line", + "raw_event", + "raw_kind", + "raw_context", + "context_human_action", + "context_transport", + "context_engine", + "context_group", + "context_convergence", + "context_source", + ) +} +HEAVY_BULK_SELECT_COLUMNS = ( + '"forensics_auditfile"."raw_text"', + *HEAVY_EVENT_SELECT_COLUMNS.values(), +) + + +def heavy_bulk_selects(captured_queries, *, allowed_columns=()): + prohibited_columns = set(HEAVY_BULK_SELECT_COLUMNS) - set(allowed_columns) + return [ + query["sql"] + for query in captured_queries + if query["sql"].lstrip().upper().startswith("SELECT") + and any(column in query["sql"] for column in prohibited_columns) + ] + def audit_event( seq, @@ -1617,16 +1647,27 @@ def test_v2_upload_builds_audit_projections(self): User.objects.create_user(username="analyst", password="correct horse battery staple") self.client.login(username="analyst", password="correct horse battery staple") - api_response = self.client.get( - reverse("api-group-projections", kwargs={"slug": group.slug}), - {"engine_id": ENGINE_ALICE}, - ) + with CaptureQueriesContext(connection) as projection_api_queries: + api_response = self.client.get( + reverse("api-group-projections", kwargs={"slug": group.slug}), + {"engine_id": ENGINE_ALICE}, + ) self.assertEqual(api_response.status_code, 200) payload = api_response.json() + self.assertEqual( + heavy_bulk_selects( + projection_api_queries.captured_queries, + allowed_columns=( + HEAVY_EVENT_SELECT_COLUMNS["raw_kind"], + HEAVY_EVENT_SELECT_COLUMNS["context_source"], + ), + ), + [], + ) self.assertEqual(payload["schema_version"], "goggles-audit-projections/v1") self.assertEqual(payload["filters"]["engine_id"], ENGINE_ALICE) - self.assertEqual(payload["pagination"]["delivery_artifacts"]["limit"], 500) + self.assertEqual(payload["pagination"]["delivery_artifacts"]["limit"], 100) self.assertEqual( payload["delivery_artifacts"][0]["decoded_payload"]["text"], "hello from Alice" ) @@ -2492,6 +2533,62 @@ def test_invalid_jsonl_returns_400_and_saves_quarantined_upload(self): self.assertEqual(bad_event.raw_line, "{not-json}") self.assertIn("JSON", bad_event.validation_error) + @override_settings(GOGGLES_MAX_DUMP_RECORDS=1) + def test_record_limit_quarantines_raw_upload_before_object_expansion(self): + raw_token, _token = UploadToken.issue("bounded parser") + body = representative_audit_log() + + response = self.client.post( + reverse("api-audit-log-upload"), + data=body, + content_type="application/x-ndjson", + HTTP_AUTHORIZATION=f"Bearer {raw_token}", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("record count exceeds maximum of 1", response.json()["error"]) + audit_file = AuditFile.objects.get() + self.assertEqual(audit_file.raw_text, body) + self.assertEqual(audit_file.events.count(), 1) + self.assertEqual(audit_file.events.get().raw_line, body) + + def test_line_byte_limit_quarantines_raw_upload_before_json_loads(self): + raw_token, _token = UploadToken.issue("bounded parser") + body = jsonl(audit_event_v2(0, kind={"type": "recorder_started", "recorder": "mdk"})) + byte_limit = len(body.rstrip("\n").encode("utf-8")) - 1 + + with self.settings(GOGGLES_MAX_JSONL_LINE_BYTES=byte_limit): + response = self.client.post( + reverse("api-audit-log-upload"), + data=body, + content_type="application/x-ndjson", + HTTP_AUTHORIZATION=f"Bearer {raw_token}", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn(f"exceeds maximum of {byte_limit} UTF-8 bytes", response.json()["error"]) + audit_file = AuditFile.objects.get() + self.assertEqual(audit_file.raw_text, body) + self.assertEqual(audit_file.events.count(), 1) + + @override_settings(GOGGLES_MAX_JSONL_LINE_BYTES=32) + def test_whitespace_only_line_still_obeys_line_byte_limit(self): + raw_token, _token = UploadToken.issue("bounded parser") + body = (" " * 33) + "\n" + + response = self.client.post( + reverse("api-audit-log-upload"), + data=body, + content_type="application/x-ndjson", + HTTP_AUTHORIZATION=f"Bearer {raw_token}", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("line 1 exceeds maximum of 32 UTF-8 bytes", response.json()["error"]) + audit_file = AuditFile.objects.get() + self.assertEqual(audit_file.raw_text, body) + self.assertEqual(audit_file.events.get().raw_line, body) + def test_invalid_utf8_group_upload_links_file_to_fallback_group(self): raw_token, _token = UploadToken.issue("ios test client") dump_bytes = b"\xff\xfeinvalid marmot audit bytes" @@ -4482,6 +4579,24 @@ def _upload_message_event(self, seq, msg_id): source_name=f"append-{seq}.jsonl", ) + def test_projection_queries_never_select_verbatim_evidence_columns(self): + with CaptureQueriesContext(connection) as captured: + result = self._upload_message_event(0, MSG_ID) + + self.assertTrue(result.created) + self.assertEqual( + heavy_bulk_selects( + captured.captured_queries, + allowed_columns=( + HEAVY_EVENT_SELECT_COLUMNS["raw_kind"], + HEAVY_EVENT_SELECT_COLUMNS["context_transport"], + HEAVY_EVENT_SELECT_COLUMNS["context_convergence"], + ), + ), + [], + "projection ingestion must not hydrate raw upload or raw event bodies", + ) + def test_small_append_does_not_clear_and_reproject_whole_group(self): # Seed a group with several prior messages -> several projection rows. prior_msg_ids = [f"{byte:02x}" * 32 for byte in range(0xA0, 0xA5)] @@ -5015,6 +5130,30 @@ def action(target_count): # The genuine first-seen 0 is preserved, not dropped or overwritten. self.assertEqual(action_groups[0]["target_count"], 0) + @override_settings(GOGGLES_MAX_ACTION_EVENTS_PER_REQUEST=1) + def test_action_api_reports_when_history_scan_is_safely_truncated(self): + ingest_body( + jsonl( + audit_event(0, wall_time_ms=T0, context={"human_action": self._human_action()}), + audit_event( + 1, + wall_time_ms=T0 + 1000, + context={"human_action": self._human_action("rename_group")}, + ), + ) + ) + group = AuditGroup.objects.get(slug=GROUP_REF) + User.objects.create_user(username="analyst", password="correct horse battery staple") + self.client.login(username="analyst", password="correct horse battery staple") + + with CaptureQueriesContext(connection) as captured: + response = self.client.get(reverse("api-group-actions", kwargs={"slug": group.slug})) + + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["pagination"]["scan_truncated"]) + self.assertEqual(response.json()["pagination"]["scan_limit"], 1) + self.assertEqual(heavy_bulk_selects(captured.captured_queries), []) + class UploadTokenLifecycleTests(TestCase): """Lock in the documented upload-token lifecycle: reusable by default, @@ -5500,7 +5639,7 @@ def test_manifest_advertises_sections_and_eof_counts_are_accurate(self): def test_streams_every_row_past_the_projection_page_cap(self): # The paginated projections API caps a section at GROUP_PROJECTION_API_DEFAULT_LIMIT - # (500). The export must stream every row — this is its reason to exist. + # (100). The export must stream every row — this is its reason to exist. over_cap = GROUP_PROJECTION_API_DEFAULT_LIMIT + 1 evidence_event = AuditEvent.objects.filter( group=self.group, parse_status=AuditEvent.STATUS_VALID @@ -6088,33 +6227,41 @@ def test_group_detail_is_login_required_and_shows_audit_workflows(self): self.assertNotContains(response, 'id="timeline-data"') self.assertNotContains(response, MSG_ID) - delivery_response = self.client.get( - reverse("group-tab", kwargs={"slug": group.slug, "tab": "delivery"}) - ) + with CaptureQueriesContext(connection) as tab_queries: + delivery_response = self.client.get( + reverse("group-tab", kwargs={"slug": group.slug, "tab": "delivery"}) + ) + network_response = self.client.get( + reverse("group-tab", kwargs={"slug": group.slug, "tab": "network"}) + ) + convergence_response = self.client.get( + reverse("group-tab", kwargs={"slug": group.slug, "tab": "convergence"}) + ) + state_response = self.client.get( + reverse("group-tab", kwargs={"slug": group.slug, "tab": "state"}) + ) self.assertContains(delivery_response, "Message artifacts") self.assertContains(delivery_response, MSG_ID[:16]) self.assertContains(delivery_response, "hello from Alice") - network_response = self.client.get( - reverse("group-tab", kwargs={"slug": group.slug, "tab": "network"}) - ) self.assertContains(network_response, "Transport observations") self.assertContains(network_response, "wss://relay.example") - convergence_response = self.client.get( - reverse("group-tab", kwargs={"slug": group.slug, "tab": "convergence"}) - ) self.assertContains(convergence_response, "Convergence runs") self.assertContains(convergence_response, "branch-a") self.assertContains(convergence_response, "highest_weight") - state_response = self.client.get( - reverse("group-tab", kwargs={"slug": group.slug, "tab": "state"}) - ) self.assertContains(state_response, "Group state deltas") self.assertContains(state_response, "text value") self.assertNotContains(state_response, "Launch room") self.assertContains(state_response, "Epoch state transitions") + self.assertEqual( + heavy_bulk_selects( + tab_queries.captured_queries, + allowed_columns=(HEAVY_EVENT_SELECT_COLUMNS["context_source"],), + ), + [], + ) evidence_response = self.client.get( reverse("group-tab", kwargs={"slug": group.slug, "tab": "evidence"}) @@ -6122,6 +6269,28 @@ def test_group_detail_is_login_required_and_shows_audit_workflows(self): self.assertContains(evidence_response, "Audit files") self.assertContains(evidence_response, "Recent evidence rows") + with CaptureQueriesContext(connection) as evidence_api_queries: + evidence_api_response = self.client.get( + reverse("api-group-evidence", kwargs={"slug": group.slug}) + ) + self.assertEqual(evidence_api_response.status_code, 200) + self.assertTrue(evidence_api_response.json()["evidence"]) + self.assertEqual(heavy_bulk_selects(evidence_api_queries.captured_queries), []) + + event_id = evidence_api_response.json()["evidence"][0]["evidence_ref"]["event_id"] + with CaptureQueriesContext(connection) as event_api_queries: + event_api_response = self.client.get( + reverse("api-event-evidence", kwargs={"event_id": event_id}) + ) + self.assertEqual(event_api_response.status_code, 200) + self.assertIn("raw_line", event_api_response.json()["event"]) + self.assertFalse( + any( + '"forensics_auditfile"."raw_text"' in query["sql"] + for query in event_api_queries.captured_queries + ) + ) + def test_group_detail_tabs_cap_large_group_rows(self): tab_limit = GROUP_DETAIL_TAB_EVENT_LIMIT row_count = tab_limit + 5 @@ -6162,12 +6331,14 @@ def test_group_detail_tabs_cap_large_group_rows(self): artifact_kind="application_message", first_seen_ms=1_700_000_000_000 + i, ) - DeliveryObservation.objects.create( + artifact.evidence_events.add(evidence_event) + observation = DeliveryObservation.objects.create( artifact=artifact, engine_id=ENGINE_ALICE, latest_state=f"delivery-marker-{i:03d}", first_seen_ms=1_700_000_000_000 + i, ) + observation.evidence_events.add(evidence_event) NetworkObservation.objects.create( group=group, artifact=artifact, @@ -6209,9 +6380,10 @@ def test_group_detail_tabs_cap_large_group_rows(self): User.objects.create_user(username="analyst", password="correct horse battery staple") self.client.login(username="analyst", password="correct horse battery staple") - delivery_response = self.client.get( - reverse("group-tab", kwargs={"slug": group.slug, "tab": "delivery"}) - ) + with CaptureQueriesContext(connection) as delivery_queries: + delivery_response = self.client.get( + reverse("group-tab", kwargs={"slug": group.slug, "tab": "delivery"}) + ) network_response = self.client.get( reverse("group-tab", kwargs={"slug": group.slug, "tab": "network"}) ) @@ -6223,6 +6395,13 @@ def test_group_detail_tabs_cap_large_group_rows(self): ) self.assertEqual(delivery_response.status_code, 200) + self.assertEqual( + heavy_bulk_selects( + delivery_queries.captured_queries, + allowed_columns=(HEAVY_EVENT_SELECT_COLUMNS["context_source"],), + ), + [], + ) self.assertEqual(len(delivery_response.context["artifacts"]), tab_limit) self.assertContains(delivery_response, f"Showing first {tab_limit} message artifacts") self.assertContains(delivery_response, "delivery-marker-000") @@ -6388,23 +6567,36 @@ def test_group_detail_shell_size_stays_bounded_for_large_groups(self): User.objects.create_user(username="analyst", password="correct horse battery staple") self.client.login(username="analyst", password="correct horse battery staple") - response = self.client.get(reverse("group-detail", kwargs={"slug": group.slug})) + with CaptureQueriesContext(connection) as shell_queries: + response = self.client.get(reverse("group-detail", kwargs={"slug": group.slug})) self.assertEqual(response.status_code, 200) self.assertLess(len(response.content), 250_000) self.assertNotContains(response, "RAW-LINE-MARKER-2999") self.assertNotContains(response, 'id="timeline-data"') - - evidence_response = self.client.get( - reverse("group-tab", kwargs={"slug": group.slug, "tab": "evidence"}) + self.assertEqual( + heavy_bulk_selects( + shell_queries.captured_queries, + allowed_columns=( + HEAVY_EVENT_SELECT_COLUMNS["raw_kind"], + HEAVY_EVENT_SELECT_COLUMNS["context_source"], + ), + ), + [], ) + with CaptureQueriesContext(connection) as evidence_queries: + evidence_response = self.client.get( + reverse("group-tab", kwargs={"slug": group.slug, "tab": "evidence"}) + ) + self.assertEqual(evidence_response.status_code, 200) self.assertLess(len(evidence_response.content), 250_000) self.assertLessEqual( len(evidence_response.context["recent_events"]), GROUP_DETAIL_TAB_EVENT_LIMIT, ) + self.assertEqual(heavy_bulk_selects(evidence_queries.captured_queries), []) class AuditFileDetailViewTests(TestCase): @@ -8083,6 +8275,18 @@ def test_group_agent_export_returns_agent_readable_json(self): self.assertNotIn("raw_line", payload["events"][0]) self.assertIn("raw_upload_bodies", payload["sensitivity"]["omits"]) + @override_settings(GOGGLES_AGENT_EXPORT_MAX_EVENTS=1) + def test_group_agent_export_rejects_oversized_synchronous_build(self): + ingest_body(representative_audit_log()) + User.objects.create_user(username="analyst", password="correct horse battery staple") + self.client.login(username="analyst", password="correct horse battery staple") + + response = self.client.get(reverse("group-agent-export", kwargs={"slug": GROUP_REF})) + + self.assertEqual(response.status_code, 413) + self.assertEqual(response.json()["event_count"], 2) + self.assertEqual(response.json()["max_events"], 1) + def test_group_detail_fetches_events_with_bounded_queries(self): ingest_body(representative_audit_log(engine_id=ENGINE_ALICE)) ingest_body(representative_audit_log(engine_id=ENGINE_BOB)) @@ -8419,6 +8623,18 @@ def test_admin_module_defines_no_event_inline(self): "The unbounded AuditEventInline must not exist (goggles#34).", ) + def test_admin_changelists_do_not_select_verbatim_evidence_columns(self): + audit_file = self.make_audit_file(3, source_name="large") + AuditFile.objects.filter(pk=audit_file.pk).update(raw_text="x" * 5_000_000) + + with CaptureQueriesContext(connection) as captured: + file_response = self.client.get(reverse("admin:forensics_auditfile_changelist")) + event_response = self.client.get(reverse("admin:forensics_auditevent_changelist")) + + self.assertEqual(file_response.status_code, 200) + self.assertEqual(event_response.status_code, 200) + self.assertEqual(heavy_bulk_selects(captured.captured_queries), []) + def test_change_page_query_count_is_bounded_regardless_of_events(self): small = self.make_audit_file(2) large = self.make_audit_file(200, source_name="big") @@ -8531,6 +8747,35 @@ def test_audit_event_admin_keeps_audit_file_deep_link_without_sidebar_filter(sel self.assertIn("audit_file", AuditEventAdmin.autocomplete_fields) self.assertIn("group", AuditEventAdmin.autocomplete_fields) + def test_audit_event_change_page_bounds_raw_evidence(self): + from .admin import AuditEventAdmin + + audit_file = self.make_audit_file(1) + event = audit_file.events.get() + trailing_marker = "TRAILING-RAW-LINE-MARKER" + AuditEvent.objects.filter(pk=event.pk).update( + raw_line="FIRST-RAW-LINE-MARKER" + ("x" * 2_000_000) + trailing_marker, + raw_event={"oversized": "y" * 2_000_000}, + raw_kind={"oversized": "z" * 500_000}, + raw_context={"oversized": "q" * 500_000}, + ) + + response = self.client.get(reverse("admin:forensics_auditevent_change", args=[event.pk])) + + self.assertEqual(response.status_code, 200) + self.assertLess(len(response.content), 200_000) + self.assertContains(response, "Raw line (preview)") + self.assertContains(response, "FIRST-RAW-LINE-MARKER") + self.assertNotContains(response, trailing_marker) + self.assertContains(response, "Open JSON evidence") + self.assertContains( + response, + reverse("api-event-evidence", kwargs={"event_id": event.pk}), + ) + for field in HEAVY_EVENT_SELECT_COLUMNS: + self.assertIn(field, AuditEventAdmin.exclude) + self.assertNotContains(response, f'name="{field}"') + # --- raw_text bounding (goggles#34, adversarial review follow-up) ------- # # Removing the event inline alone did not make the change page bounded: diff --git a/forensics/views.py b/forensics/views.py index 1af0983..0006030 100644 --- a/forensics/views.py +++ b/forensics/views.py @@ -67,8 +67,8 @@ RAW_TEXT_PREVIEW_CHARS = 32 * 1024 GROUP_ENGINE_PREVIEW_LIMIT = 12 GROUP_DETAIL_TAB_EVENT_LIMIT = 100 -GROUP_PROJECTION_API_DEFAULT_LIMIT = 500 -GROUP_PROJECTION_API_MAX_LIMIT = 5_000 +GROUP_PROJECTION_API_DEFAULT_LIMIT = 100 +GROUP_PROJECTION_API_MAX_LIMIT = 500 GROUP_EXPORT_SCHEMA_VERSION = "goggles-group-export/v1" FULL_DATA_AUDIT_MODE = "full_data" ERROR_SEVERITY_TOKENS = ( @@ -105,6 +105,43 @@ "exports": "forensics/partials/group_exports.html", } +# Event list/projection responses render normalized summaries and evidence +# references, never the verbatim line or parsed source object. Keep the heavy +# evidence columns out of every bulk queryset; the single-event evidence API is +# the intentionally narrow exception. +HEAVY_EVENT_FIELDS = { + "raw_line", + "raw_event", + "raw_kind", + "raw_context", + "context_human_action", + "context_transport", + "context_engine", + "context_group", + "context_convergence", + "context_source", +} +EVENT_ROW_FIELDS = tuple( + field.name + for field in AuditEvent._meta.local_concrete_fields + if field.name not in HEAVY_EVENT_FIELDS +) +EVIDENCE_REF_EVENT_FIELDS = ( + "id", + "audit_file_id", + "line_number", + "line_hash", + "schema_version", + "audit_data_mode", + "event_type", + "wall_time_ms", +) + + +def evidence_ref_event_queryset(*extra_fields: str): + fields = tuple(dict.fromkeys((*EVIDENCE_REF_EVENT_FIELDS, *extra_fields))) + return AuditEvent.objects.only(*fields) + def healthz(_request: HttpRequest): return JsonResponse({"status": "ok"}) @@ -376,7 +413,7 @@ def engine_source_values(group: AuditGroup) -> dict[str, dict[str, list[str]]]: "audit_file__source_account_npub", ) ) - for event in events: + for event in events.iterator(chunk_size=2_000): engine_values = values_by_engine.setdefault( event.engine_id, {key: set() for key, _file_field, _context_key in ENGINE_SOURCE_FIELD_MAP} @@ -504,6 +541,7 @@ def group_overview_context(group: AuditGroup) -> dict: "other", action_filters, ), + "action_events_limited": action_groups.truncated, } @@ -528,10 +566,11 @@ def group_tab_context(group: AuditGroup, tab: str) -> dict: if tab == "overview": return {"group": group, "overview": group_overview_context(group)} if tab == "evidence": - audit_files = list(audit_files_for_group(group)) + audit_files, audit_files_limited = limited_tab_events(audit_files_for_group(group)) return { "group": group, "audit_files": file_rows_for_group(audit_files, group), + "audit_files_limited": audit_files_limited, "recent_events": recent_evidence_rows(group), "tab_event_limit": GROUP_DETAIL_TAB_EVENT_LIMIT, } @@ -552,8 +591,8 @@ def group_tab_context(group: AuditGroup, tab: str) -> dict: } if tab == "network": observations, has_more = limited_tab_events( - NetworkObservation.objects.filter(group=group) - .select_related("artifact", "audit_event") + network_observation_queryset() + .filter(group=group) .order_by("wall_time_ms", "engine_id", "id") ) return { @@ -564,8 +603,8 @@ def group_tab_context(group: AuditGroup, tab: str) -> dict: } if tab == "convergence": runs, has_more = limited_tab_events( - ConvergenceRun.objects.filter(group=group) - .prefetch_related("candidates", "rule_evaluations", "evidence_events") + convergence_run_queryset() + .filter(group=group) .order_by("started_at_ms", "engine_id", "run_id") ) attach_decisive_rules(runs) @@ -576,11 +615,9 @@ def group_tab_context(group: AuditGroup, tab: str) -> dict: "tab_event_limit": GROUP_DETAIL_TAB_EVENT_LIMIT, } if tab == "state": - deltas, deltas_has_more = limited_tab_events( - StateDelta.objects.filter(group=group).select_related("audit_event") - ) + deltas, deltas_has_more = limited_tab_events(state_delta_queryset().filter(group=group)) transitions, transitions_has_more = limited_tab_events( - EpochStateTransition.objects.filter(group=group).select_related("audit_event") + epoch_transition_queryset().filter(group=group) ) return { "group": group, @@ -595,7 +632,9 @@ def group_tab_context(group: AuditGroup, tab: str) -> dict: "group": group, "summary": group_summary_header_context(group)["summary"], "classification": group_classification(group), - "saved_reports": list(group.analysis_runs.select_related("created_by")[:20]), + "saved_reports": list( + group.analysis_runs.select_related("created_by").defer("report_json")[:20] + ), } raise Http404("unknown group detail tab") @@ -668,7 +707,7 @@ def attach_decisive_rules(runs: list[ConvergenceRun]) -> None: def recent_evidence_rows(group: AuditGroup) -> list[dict]: events = list( AuditEvent.objects.filter(group=group) - .select_related("audit_file") + .only(*EVENT_ROW_FIELDS) .order_by("-wall_time_ms", "-id")[:GROUP_DETAIL_TAB_EVENT_LIMIT] ) rows = [] @@ -683,6 +722,16 @@ def recent_evidence_rows(group: AuditGroup) -> list[dict]: @login_required def group_agent_export(request: HttpRequest, slug: str): group = get_object_or_404(AuditGroup, slug=slug) + event_count = valid_events_for_group(group).count() + if event_count > settings.GOGGLES_AGENT_EXPORT_MAX_EVENTS: + return JsonResponse( + { + "error": "group too large for synchronous export", + "event_count": event_count, + "max_events": settings.GOGGLES_AGENT_EXPORT_MAX_EVENTS, + }, + status=413, + ) audit_files = list(audit_files_for_group(group)) events = list(valid_events_for_group(group, include_export_fields=True)) pretty = request.GET.get("pretty", "").lower() in {"1", "true", "yes"} @@ -1202,7 +1251,10 @@ def api_engine_groups(request: HttpRequest, engine_id: str): @login_required def api_event_evidence(request: HttpRequest, event_id: int): event = get_object_or_404( - AuditEvent.objects.select_related("audit_file", "group"), + AuditEvent.objects.select_related("audit_file", "group").defer( + "audit_file__raw_text", + "audit_file__user_agent", + ), pk=event_id, ) return JsonResponse( @@ -1724,15 +1776,27 @@ def audit_data_mode_change_payload_severity(payload: dict) -> str: def delivery_artifact_queryset(): + observation_evidence = evidence_ref_event_queryset( + "context_source", + "audit_file__source_account_pubkey_hex", + ).select_related("audit_file") + expectation_evidence = evidence_ref_event_queryset( + "engine_id", + "account_ref", + "context_source", + ) return DeliveryArtifact.objects.prefetch_related( - Prefetch("evidence_events", queryset=AuditEvent.objects.select_related("audit_file")), + Prefetch("evidence_events", queryset=evidence_ref_event_queryset()), "engine_observations", Prefetch( "engine_observations__evidence_events", - queryset=AuditEvent.objects.select_related("audit_file"), + queryset=observation_evidence, ), "recipient_expectations", - "recipient_expectations__evidence_event", + Prefetch( + "recipient_expectations__evidence_event", + queryset=expectation_evidence, + ), ) @@ -1768,7 +1832,12 @@ def apply_delivery_artifact_filters(artifacts, filters: dict): def network_observation_queryset(): - return NetworkObservation.objects.select_related("artifact", "audit_event", "group") + return NetworkObservation.objects.prefetch_related( + Prefetch( + "audit_event", + queryset=evidence_ref_event_queryset(), + ) + ) def filtered_network_observations(group: AuditGroup, filters: dict): @@ -1792,7 +1861,7 @@ def filtered_network_observations(group: AuditGroup, filters: dict): def convergence_run_queryset(): return ConvergenceRun.objects.prefetch_related( - "evidence_events", + Prefetch("evidence_events", queryset=evidence_ref_event_queryset()), "candidates", "rule_evaluations", ) @@ -1849,7 +1918,9 @@ def convergence_payload_matches_filters(payload: dict, filters: dict) -> bool: def state_delta_queryset(): - return StateDelta.objects.select_related("audit_event", "group") + return StateDelta.objects.prefetch_related( + Prefetch("audit_event", queryset=evidence_ref_event_queryset()) + ) def filtered_state_deltas(group: AuditGroup, filters: dict): @@ -1879,7 +1950,9 @@ def filtered_state_deltas(group: AuditGroup, filters: dict): def epoch_transition_queryset(): - return EpochStateTransition.objects.select_related("audit_event", "group") + return EpochStateTransition.objects.prefetch_related( + Prefetch("audit_event", queryset=evidence_ref_event_queryset()) + ) def filtered_epoch_transitions(group: AuditGroup, filters: dict): @@ -1908,7 +1981,19 @@ def filtered_epoch_transitions(group: AuditGroup, filters: dict): def audit_data_mode_change_queryset(group: AuditGroup): - return valid_group_event_queryset(group).filter(event_type="audit_data_mode_changed") + return ( + valid_group_event_queryset(group) + .filter(event_type="audit_data_mode_changed") + .only( + *EVIDENCE_REF_EVENT_FIELDS, + "engine_id", + "account_ref", + "recorder_session_id", + "outcome", + "reason", + "raw_kind", + ) + ) def filtered_audit_data_mode_changes(group: AuditGroup, filters: dict): @@ -1925,11 +2010,28 @@ def filtered_audit_data_mode_changes(group: AuditGroup, filters: dict): events = events.filter(wall_time_ms__gte=filters["from_ms"]) if filters["to_ms"] is not None: events = events.filter(wall_time_ms__lte=filters["to_ms"]) - return events.select_related("audit_file", "group") + return events.only( + *EVIDENCE_REF_EVENT_FIELDS, + "engine_id", + "account_ref", + "recorder_session_id", + "outcome", + "reason", + "raw_kind", + ) def evidence_event_queryset(group: AuditGroup): - return AuditEvent.objects.filter(group=group).select_related("audit_file", "group") + return ( + AuditEvent.objects.filter(group=group) + .select_related("audit_file") + .only( + *EVENT_ROW_FIELDS, + "audit_file__source_name", + "audit_file__validation_status", + "audit_file__file_sha256", + ) + ) def filtered_evidence_events(group: AuditGroup, filters: dict): @@ -1966,7 +2068,7 @@ def action_event_queryset(group: AuditGroup): return ( valid_group_event_queryset(group) .exclude(human_action_action="") - .select_related("audit_file", "group") + .only(*EVENT_ROW_FIELDS) .order_by("wall_time_ms", "engine_id", "line_number", "id") ) @@ -2001,13 +2103,34 @@ def filtered_action_events(group: AuditGroup, filters: dict): } -def action_groups_for_api(group: AuditGroup, filters: dict) -> list[dict]: - events = list(filtered_action_events(group, filters)) +class ActionGroupCollection(list[dict]): + def __init__(self, values=(), *, truncated: bool = False): + super().__init__(values) + self.truncated = truncated + + +def action_groups_for_api(group: AuditGroup, filters: dict) -> ActionGroupCollection: + max_events = settings.GOGGLES_MAX_ACTION_EVENTS_PER_REQUEST + # Pull the newest bounded window, then restore chronological order for the + # existing grouping semantics. This prevents a single group overview/API + # request from materializing an unbounded action history. + events = list( + filtered_action_events(group, filters).order_by( + "-wall_time_ms", "-engine_id", "-line_number", "-id" + )[: max_events + 1] + ) + truncated = len(events) > max_events + if truncated: + events = events[:max_events] + events.reverse() if filters["message_id"]: events = [ event for event in events if filters["message_id"] in action_event_message_ids(event) ] event_by_id = {event.id: event for event in events} + # A truncated window can bisect one operation_id group. In that case the + # request-level scan_truncated flag is authoritative and each affected + # group's event_count describes only its in-window evidence. groups = [ action_group_payload(action_group, event_by_id) for action_group in human_action_groups_for_group(events) @@ -2018,7 +2141,7 @@ def action_groups_for_api(group: AuditGroup, filters: dict) -> list[dict]: for action_group in groups if action_attribution_payload_severity(action_group) == filters["severity"] ] - return groups + return ActionGroupCollection(groups, truncated=truncated) def action_origin_counts(group: AuditGroup) -> list[dict]: @@ -2056,7 +2179,14 @@ def attribution_pagination_payload(action_groups: list[dict], filters: dict) -> for kind, page_key in ACTION_ATTRIBUTION_PAGE_KEYS.items(): total = len([row for row in action_groups if row["attribution_kind"] == kind]) returned = min(max(total - offset, 0), limit) - payload[page_key] = pagination_payload(limit, offset, returned, total > offset + limit) + payload[page_key] = pagination_payload( + limit, + offset, + returned, + total > offset + limit or getattr(action_groups, "truncated", False), + ) + payload["scan_truncated"] = getattr(action_groups, "truncated", False) + payload["scan_limit"] = settings.GOGGLES_MAX_ACTION_EVENTS_PER_REQUEST return payload @@ -3178,14 +3308,13 @@ def read_upload_bytes(upload) -> bytes: if upload_size is not None and upload_size > max_dump_bytes: raise RequestDataTooBig(UPLOAD_TOO_LARGE_ERROR) - chunks = [] - total_bytes = 0 - for chunk in upload.chunks(): - total_bytes += len(chunk) - if total_bytes > max_dump_bytes: - raise RequestDataTooBig(UPLOAD_TOO_LARGE_ERROR) - chunks.append(chunk) - return b"".join(chunks) + # Multipart uploads larger than FILE_UPLOAD_MAX_MEMORY_SIZE have already + # spooled to disk. Read at most one byte beyond the accepted limit instead + # of retaining a chunk list and then allocating a second full-size join. + data = upload.read(max_dump_bytes + 1) + if len(data) > max_dump_bytes: + raise RequestDataTooBig(UPLOAD_TOO_LARGE_ERROR) + return data def source_metadata_from_request(request: HttpRequest) -> dict[str, str]: