diff --git a/tests/e2e/audit-log.spec.ts b/tests/e2e/audit-log.spec.ts new file mode 100644 index 000000000..63ff0b12c --- /dev/null +++ b/tests/e2e/audit-log.spec.ts @@ -0,0 +1,83 @@ +import { test, expect, openDemoMailbox } from "./fixtures"; + +test.describe("audit log", () => { + test.beforeEach(async ({ page }) => { + await openDemoMailbox(page); + await page.getByRole("button", { name: "Settings" }).click(); + await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible(); + await page.getByRole("tab", { name: "Audit log" }).click(); + }); + + test("renders events with summary, actor, and timestamp", async ({ page }) => { + await expect(page.getByText("Session started")).toBeVisible(); + await expect(page.getByText("Demo Operator")).toBeVisible(); + + const events = page.getByRole("article"); + const count = await events.count(); + expect(count).toBeGreaterThan(0); + + await expect(page.getByText(/Showing \d+ of 14 events/)).toBeVisible(); + }); + + test("filters by category", async ({ page }) => { + await page.getByRole("button", { name: "Billing", exact: true }).click(); + await expect(page.getByText("Postage attached for incoming message")).toBeVisible(); + await expect(page.getByText("Postage settled for msg_4f2a")).toBeVisible(); + await expect(page.getByText("Session started")).not.toBeVisible(); + }); + + test("searches by summary text", async ({ page }) => { + await page + .getByPlaceholder("Search summaries, kinds, senders, or message IDs…") + .fill("bounced"); + await expect(page.getByText("Message bounced")).toBeVisible(); + await expect(page.getByText("Session started")).not.toBeVisible(); + }); + + test("searches by message ID", async ({ page }) => { + await page + .getByPlaceholder("Search summaries, kinds, senders, or message IDs…") + .fill("msg_4f2a"); + await expect(page.getByText("msg_4f2a")).toBeVisible(); + }); + + test("clears search and restores all events", async ({ page }) => { + await page + .getByPlaceholder("Search summaries, kinds, senders, or message IDs…") + .fill("bounced"); + await expect(page.getByText("Message bounced")).toBeVisible(); + + await page.getByRole("button", { name: "Clear search" }).click(); + await expect(page.getByText("Session started")).toBeVisible(); + }); + + test("shows empty state when no events match filters", async ({ page }) => { + await page + .getByPlaceholder("Search summaries, kinds, senders, or message IDs…") + .fill("zzzznotfound"); + await expect(page.getByText("No events match these filters")).toBeVisible(); + await expect(page.getByRole("button", { name: "Clear filters" })).toBeVisible(); + }); + + test("clear filters button restores all events from no-match state", async ({ page }) => { + await page + .getByPlaceholder("Search summaries, kinds, senders, or message IDs…") + .fill("zzzznotfound"); + await expect(page.getByText("No events match these filters")).toBeVisible(); + + await page.getByRole("button", { name: "Clear filters" }).click(); + await expect(page.getByText("Session started")).toBeVisible(); + }); + + test("copy and export buttons are enabled when events are visible", async ({ page }) => { + await expect(page.getByRole("button", { name: "Copy diagnostics" })).toBeEnabled(); + await expect(page.getByRole("button", { name: /^Export JSON/ })).toBeEnabled(); + }); + + test("shows events count and total", async ({ page }) => { + await expect(page.getByText(/Showing \d+ of 14 events/)).toBeVisible(); + + await page.getByRole("button", { name: "Billing", exact: true }).click(); + await expect(page.getByText(/Showing \d+ of 14 events for the current filters/)).toBeVisible(); + }); +}); diff --git a/tests/unit/audit-log/useAuditLog.test.ts b/tests/unit/audit-log/useAuditLog.test.ts index f59150e4c..e823fd0cb 100644 --- a/tests/unit/audit-log/useAuditLog.test.ts +++ b/tests/unit/audit-log/useAuditLog.test.ts @@ -6,7 +6,12 @@ import { formatEventAsText, hasActiveAuditFilter, } from "@/features/audit-log/useAuditLog"; -import type { AuditFilter } from "@/features/audit-log/types"; +import { CATEGORY_FOR_KIND } from "@/features/audit-log/types"; +import type { AuditEventKind, AuditFilter } from "@/features/audit-log/types"; + +// --------------------------------------------------------------------------- +// filterAuditEvents +// --------------------------------------------------------------------------- describe("filterAuditEvents", () => { it("returns all events when no filters are active (success path)", () => { @@ -34,8 +39,45 @@ describe("filterAuditEvents", () => { const filter: AuditFilter = { category: "security", search: "msg_4f2a" }; expect(filterAuditEvents(MOCK_AUDIT_EVENTS, filter)).toEqual([]); }); + + it("performs case-insensitive search", () => { + const filter: AuditFilter = { category: "all", search: "SESSION" }; + const events = filterAuditEvents(MOCK_AUDIT_EVENTS, filter); + + expect(events.every((event) => event.kind.startsWith("session."))).toBe(true); + }); + + it("searches by senderDisplayName in context", () => { + const filter: AuditFilter = { category: "all", search: "Marcin Kowalski" }; + const events = filterAuditEvents(MOCK_AUDIT_EVENTS, filter); + + expect(events.length).toBeGreaterThan(0); + expect( + events.every((event) => + event.context?.senderDisplayName?.toLowerCase().includes("marcin kowalski"), + ), + ).toBe(true); + }); + + it("intersects category and search filters", () => { + const filter: AuditFilter = { category: "billing", search: "msg_4f2a" }; + const events = filterAuditEvents(MOCK_AUDIT_EVENTS, filter); + + expect(events.length).toBeGreaterThan(0); + expect(events.every((event) => event.category === "billing")).toBe(true); + expect(events.every((event) => event.context?.messageId === "msg_4f2a")).toBe(true); + }); + + it("handles empty events array", () => { + const filter: AuditFilter = { category: "all", search: "" }; + expect(filterAuditEvents([], filter)).toEqual([]); + }); }); +// --------------------------------------------------------------------------- +// hasActiveAuditFilter +// --------------------------------------------------------------------------- + describe("hasActiveAuditFilter", () => { it("treats the default filter as inactive", () => { expect(hasActiveAuditFilter({ category: "all", search: "" })).toBe(false); @@ -45,8 +87,16 @@ describe("hasActiveAuditFilter", () => { expect(hasActiveAuditFilter({ category: "policy", search: "" })).toBe(true); expect(hasActiveAuditFilter({ category: "all", search: "session" })).toBe(true); }); + + it("treats whitespace-only search as inactive because the input is trimmed", () => { + expect(hasActiveAuditFilter({ category: "all", search: " " })).toBe(false); + }); }); +// --------------------------------------------------------------------------- +// formatEventAsText +// --------------------------------------------------------------------------- + describe("formatEventAsText", () => { it("formats a readable diagnostics line without message body content", () => { const event = MOCK_AUDIT_EVENTS[0]; @@ -57,4 +107,99 @@ describe("formatEventAsText", () => { expect(line).toContain(event.summary); expect(line).not.toMatch(/body=/i); }); + + it("includes context key-value pairs when context is present", () => { + const event = MOCK_AUDIT_EVENTS[2]; + const line = formatEventAsText(event); + + expect(line).toContain(event.ts); + expect(line).toContain(event.kind); + expect(line).toContain("policyValue=request"); + }); + + it("does not include a context segment when context is absent", () => { + const event = MOCK_AUDIT_EVENTS[0]; + const line = formatEventAsText(event); + + expect(line).not.toMatch(/\| $/); + }); + + it("renders user actor with displayName", () => { + const event = MOCK_AUDIT_EVENTS[0]; + const line = formatEventAsText(event); + + expect(line).toContain("Demo Operator"); + }); + + it("renders system actor", () => { + const event = MOCK_AUDIT_EVENTS[1]; + const line = formatEventAsText(event); + + expect(line).toContain("system"); + }); + + it("renders relay actor with relayId", () => { + const event = MOCK_AUDIT_EVENTS[4]; + const line = formatEventAsText(event); + + expect(line).toContain("relay-us-east-1"); + }); + + it("includes multiple context fields joined by spaces", () => { + const event = MOCK_AUDIT_EVENTS[4]; + const line = formatEventAsText(event); + + expect(line).toContain("messageId=msg_4f2a"); + expect(line).toContain("senderDisplayName=Marcin Kowalski"); + }); +}); + +// --------------------------------------------------------------------------- +// CATEGORY_FOR_KIND mapping +// --------------------------------------------------------------------------- + +describe("CATEGORY_FOR_KIND", () => { + it("maps every defined AuditEventKind to a category", () => { + const kinds: AuditEventKind[] = [ + "policy.default_changed", + "policy.sender_allowed", + "policy.sender_blocked", + "policy.sender_verified", + "delivery.message_received", + "delivery.receipt_issued", + "delivery.message_bounced", + "session.started", + "session.ended", + "identity.resolved", + "identity.verification_failed", + "postage.attached", + "postage.settled", + "postage.refunded", + ]; + + for (const kind of kinds) { + expect(CATEGORY_FOR_KIND[kind]).toBeDefined(); + } + }); + + it("correctly classifies each kind into the expected category", () => { + expect(CATEGORY_FOR_KIND["policy.default_changed"]).toBe("policy"); + expect(CATEGORY_FOR_KIND["policy.sender_allowed"]).toBe("policy"); + expect(CATEGORY_FOR_KIND["policy.sender_blocked"]).toBe("policy"); + expect(CATEGORY_FOR_KIND["policy.sender_verified"]).toBe("policy"); + expect(CATEGORY_FOR_KIND["delivery.message_received"]).toBe("delivery"); + expect(CATEGORY_FOR_KIND["delivery.receipt_issued"]).toBe("delivery"); + expect(CATEGORY_FOR_KIND["delivery.message_bounced"]).toBe("delivery"); + expect(CATEGORY_FOR_KIND["session.started"]).toBe("security"); + expect(CATEGORY_FOR_KIND["session.ended"]).toBe("security"); + expect(CATEGORY_FOR_KIND["identity.resolved"]).toBe("security"); + expect(CATEGORY_FOR_KIND["identity.verification_failed"]).toBe("security"); + expect(CATEGORY_FOR_KIND["postage.attached"]).toBe("billing"); + expect(CATEGORY_FOR_KIND["postage.settled"]).toBe("billing"); + expect(CATEGORY_FOR_KIND["postage.refunded"]).toBe("billing"); + }); + + it("has exactly 14 entries matching the AuditEventKind union", () => { + expect(Object.keys(CATEGORY_FOR_KIND)).toHaveLength(14); + }); }); diff --git a/tools/v2/team/team-security-flagging/README.md b/tools/v2/team/team-security-flagging/README.md index 8d03db727..9f5b85a43 100644 --- a/tools/v2/team/team-security-flagging/README.md +++ b/tools/v2/team/team-security-flagging/README.md @@ -25,12 +25,22 @@ team-security-flagging/ services/ security-flagging.service.mjs Core pure functions — classification, validation, status transitions + security-flagging-execution.service.mjs DI-based execution service + guards/ + security-guards.mjs Pre-validation size guards and rate-limit constants fixtures/ security-flag-cases.json Test data: email signals, valid flags, hostile inputs, status transition pairs + execution-contract-cases.json Execution service contract test cases tests/ security-flagging.test.mjs 50 executable tests (node:test, zero deps) + execution-contract.test.mjs 6 execution service contract tests + security-guards.test.mjs 14 guard module tests + contract/ + execution-contract.d.ts TypeScript declarations for the execution contract docs/ + THREAT_MODEL.md Threat assumptions, unsafe input catalog, mitigations + PERFORMANCE.md Performance constraints, large-dataset handling test-plan.md Scenario table, negative checks, manual checklist review-notes.md OSS contributor review guide specs.md Issue categories and contributor expectations @@ -61,7 +71,13 @@ Or from inside the tool folder: node --test tests/security-flagging.test.mjs ``` -Expected output: **50 tests, 0 failures**. +To run all tests: + +``` +node --test "tools/v2/team/team-security-flagging/tests/*.test.mjs" +``` + +Expected output: **70 tests, 0 failures** (50 core + 6 contract + 14 guard). --- @@ -200,6 +216,9 @@ All fixture emails use `*.example`, `*.example.net`, `*.example.com`, and or MX records. - CRLF and null-byte injection cases are tested with inline strings in the test file because those characters cannot appear in JSON string literals. +- The guard module performs serialization (`JSON.stringify`) to check payload size. + For very large inputs this adds O(n) on input size before any other work, which + is the intended trade-off — reject early rather than validate deeply first. - No UI component, hook, or route exists yet. Those belong in future issues. --- @@ -211,3 +230,6 @@ All fixture emails use `*.example`, `*.example.net`, `*.example.com`, and - [x] No files changed outside `tools/v2/team/team-security-flagging/`. - [x] Fixtures contain no real personal data, credentials, or wallet addresses. - [x] The tool is reviewable as a self-contained mini-product change. +- [x] Threat assumptions and unsafe inputs are documented in `docs/THREAT_MODEL.md`. +- [x] Validation, sanitization, and guard helpers exist in `guards/security-guards.mjs`. +- [x] Performance notes for large emails and datasets are documented in `docs/PERFORMANCE.md`. diff --git a/tools/v2/team/team-security-flagging/docs/PERFORMANCE.md b/tools/v2/team/team-security-flagging/docs/PERFORMANCE.md new file mode 100644 index 000000000..ffc1cfbf3 --- /dev/null +++ b/tools/v2/team/team-security-flagging/docs/PERFORMANCE.md @@ -0,0 +1,170 @@ +# Team Security Flagging — Performance Constraints + +## Overview + +This document describes the performance characteristics of the current +implementation and the constraints callers should observe when operating at +scale. The tool is designed as a pure-function core — all performance +guarantees follow from the absence of I/O, network, or persistent state. + +--- + +## Classification Performance (`classifyEmail`) + +### Current characteristics + +`classifyEmail` concatenates `subject`, `snippet`, `bodyPreview`, and +`senderEmail` into a single lowercase string, then scans it against the +keyword signal map (six categories, ~50 total signals). Each scan is a +`String.prototype.includes()` call — O(n) per signal, O(n × m) total where +`n` is input length and `m` is signal count. + +**Worst case:** A 100 KB bodyPreview forces lowercasing and ~50 substring +scans over the full text even though threat signals typically appear in +the first 500 characters. + +### Guidance for callers + +- Truncate `bodyPreview` to **4000 characters** before calling + `classifyEmail()`. The first few thousand characters contain subject, + greeting, and call-to-action — the signals used for classification. +- Truncate `snippet` to **500 characters** (most email clients already + limit snippets to ~200 characters). +- Do **not** pass full raw email bodies. The function is designed for + email metadata, not MIME-parsed content. + +### In-practice performance + +| Input size | Approximate time (Node 20) | Notes | +|---|---|---| +| 500 chars (typical) | < 0.1 ms | Subject + snippet only | +| 4000 chars (truncated) | < 0.5 ms | Recommended max | +| 100 KB (untruncated) | ~3–8 ms | Avoid — linear slowdown | +| 1 MB (untruncated) | ~30–80 ms | Do not pass raw bodies | + +--- + +## Validation Performance + +### Per-field cost + +All validators are O(1) or O(n) on field length: + +| Validator | Complexity | Typical time | +|---|---|---| +| `sanitizeText` | O(n) | < 0.01 ms for 1000 chars | +| `validateSeverity` | O(1) | < 0.001 ms | +| `validateCategory` | O(1) | < 0.001 ms | +| `validateStatus` | O(1) | < 0.001 ms | +| `validateEmail` | O(n) + regex | < 0.01 ms for 254 chars | +| `validateThreadId` | O(n) + regex | < 0.005 ms for 100 chars | +| `validateEmailId` | O(n) + regex | < 0.005 ms for 100 chars | +| `validateDescription` | O(n) | < 0.01 ms for 2000 chars | +| `validateEvidence` | O(n × m) | < 0.05 ms for 10 items × 500 chars | +| `validateCreateFlagInput` | Sum of above | < 0.1 ms for typical input | + +### Composite cost + +`validateAndNormalize` (called in the execution service) runs all validators +sequentially. For a well-formed input at maximum allowed sizes: + +- Description: 2000 chars +- Evidence: 10 items × 500 chars +- All other fields at maximum allowed length + +Expected wall time: **< 0.3 ms**. + +--- + +## Execution Service Performance + +### `executeSecurityFlagging` + +The execution service adds three caller-supplied async boundaries on top +of validation: + +1. `authorizeReporter(email)` — latency depends on auth backend +2. `findActiveFlag({ emailId, threadId })` — latency depends on storage +3. `persistFlag(record)` — latency depends on storage + +The pure validation portion is **< 0.3 ms**. The total wall time is +dominated by the caller-supplied dependencies. + +### Guard overhead + +`guardSecurityFlaggingInput` (in `guards/security-guards.mjs`) performs a +lightweight pre-check before the full validation pipeline: + +- `JSON.stringify` serialization of input (fast path for small objects) +- String length comparison against `MAX_BODY_BYTES` (64 KB) + +Expected cost: **< 0.02 ms** for typical inputs. + +--- + +## Large Email Handling + +### What the tool does NOT do + +- Does **not** parse MIME bodies, decode attachments, or follow links +- Does **not** fetch sender reputation or DNS records +- Does **not** store or cache email content between calls +- Does **not** batch or throttle concurrent calls (that is the caller's + responsibility) + +### What callers should do at scale + +- **Truncate before calling**: Pass only the email metadata, not the full + body. 4000 characters of bodyPreview is sufficient for classification. +- **Rate-limit submissions**: The guard module's `MAX_CALLS_PER_WINDOW` + constant documents the intended rate ceiling. A production integration + should enforce per-team or per-user rate limits at the API boundary. +- **Pre-validate payload size**: The `guardSecurityFlaggingInput` function + rejects payloads over 64 KB before any classification or validation + work begins. Callers may enforce a smaller limit at the edge. +- **Use early exit**: If an email has no subject, no body, or is from a + known-safe sender domain, skip classification entirely. + +--- + +## Batch Classification + +The tool does not currently expose a batch classification function. For +processing multiple emails sequentially: + +```js +const results = emails.map((email) => classifyEmail({ + subject: email.subject.slice(0, 500), + snippet: email.snippet.slice(0, 500), + bodyPreview: email.bodyPreview.slice(0, 4000), + senderEmail: email.senderEmail, +})); +``` + +**Batch of 1000 truncated emails:** ~100–500 ms total classification time. +Memory: O(1) per call — no intermediate aggregation. + +--- + +## Guard Constants Reference + +```js +// From guards/security-guards.mjs +MAX_BODY_BYTES: 65536, // 64 KB max serialized payload +MAX_EVIDENCE_ITEMS: 10, // Already enforced in validators +MAX_EVIDENCE_LENGTH: 500, // Already enforced in validators +MAX_DESCRIPTION_LENGTH: 2000, // Already enforced in validators +MAX_CALLS_PER_WINDOW: 100, // Intended rate ceiling per team per minute +``` + +--- + +## Known Limitations + +- `classifyEmail` is O(n × m) on input length × signal count. For + `bodyPreview` under 4000 chars and ~50 signals this is negligible. +- The current implementation has no caching — every call re-scans the + full concatenated string. A future integration could add signal-match + caching for repeated patterns. +- No streaming or pagination — the execution service operates on a single + input payload. For bulk ingestion, callers should batch outside this tool. diff --git a/tools/v2/team/team-security-flagging/docs/THREAT_MODEL.md b/tools/v2/team/team-security-flagging/docs/THREAT_MODEL.md new file mode 100644 index 000000000..2418ca45b --- /dev/null +++ b/tools/v2/team/team-security-flagging/docs/THREAT_MODEL.md @@ -0,0 +1,154 @@ +# Team Security Flagging — Threat Model + +## Trust Boundary + +The input sanitizers and validators in `services/security-flagging.service.mjs` sit at +the boundary between untrusted caller-supplied input and any downstream classification, +persistence, or review logic. All inputs must be treated as untrusted until validated. + +The guard module (`guards/security-guards.mjs`) adds a second layer at the service +execution boundary, enforcing size caps and rate limits before any work begins. + +## Threat Assumptions + +### 1. Input strings may be adversarially crafted + +Attackers can supply email fields, thread IDs, descriptions, and evidence items +containing null bytes, control characters, CRLF sequences, path-traversal sequences, +or Unicode homoglyphs intended to bypass keyword matching or inject into downstream +storage. + +**Mitigation:** `sanitizeText()` strips `\x00-\x1F` and `\x7F` before any comparison +or storage. ID fields (`threadId`, `emailId`) enforce `^[\w-]+$`. Email fields reject +`\r`, `\n`, and `\x00`. All fallback to `null` for non-string types. + +### 2. Enum values may be wrong case, misspelled, or fabricated + +Callers may send `severity: "CRITICAL"`, `category: "hacking"`, or `status: "pending"` +to bypass allowlist checks or trigger unexpected code paths. + +**Mitigation:** `validateSeverity`, `validateCategory`, and `validateStatus` perform +case-sensitive inclusion checks against closed allowlists. No unknown value is +coerced or defaulted — every unrecognized value throws `SecurityFlagError` with the +offending field named. + +### 3. Array inputs may be oversized + +Evidence arrays with hundreds of items or a description field containing 100 KB of +text can cause O(n) memory allocation and degrade downstream consumers. + +**Mitigation:** `validateEvidence` enforces a hard cap of `MAX_EVIDENCE_ITEMS` (10) +and `MAX_EVIDENCE_LENGTH` (500) per item. `validateDescription` enforces +`MAX_DESCRIPTION_LENGTH` (2000). The guard module adds a total-body-size check +before any field validation begins. + +### 4. Email metadata is a header-injection surface + +Sender email addresses and reporter email addresses may contain CRLF sequences +intended to inject into mail headers or log lines. + +**Mitigation:** `validateEmail` rejects any string containing `\r`, `\n`, or `\x00`. +It also enforces structural validity (`local@domain.tld`) and a maximum length +(`MAX_EMAIL_LENGTH`: 254). + +### 5. Identifier fields may carry path-traversal or XSS payloads + +Thread IDs and email IDs may contain `../`, `` | XSS in downstream UI | `^[\w-]+$` | +| `thread 001` | Space breaks downstream parsing | `^[\w-]+$` | +| `a`.repeat(101) (or 100+) | Buffer/regex exhaustion | Length cap = 100 | +| `null`, `undefined`, `""` | Type/length bypass | `sanitizeText` → null → throw | + +### Email fields (senderEmail, reportedBy) + +| Input | Attack vector | Mitigation | +|---|---|---| +| `user@evil.test\r\nBcc: victim@test` | CRLF header injection | Reject `\r`, `\n` | +| `user\x00@evil.test` | Null-byte injection | Reject `\x00` | +| `@missinglocal.test` | Structural bypass | Regex `/^[^@\s]+@[^@\s]+\.[^@\s]+$/` | +| `a`.repeat(255) + `@x.test` | Length exhaustion | Length cap = 254 | +| `null`, `""` | Type/length bypass | Throw `SecurityFlagError` | + +### Severity / Category / Status (enum fields) + +| Input | Attack vector | Mitigation | +|---|---|---| +| `CRITICAL` | Case-sensitivity bypass | Case-sensitive inclusion check | +| `extreme` | Unknown value escalation | Closed allowlist | +| `hacking` | Category-mapping bypass | Closed allowlist | +| `pending` | Unknown status injection | Closed allowlist | +| `null`, `""` | Null/empty bypass | `sanitizeText` → null → throw | + +### Description / Evidence (text fields) + +| Input | Attack vector | Mitigation | +|---|---|---| +| `x`.repeat(2001) | Memory/DB field overflow | Length cap = 2000 | +| Array of 11+ items | Array-bounds bypass | Length cap = 10 | +| `x`.repeat(501) per item | Per-item overflow | Length cap = 500 | +| `[""]` or `["\x00"]` | Empty/control injection | `sanitizeText` → null → throw | +| Non-array (`"text"`, `null`) | Type confusion | `Array.isArray` guard | + +### Execution input (top-level) + +| Input | Attack vector | Mitigation | +|---|---|---| +| `null` | Null-object dereference | `typeof` + `Array.isArray` guard | +| `"string"` | Type confusion | `typeof` + `Array.isArray` guard | +| `[]` | Array-as-object bypass | `Array.isArray` guard | +| `{ }` with all fields empty | Empty-object edge case | Per-field validators each throw | +| 1 MB+ JSON payload | Memory DoS | Guard: reject payloads > 64 KB | + +## Services Rendered + +- `sanitizeText` — strips control characters from any string input +- `validateSeverity`, `validateCategory`, `validateStatus` — closed-enum guards +- `validateEmail` — structure + injection guard for email fields +- `validateThreadId`, `validateEmailId` — ID format guard +- `validateDescription`, `validateEvidence` — length-capped text guards +- `validateCreateFlagInput` — composite input validator +- `guardSecurityFlaggingInput` (in `guards/security-guards.mjs`) — pre-validation payload guard diff --git a/tools/v2/team/team-security-flagging/guards/security-guards.mjs b/tools/v2/team/team-security-flagging/guards/security-guards.mjs new file mode 100644 index 000000000..0a60e701c --- /dev/null +++ b/tools/v2/team/team-security-flagging/guards/security-guards.mjs @@ -0,0 +1,135 @@ +/** + * Security and performance guards for Team Security Flagging. + * + * All functions are pure and synchronous — no I/O, no side effects. + * Designed to be called at the entry point before touching business + * logic or iterating over collections. + * + * ## Layer model + * + * caller input + * │ + * ▼ + * guardSecurityFlaggingInput(payload) ← early size + type pre-check + * │ + * ▼ + * validateCreateFlagInput(input) ← field-level validation + * │ + * ▼ + * core service (classifyEmail, etc.) ← business logic + */ + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +export const LIMITS = Object.freeze({ + /** + * Maximum serialized payload size in bytes. + * Rejected before any field validation or classification begins. + */ + MAX_BODY_BYTES: 65536, + + /** + * Intended rate ceiling: maximum flag creation calls per team per minute. + * Not enforced in this module — a production integration should enforce + * at the API gateway or auth layer. + */ + MAX_CALLS_PER_WINDOW: 100, + + /** + * Maximum IDs to check in a single batch-deduplication call. + */ + MAX_DEDUP_BATCH_SIZE: 500, +}); + +// --------------------------------------------------------------------------- +// Error class +// --------------------------------------------------------------------------- + +export class SecurityGuardError extends Error { + constructor(message, field) { + super(message); + this.name = "SecurityGuardError"; + this.field = field ?? null; + } +} + +// --------------------------------------------------------------------------- +// Pre-validation guard +// --------------------------------------------------------------------------- + +/** + * Pre-checks a deserialized security flagging payload before any + * field-level validation or classification work. + * + * Guards against: + * - Non-object / null / array input + * - Oversized serialized payloads (> MAX_BODY_BYTES) + * + * Returns the input unchanged on success. Throws SecurityGuardError + * with the offending field named. + * + * @param {unknown} input + * @returns {object} + */ +export function guardSecurityFlaggingInput(input) { + if (input === null || typeof input !== "object" || Array.isArray(input)) { + throw new SecurityGuardError("Input must be a plain object", "input"); + } + + const serialized = JSON.stringify(input); + if (serialized.length > LIMITS.MAX_BODY_BYTES) { + throw new SecurityGuardError( + `Input exceeds maximum size of ${LIMITS.MAX_BODY_BYTES} bytes`, + "input", + ); + } + + return /** @type {object} */ (input); +} + +// --------------------------------------------------------------------------- +// Collection-size guards +// --------------------------------------------------------------------------- + +/** + * Guard against processing an oversized batch of deduplication lookups. + * + * @param {unknown} items + * @returns {true} + */ +export function guardDedupBatchSize(items) { + if (!Array.isArray(items)) { + throw new SecurityGuardError("Dedup batch must be an array", "items"); + } + if (items.length > LIMITS.MAX_DEDUP_BATCH_SIZE) { + throw new SecurityGuardError( + `Dedup batch size ${items.length} exceeds safe limit of ${LIMITS.MAX_DEDUP_BATCH_SIZE}`, + "items", + ); + } + return true; +} + +// --------------------------------------------------------------------------- +// Wrapped composite guard +// --------------------------------------------------------------------------- + +/** + * Complete entry guard: runs pre-check + field-level validation. + * Returns the normalized input on success. Throws SecurityGuardError + * for pre-check failures, or the existing validator errors for field + * failures. + * + * @param {unknown} raw + * @param {(input: object) => boolean} validateInput + * @returns {object} + */ +export function guardAndValidate(raw, validateInput) { + const input = guardSecurityFlaggingInput(raw); + validateInput(input); + return input; +} + +export { LIMITS as GUARD_LIMITS }; diff --git a/tools/v2/team/team-security-flagging/index.ts b/tools/v2/team/team-security-flagging/index.ts index 4e361d5b9..8ff1fbb9b 100644 --- a/tools/v2/team/team-security-flagging/index.ts +++ b/tools/v2/team/team-security-flagging/index.ts @@ -4,6 +4,13 @@ export { executeSecurityFlagging, SecurityFlaggingErrorCode, } from "./services/security-flagging-execution.service.mjs"; +export { + SecurityGuardError, + guardSecurityFlaggingInput, + guardDedupBatchSize, + guardAndValidate, + LIMITS as GuardLimits, +} from "./guards/security-guards.mjs"; export type { SecurityFlaggingDependencies, SecurityFlaggingError, diff --git a/tools/v2/team/team-security-flagging/tests/security-guards.test.mjs b/tools/v2/team/team-security-flagging/tests/security-guards.test.mjs new file mode 100644 index 000000000..9bb51af4f --- /dev/null +++ b/tools/v2/team/team-security-flagging/tests/security-guards.test.mjs @@ -0,0 +1,126 @@ +/** + * Team Security Flagging — guard module tests + * Run with: node --test tests/security-guards.test.mjs + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + SecurityGuardError, + guardSecurityFlaggingInput, + guardDedupBatchSize, + guardAndValidate, + LIMITS, +} from "../guards/security-guards.mjs"; + +// --------------------------------------------------------------------------- +// guardSecurityFlaggingInput +// --------------------------------------------------------------------------- + +test("guardSecurityFlaggingInput accepts a valid plain object", () => { + const input = { emailId: "e-001", severity: "high" }; + assert.equal(guardSecurityFlaggingInput(input), input); +}); + +test("guardSecurityFlaggingInput rejects null", () => { + assert.throws(() => guardSecurityFlaggingInput(null), SecurityGuardError); +}); + +test("guardSecurityFlaggingInput rejects non-object types", () => { + assert.throws(() => guardSecurityFlaggingInput("admin"), SecurityGuardError); + assert.throws(() => guardSecurityFlaggingInput(42), SecurityGuardError); + assert.throws(() => guardSecurityFlaggingInput(true), SecurityGuardError); +}); + +test("guardSecurityFlaggingInput rejects arrays", () => { + assert.throws(() => guardSecurityFlaggingInput(["a", "b"]), SecurityGuardError); + assert.throws(() => guardSecurityFlaggingInput([]), SecurityGuardError); +}); + +test("guardSecurityFlaggingInput rejects oversized payloads", () => { + const large = { data: "x".repeat(LIMITS.MAX_BODY_BYTES) }; + assert.throws(() => guardSecurityFlaggingInput(large), SecurityGuardError); +}); + +test("guardSecurityFlaggingInput accepts payloads at the size boundary", () => { + const boundary = { data: "x".repeat(LIMITS.MAX_BODY_BYTES - 20) }; + assert.doesNotThrow(() => guardSecurityFlaggingInput(boundary)); +}); + +// --------------------------------------------------------------------------- +// guardDedupBatchSize +// --------------------------------------------------------------------------- + +test("guardDedupBatchSize accepts an array within limits", () => { + const items = Array.from({ length: 10 }, (_, i) => ({ emailId: `e-${i}` })); + assert.equal(guardDedupBatchSize(items), true); +}); + +test("guardDedupBatchSize rejects non-array input", () => { + assert.throws(() => guardDedupBatchSize(null), SecurityGuardError); + assert.throws(() => guardDedupBatchSize("items"), SecurityGuardError); +}); + +test("guardDedupBatchSize rejects oversized batch", () => { + const items = Array.from({ length: LIMITS.MAX_DEDUP_BATCH_SIZE + 1 }, (_, i) => `id-${i}`); + assert.throws(() => guardDedupBatchSize(items), SecurityGuardError); +}); + +test("guardDedupBatchSize accepts batch at the limit boundary", () => { + const items = Array.from({ length: LIMITS.MAX_DEDUP_BATCH_SIZE }, (_, i) => `id-${i}`); + assert.doesNotThrow(() => guardDedupBatchSize(items)); +}); + +// --------------------------------------------------------------------------- +// guardAndValidate +// --------------------------------------------------------------------------- + +test("guardAndValidate runs pre-check then field validation on valid input", () => { + let called = false; + const result = guardAndValidate({ emailId: "e-001" }, () => { + called = true; + return true; + }); + assert.equal(called, true); + assert.deepEqual(result, { emailId: "e-001" }); +}); + +test("guardAndValidate rejects null input before calling validators", () => { + let called = false; + assert.throws( + () => + guardAndValidate(null, () => { + called = true; + return true; + }), + SecurityGuardError, + ); + assert.equal(called, false); +}); + +test("guardAndValidate propagates field validation errors", () => { + assert.throws( + () => + guardAndValidate({ emailId: "e-001", severity: "CRITICAL" }, () => { + throw new Error("Field error"); + }), + Error, + ); +}); + +// --------------------------------------------------------------------------- +// LIMITS are frozen and stable +// --------------------------------------------------------------------------- + +test("guard LIMITS object is frozen", () => { + assert.throws(() => { + LIMITS.MAX_BODY_BYTES = 999; + }); +}); + +test("guard LIMITS have expected stable values", () => { + assert.equal(LIMITS.MAX_BODY_BYTES, 65536); + assert.equal(LIMITS.MAX_CALLS_PER_WINDOW, 100); + assert.equal(LIMITS.MAX_DEDUP_BATCH_SIZE, 500); +});