diff --git a/.ai/analytics-output-port-design.md b/.ai/analytics-output-port-design.md deleted file mode 100644 index 41ce63cca..000000000 --- a/.ai/analytics-output-port-design.md +++ /dev/null @@ -1,190 +0,0 @@ -# Analytics Output Port Design - -## Status: Approved -## Date: 2025-01-21 - -## Problem Statement - -When connecting a component's `rawOutput` (which contains complex nested JSON) to the Analytics Sink, OpenSearch hits the default field limit of 1000 fields. This is because: - -1. **Dynamic mapping explosion**: Elasticsearch/OpenSearch creates a field for every unique JSON path -2. **Nested structures**: Arrays with objects like `issues[0].metadata.schema` create many paths -3. **Varying schemas**: Different scanner outputs accumulate unique field paths over time - -Example error: -``` -illegal_argument_exception: Limit of total fields [1000] has been exceeded -``` - -## Solution - -### Design Decisions - -1. **Each component owns its analytics schema** - - Components output structured `list` through dedicated ports (`findings`, `results`, `secrets`, `issues`) - - Component authors define the structure appropriate for their tool - - No generic "one schema fits all" approach - -2. **Analytics Sink accepts `list`** - - Input type: `z.array(z.record(z.string(), z.unknown()))` - - Each item in the array is indexed as a separate document - - Rejects arbitrary nested objects (must be an array) - -3. **Same timestamp for all findings in a batch** - - All findings from one component execution share the same `@timestamp` - - Captured once at the start of indexing, applied to all documents - -4. **Nested `shipsec` context** - - Workflow context stored under `shipsec.*` namespace - - Prevents field name collision with component data - - Clear separation: component fields at root, system fields under `shipsec` - -5. **Nested objects serialized before indexing** - - Any nested object or array within a finding is JSON-stringified - - Prevents field explosion from dynamic mapping - - Trade-off: Can't query inside serialized fields directly, but prevents index corruption - -6. **No `data` wrapper** - - Original PRD design wrapped component output in a `data` field - - New design: finding fields are at the top level for easier querying - -### Document Structure - -**Before (PRD design):** -```json -{ - "workflow_id": "...", - "workflow_name": "...", - "run_id": "...", - "node_ref": "...", - "component_id": "...", - "@timestamp": "...", - "asset_key": "...", - "data": { - "check_id": "DB_RLS_DISABLED", - "severity": "CRITICAL", - "metadata": { "schema": "public", "table": "users" } - } -} -``` - -**After (new design):** -```json -{ - "check_id": "DB_RLS_DISABLED", - "severity": "CRITICAL", - "title": "RLS Disabled on Table: users", - "resource": "public.users", - "metadata": "{\"schema\":\"public\",\"table\":\"users\"}", - "scanner": "supabase-scanner", - "asset_key": "abcdefghij1234567890", - "finding_hash": "a1b2c3d4e5f67890", - - "shipsec": { - "organization_id": "org_123", - "run_id": "shipsec-run-xxx", - "workflow_id": "d1d33161-929f-4af4-9a64-xxx", - "workflow_name": "Supabase Security Audit", - "component_id": "core.analytics.sink", - "node_ref": "analytics-sink-1" - }, - - "@timestamp": "2025-01-21T10:30:00.000Z" -} -``` - -### Component Output Ports - -Components should use their existing structured list outputs: - -| Component | Port | Type | Notes | -|-----------|------|------|-------| -| Nuclei | `results` | `z.array(z.record(z.string(), z.unknown()))` | Scanner + asset_key added | -| TruffleHog | `results` | `z.array(z.record(z.string(), z.unknown()))` | Scanner + asset_key added | -| Supabase Scanner | `results` | `z.array(z.record(z.string(), z.unknown()))` | Scanner + asset_key added | - -All `results` ports include: -- `scanner`: Scanner identifier (e.g., `'nuclei'`, `'trufflehog'`, `'supabase-scanner'`) -- `asset_key`: Primary asset identifier from the finding -- `finding_hash`: Stable hash for deduplication (16-char hex from SHA-256) - -### Finding Hash for Deduplication - -The `finding_hash` enables tracking findings across workflow runs: - -**Generation:** -```typescript -import { createHash } from 'crypto'; - -function generateFindingHash(...fields: (string | undefined | null)[]): string { - const normalized = fields.map((f) => (f ?? '').toLowerCase().trim()).join('|'); - return createHash('sha256').update(normalized).digest('hex').slice(0, 16); -} -``` - -**Key fields per scanner:** -| Scanner | Hash Fields | -|---------|-------------| -| Nuclei | `templateId + host + matchedAt` | -| TruffleHog | `DetectorType + Redacted + filePath` | -| Supabase Scanner | `check_id + projectRef + resource` | - -**Use cases:** -- **New vs recurring**: Is this finding appearing for the first time? -- **First-seen / last-seen**: When did we first detect this? Is it still present? -- **Resolution tracking**: Findings that stop appearing may be resolved -- **Deduplication**: Remove duplicates in dashboards across runs - -### `shipsec` Context Fields - -The indexer automatically adds these fields under `shipsec`: - -| Field | Description | -|-------|-------------| -| `organization_id` | Organization that owns the workflow | -| `run_id` | Unique identifier for this workflow execution | -| `workflow_id` | ID of the workflow definition | -| `workflow_name` | Human-readable workflow name | -| `component_id` | Component type (e.g., `core.analytics.sink`) | -| `node_ref` | Node reference in the workflow graph | -| `asset_key` | Auto-detected or specified asset identifier | - -### Querying in OpenSearch - -With this structure, users can: -- Filter by organization: `shipsec.organization_id: "org_123"` -- Filter by workflow: `shipsec.workflow_id: "xxx"` -- Filter by run: `shipsec.run_id: "xxx"` -- Filter by asset: `asset_key: "api.example.com"` -- Filter by scanner: `scanner: "nuclei"` -- Filter by component-specific fields: `severity: "CRITICAL"` -- Aggregate by severity: `terms` aggregation on `severity` field -- Track finding history: `finding_hash: "a1b2c3d4" | sort @timestamp` -- Find recurring findings: Group by `finding_hash`, count occurrences - -### Trade-offs - -| Decision | Pro | Con | -|----------|-----|-----| -| Serialize nested objects | Prevents field explosion | Can't query inside serialized fields | -| `shipsec` namespace | No field collision | Slightly more verbose queries | -| No generic schema | Better fit per component | Less consistency across components | -| Same timestamp per batch | Accurate (same scan time) | Can't distinguish individual finding times | - -### Implementation Files - -1. `/worker/src/utils/opensearch-indexer.ts` - Add `shipsec` context, serialize nested objects -2. `/worker/src/components/core/analytics-sink.ts` - Accept `list`, consistent timestamp -3. Component files - Ensure structured output, add `results` port where missing - -### Backward Compatibility - -- Existing workflows connecting `rawOutput` to Analytics Sink will still work -- Analytics Sink continues to accept any data type for backward compatibility -- New `list` processing only triggers when input is an array - -### Future Considerations - -1. **Index templates**: Create OpenSearch index template with explicit mappings for `shipsec.*` fields -2. **Field discovery**: Build UI to show available fields from indexed data -3. **Schema validation**: Optional strict mode to validate findings against expected schema diff --git a/.claude/skills/codex-review/SKILL.md b/.claude/skills/codex-review/SKILL.md new file mode 100644 index 000000000..8e40bf1fc --- /dev/null +++ b/.claude/skills/codex-review/SKILL.md @@ -0,0 +1,175 @@ +--- +name: codex-review +description: Send the current plan to OpenAI Codex CLI for iterative review. Claude and Codex go back-and-forth until Codex approves the plan. +user_invocable: true +--- + +# Codex Plan Review (Iterative) + +Send the current implementation plan to OpenAI Codex for review. Claude revises the plan based on Codex's feedback and re-submits until Codex approves. Max 5 rounds. + +--- + +## When to Invoke + +- When the user runs `/codex-review` during or after plan mode +- When the user wants a second opinion on a plan from a different model + +## Agent Instructions + +When invoked, perform the following iterative review loop: + +### Step 1: Generate Session ID + +Generate a unique ID to avoid conflicts with other concurrent Claude Code sessions: + +```bash +REVIEW_ID=$(uuidgen | tr '[:upper:]' '[:lower:]' | head -c 8) +``` + +Use this for all temp file paths: `/tmp/claude-plan-${REVIEW_ID}.md` and `/tmp/codex-review-${REVIEW_ID}.md`. + +### Step 2: Capture the Plan + +Write the current plan to the session-scoped temporary file. The plan is whatever implementation plan exists in the current conversation context (from plan mode, or a plan discussed in chat). + +1. Write the full plan content to `/tmp/claude-plan-${REVIEW_ID}.md` +2. If there is no plan in the current context, ask the user what they want reviewed + +### Step 3: Initial Review (Round 1) + +Run Codex CLI in non-interactive mode to review the plan: + +```bash +codex exec \ + -m gpt-5.3-codex \ + -s read-only \ + -o /tmp/codex-review-${REVIEW_ID}.md \ + "Review the implementation plan in /tmp/claude-plan-${REVIEW_ID}.md. Focus on: +1. Correctness - Will this plan achieve the stated goals? +2. Risks - What could go wrong? Edge cases? Data loss? +3. Missing steps - Is anything forgotten? +4. Alternatives - Is there a simpler or better approach? +5. Security - Any security concerns? + +Be specific and actionable. If the plan is solid and ready to implement, end your review with exactly: VERDICT: APPROVED + +If changes are needed, end with exactly: VERDICT: REVISE" +``` + +**Capture the Codex session ID** from the output line that says `session id: `. Store this as `CODEX_SESSION_ID`. You MUST use this exact ID to resume in subsequent rounds (do NOT use `--last`, which would grab the wrong session if multiple reviews are running concurrently). + +**Notes:** + +- Use `-m gpt-5.3-codex` as the default model (configured in `~/.codex/config.toml`). If the user specifies a different model (e.g., `/codex-review o4-mini`), use that instead. +- Use `-s read-only` so Codex can read the codebase for context but cannot modify anything. +- Use `-o` to capture the output to a file for reliable reading. + +### Step 4: Read Review & Check Verdict + +1. Read `/tmp/codex-review-${REVIEW_ID}.md` +2. Present Codex's review to the user: + +``` +## Codex Review — Round N (model: gpt-5.3-codex) + +[Codex's feedback here] +``` + +3. Check the verdict: + - If **VERDICT: APPROVED** → go to Step 7 (Done) + - If **VERDICT: REVISE** → go to Step 5 (Revise & Re-submit) + - If no clear verdict but feedback is all positive / no actionable items → treat as approved + - If max rounds (5) reached → go to Step 7 with a note that max rounds hit + +### Step 5: Revise the Plan + +Based on Codex's feedback: + +1. **Revise the plan** — address each issue Codex raised. Update the plan content in the conversation context and rewrite `/tmp/claude-plan-${REVIEW_ID}.md` with the revised version. +2. **Briefly summarize** what you changed for the user: + +``` +### Revisions (Round N) +- [What was changed and why, one bullet per Codex issue addressed] +``` + +3. Inform the user what's happening: "Sending revised plan back to Codex for re-review..." + +### Step 6: Re-submit to Codex (Rounds 2-5) + +Resume the existing Codex session so it has full context of the prior review: + +```bash +codex exec resume ${CODEX_SESSION_ID} \ + "I've revised the plan based on your feedback. The updated plan is in /tmp/claude-plan-${REVIEW_ID}.md. + +Here's what I changed: +[List the specific changes made] + +Please re-review. If the plan is now solid and ready to implement, end with: VERDICT: APPROVED +If more changes are needed, end with: VERDICT: REVISE" 2>&1 | tail -80 +``` + +**Note:** `codex exec resume` does NOT support `-o` flag. Capture output from stdout instead (pipe through `tail` to skip startup lines). Read the Codex response directly from the command output. + +Then go back to **Step 4** (Read Review & Check Verdict). + +**Important:** If `resume ${CODEX_SESSION_ID}` fails (e.g., session expired), fall back to a fresh `codex exec` call with context about the prior rounds included in the prompt. + +### Step 7: Present Final Result + +Once approved (or max rounds reached): + +``` +## Codex Review — Final (model: gpt-5.3-codex) + +**Status:** ✅ Approved after N round(s) + +[Final Codex feedback / approval message] + +--- +**The plan has been reviewed and approved by Codex. Ready for your approval to implement.** +``` + +If max rounds were reached without approval: + +``` +## Codex Review — Final (model: gpt-5.3-codex) + +**Status:** ⚠️ Max rounds (5) reached — not fully approved + +**Remaining concerns:** +[List unresolved issues from last review] + +--- +**Codex still has concerns. Review the remaining items and decide whether to proceed or continue refining.** +``` + +### Step 8: Cleanup + +Remove the session-scoped temporary files: + +```bash +rm -f /tmp/claude-plan-${REVIEW_ID}.md /tmp/codex-review-${REVIEW_ID}.md +``` + +## Loop Summary + +``` +Round 1: Claude sends plan → Codex reviews → REVISE? +Round 2: Claude revises → Codex re-reviews (resume session) → REVISE? +Round 3: Claude revises → Codex re-reviews (resume session) → APPROVED ✅ +``` + +Max 5 rounds. Each round preserves Codex's conversation context via session resume. + +## Rules + +- Claude **actively revises the plan** based on Codex feedback between rounds — this is NOT just passing messages, Claude should make real improvements +- Default model is `gpt-5.3-codex`. Accept model override from the user's arguments (e.g., `/codex-review o4-mini`) +- Always use read-only sandbox mode — Codex should never write files +- Max 5 review rounds to prevent infinite loops +- Show the user each round's feedback and revisions so they can follow along +- If Codex CLI is not installed or fails, inform the user and suggest `npm install -g @openai/codex` +- If a revision contradicts the user's explicit requirements, skip that revision and note it for the user diff --git a/.claude/skills/component-development/SKILL.md b/.claude/skills/component-development/SKILL.md index 61e71994d..ad5b01630 100644 --- a/.claude/skills/component-development/SKILL.md +++ b/.claude/skills/component-development/SKILL.md @@ -12,18 +12,23 @@ description: Creating components (inline/docker). Dynamic ports, retry policies, ## Quick Reference ### File Location + ``` worker/src/components//.ts ``` + Categories: `security/`, `core/`, `ai/`, `notification/`, `manual-action/` ### ID Pattern + ``` .. ``` + Examples: `shipsec.dnsx.run`, `core.http.request`, `ai.llm.generate` ### Minimal Component + ```typescript import { z } from 'zod'; import { defineComponent, inputs, outputs, port } from '@shipsec/component-sdk'; @@ -31,9 +36,9 @@ import { defineComponent, inputs, outputs, port } from '@shipsec/component-sdk'; export default defineComponent({ id: 'category.tool.action', label: 'My Component', - category: 'security', // or: core, ai, notification, manual_action - runner: { kind: 'inline' }, // or: docker - + category: 'security', // or: core, ai, notification, manual_action + runner: { kind: 'inline' }, // or: docker + inputs: inputs({ target: port(z.string(), { label: 'Target Host' }), }), @@ -42,10 +47,10 @@ export default defineComponent({ success: port(z.boolean(), { label: 'Success' }), }), - async execute({ inputs }, context) { + async execute({ inputs }, context) { // ... logic ... return { success: true }; - } + }, }); ``` @@ -56,6 +61,7 @@ export default defineComponent({ ### When Creating a New Component 1. **Check existing components** in same category for patterns + ```bash ls worker/src/components// ``` @@ -69,8 +75,10 @@ export default defineComponent({ - Unit test in `__tests__/.test.ts` 4. **For Docker components:** - - MUST use shell wrapper: `entrypoint: 'sh', command: ['-c', 'tool "$@"', '--']` + - Use a shell wrapper when the tool needs PTY-compatible terminal behavior or shell setup - MUST use `IsolatedContainerVolume` for file I/O + - Prefer parsing native output files over parsing PTY/stdout for JSON-producing tools + - For tool-native outputs, set `expectOutputFile: false` and read the mounted output file directly - Reference: `worker/src/components/security/dnsx.ts` ### Quick Component Checklist @@ -91,12 +99,16 @@ export default defineComponent({ ## Key Patterns (Quick Look) ### Inline Component + ```typescript -runner: { kind: 'inline' } +runner: { + kind: 'inline'; +} // Just write TypeScript in execute() ``` -### Docker Component +### Docker Component + ```typescript runner: { kind: 'docker', @@ -105,11 +117,14 @@ runner: { command: ['-c', 'tool "$@"', '--'], network: 'bridge', } -// ⚠️ Shell wrapper required for PTY +// Use shell wrappers for PTY/log UX or shell setup. +// For structured output, prefer native file output or redirect stdout to a mounted file. ``` + → See: `docs/development/component-development.mdx#docker-component-requirements` ### File I/O (Docker) + ```typescript import { IsolatedContainerVolume } from '../../utils/isolated-volume'; const volume = new IsolatedContainerVolume(tenantId, context.runId); @@ -121,9 +136,38 @@ try { await volume.cleanup(); } ``` + → See: `docs/development/isolated-volumes.mdx` +### Structured Output Pattern + +```typescript +import { + TOOL_OUTPUT_DIR, + createToolOutputVolume, + readToolOutputFile, +} from '../../utils/tool-output'; + +const outputVolume = await createToolOutputVolume(tenantId, context.runId, 'tool-output'); +try { + const config = { + ...runner, + command: ['-c', `tool \"$@\" 1>${TOOL_OUTPUT_DIR}/report.json`, '--', '--json'], + stdinJson: false, + expectOutputFile: false, + volumes: [outputVolume.getVolumeConfig(TOOL_OUTPUT_DIR, false)], + }; + await runComponentWithRunner(config, async () => undefined as void, inputs, context); + const rawOutput = await readToolOutputFile(outputVolume, 'report.json', 'Tool'); +} finally { + await outputVolume.cleanup(); +} +``` + +→ Use this for tools where PTY/live terminal output is useful but parser correctness must come from a file. + ### Entry Point Runtime Inputs + ```typescript // Supported types: text, number, file, json, array, secret const runtimeInputs = [ @@ -132,18 +176,20 @@ const runtimeInputs = [ ]; // Secret type renders as password field in UI ``` + → See: `docs/development/component-development.mdx#entry-point-runtime-input-types` ### Inputs vs. Parameters -| Type | Function | UI Location | Use Case | -|------|----------|-------------|----------| -| **Inputs** | `inputs()` + `port()` | Canvas handles | Runtime data (target, apiKey, fileId) | -| **Parameters**| `parameters()` + `param()` | Sidebar form | Static config (model, timeout, enum) | +| Type | Function | UI Location | Use Case | +| -------------- | -------------------------- | -------------- | ------------------------------------- | +| **Inputs** | `inputs()` + `port()` | Canvas handles | Runtime data (target, apiKey, fileId) | +| **Parameters** | `parameters()` + `param()` | Sidebar form | Static config (model, timeout, enum) | **Note on Inputs:** You can set `valuePriority: 'manual-first'` in port metadata to prioritize manual overrides over connected data. ### Defining Parameters + ```typescript parameters: parameters({ model: param(z.string().default('gpt-4'), { @@ -151,12 +197,13 @@ parameters: parameters({ editor: 'select', options: [{ label: 'GPT-4', value: 'gpt-4' }], }), - timeout: param(z.number().min(1), { - label: 'Timeout', - editor: 'number' + timeout: param(z.number().min(1), { + label: 'Timeout', + editor: 'number', }), -}) +}); ``` + Editors: `text`, `textarea`, `number`, `boolean`, `select`, `multi-select`, `json`, `secret`. --- @@ -187,6 +234,7 @@ throw new AuthenticationError('Invalid API key'); // Retryable (Temporal will retry) throw new ServiceError('API down', { statusCode: 503 }); ``` + → See: `docs/development/component-development.mdx#error-handling` --- @@ -208,24 +256,25 @@ RUN_E2E=true bun --cwd e2e-tests test ## Common Mistakes to Avoid -| Mistake | Fix | -|---------|-----| -| Docker without shell wrapper | Use `entrypoint: 'sh', command: ['-c', 'tool "$@"', '--']` | -| Direct file mounts in Docker | Use `IsolatedContainerVolume` | -| Missing `finally` for volume cleanup | Always `await volume.cleanup()` in finally | -| Missing `port()` wrapper | All input/output schemas must use `port()` | -| Mixing inputs and parameters | Use `inputs()` for runtime ports and `parameters()` for design-time config | -| Throwing plain Error | Use SDK errors: `ValidationError`, `ServiceError`, etc. | +| Mistake | Fix | +| ------------------------------------ | ------------------------------------------------------------------------------------------------- | +| Parsing PTY JSON directly | Write structured results to a mounted file and parse the file | +| Docker without needed shell setup | Use `entrypoint: 'sh', command: ['-c', 'tool "$@"', '--']` when the tool needs PTY or shell setup | +| Direct file mounts in Docker | Use `IsolatedContainerVolume` | +| Missing `finally` for volume cleanup | Always `await volume.cleanup()` in finally | +| Missing `port()` wrapper | All input/output schemas must use `port()` | +| Mixing inputs and parameters | Use `inputs()` for runtime ports and `parameters()` for design-time config | +| Throwing plain Error | Use SDK errors: `ValidationError`, `ServiceError`, etc. | --- ## Reference Files -| What | Where | -|------|-------| -| Full docs | `docs/development/component-development.mdx` | -| Isolated volumes | `docs/development/isolated-volumes.mdx` | -| SDK source | `packages/component-sdk/src/` | -| Good example (Docker) | `worker/src/components/security/dnsx.ts` | +| What | Where | +| --------------------- | -------------------------------------------- | +| Full docs | `docs/development/component-development.mdx` | +| Isolated volumes | `docs/development/isolated-volumes.mdx` | +| SDK source | `packages/component-sdk/src/` | +| Good example (Docker) | `worker/src/components/security/dnsx.ts` | | Good example (inline) | `worker/src/components/core/http-request.ts` | -| E2E tests | `e2e-tests/` | +| E2E tests | `e2e-tests/` | diff --git a/.claude/skills/performance-patterns/SKILL.md b/.claude/skills/performance-patterns/SKILL.md new file mode 100644 index 000000000..fc2474a78 --- /dev/null +++ b/.claude/skills/performance-patterns/SKILL.md @@ -0,0 +1,249 @@ +--- +name: performance-patterns +description: Performance patterns for frontend stores, API calls, backend services, and Temporal interactions. Load this skill when building features that touch API endpoints, Zustand stores, or workflow run status. +--- + +# Performance Patterns + +**Full guide:** `docs/development/performance-patterns.md` + +--- + +## 1. Temporal RPC Avoidance + +**Rule:** Never call Temporal for terminal runs. Always check the DB cache first. + +Terminal statuses are defined in `packages/shared/src/execution.ts`: + +```typescript +import { TERMINAL_STATUSES } from '@shipsec/shared'; +// ['COMPLETED', 'FAILED', 'CANCELLED', 'TERMINATED', 'TIMED_OUT'] +``` + +**Pattern (backend service):** + +```typescript +// GOOD: Cache-first +if (run.status && TERMINAL_STATUSES.includes(run.status)) { + return run.status; // DB hit only, no Temporal RPC +} +// Only call Temporal for non-terminal (running/pending) runs +const desc = await this.temporalService.describeWorkflow({ workflowId: run.runId }); + +// Cache terminal result fire-and-forget +if (TERMINAL_STATUSES.includes(normalizedStatus)) { + this.runRepository + .cacheTerminalStatus(run.runId, normalizedStatus, closeTime) + .catch((err) => this.logger.warn(`Cache failed: ${err}`)); +} +``` + +```typescript +// BAD: Always calling Temporal +const desc = await this.temporalService.describeWorkflow({ workflowId: run.runId }); +``` + +**Rule:** Always use the shared `TERMINAL_STATUSES` constant. Never hardcode terminal status arrays. + +```typescript +// BAD +if (['COMPLETED', 'FAILED', 'CANCELLED'].includes(status)) { ... } + +// GOOD +import { TERMINAL_STATUSES } from '@shipsec/shared'; +if ((TERMINAL_STATUSES as readonly string[]).includes(status)) { ... } +``` + +--- + +## 2. Lightweight List Endpoints + +**Rule:** List pages should use summary endpoints that exclude heavy fields (like `graph` JSON). + +```typescript +// BAD: Fetching full workflow objects for a list page +const workflows = await api.workflows.list(); + +// GOOD: Fetch only metadata needed for display +const workflows = await api.workflows.listSummary(); +``` + +**When creating new list endpoints:** Return only the fields needed for display (id, name, description, counts, timestamps). Never include serialized graphs, full configs, or nested objects in list responses. + +--- + +## 3. Frontend Lazy Loading + +**Rule:** All page-level components must use `React.lazy()`. + +```typescript +// GOOD +const MyNewPage = lazy(() => import('@/pages/MyNewPage').then((m) => ({ default: m.MyNewPage }))); + +// BAD: Direct import at top level +import { MyNewPage } from '@/pages/MyNewPage'; +``` + +Pages are wrapped in a shared `` in `App.tsx`. Do not add per-page Suspense boundaries unless the page has sub-routes with their own lazy components. + +--- + +## 4. Store Fetch Deduplication + +**Rule:** Zustand stores that fetch data must guard against redundant fetches. + +**Pattern: Skip-if-loaded** + +```typescript +// GOOD +fetchComponents: async () => { + if (get().components.length > 0) return; // Already loaded + const data = await api.components.list(); + set({ components: data }); +}, +``` + +**Pattern: Cooldown + inflight dedup** (for data that refreshes) + +```typescript +// GOOD: Prevent rapid re-fetching +const COOLDOWN_MS = 5000; +const lastFetchRef = useRef(0); +const inflightRef = useRef | null>(null); + +const fetchData = async () => { + if (Date.now() - lastFetchRef.current < COOLDOWN_MS) return; + if (inflightRef.current) return inflightRef.current; + + const promise = doFetch().finally(() => { + inflightRef.current = null; + }); + inflightRef.current = promise; + lastFetchRef.current = Date.now(); + return promise; +}; +``` + +--- + +## 5. Paginated Data Loading + +**Rule:** Lists with potentially many items should load a small initial batch with "load more". + +```typescript +// GOOD: Paginated with initial limit +const INITIAL_LIMIT = 5; +const LOAD_MORE_LIMIT = 20; + +fetchRuns: async (workflowId) => { + const runs = await api.workflows.listRuns({ workflowId, limit: INITIAL_LIMIT }); + set({ runs, hasMore: runs.length === INITIAL_LIMIT }); +}, +fetchMoreRuns: async (workflowId) => { + const current = get().runs; + const more = await api.workflows.listRuns({ + workflowId, limit: LOAD_MORE_LIMIT, offset: current.length, + }); + set({ runs: [...current, ...more], hasMore: more.length === LOAD_MORE_LIMIT }); +}, +``` + +**Backend:** All list endpoints should accept `limit` and `offset` parameters. + +--- + +## 6. Smart Polling + +**Rule:** Only poll for live/running data. Stop polling when data reaches a terminal state. + +```typescript +// GOOD: Only poll non-terminal runs +import { isRunLive } from '@/features/workflow-builder/utils/executionRuns'; + +useEffect(() => { + if (!isRunLive(currentRun)) return; // No polling needed + const interval = setInterval(() => refreshStatus(), 3000); + return () => clearInterval(interval); +}, [currentRun?.status]); +``` + +```typescript +// BAD: Polling regardless of status +useEffect(() => { + const interval = setInterval(() => refreshStatus(), 3000); + return () => clearInterval(interval); +}, []); +``` + +Use `isRunLive()` from `frontend/src/features/workflow-builder/utils/executionRuns.ts` for run liveness checks. + +--- + +## 7. Conditional Data Fetching + +**Rule:** Don't fetch data the user hasn't asked for yet. + +```typescript +// GOOD: Only fetch runs when entering execution mode +if (mode === 'execution' || routeRunId) { + await fetchRuns(workflowId); +} + +// BAD: Always fetch runs on page load +useEffect(() => { + fetchRuns(workflowId); +}, [workflowId]); +``` + +--- + +## 8. Route Prefetching + +**Rule:** All new page routes must be registered in the prefetch map so navigation feels instant. + +The app uses idle + hover prefetching via `frontend/src/lib/prefetch-routes.ts`. When a new page is added: + +1. Add a `React.lazy()` entry in `App.tsx` (standard lazy loading) +2. Add the route's path and import to `routeImports` in `prefetch-routes.ts` +3. Sidebar links automatically get hover prefetching via `onMouseEnter={() => prefetchRoute(item.href)}` +4. All routes in the map are idle-prefetched after initial page load via `requestIdleCallback` + +```typescript +// In prefetch-routes.ts — add new route to the map: +const routeImports: Record Promise> = { + '/my-new-page': () => import('@/pages/MyNewPage'), + // ... existing routes +}; +``` + +```typescript +// In AppLayout.tsx — sidebar links already have hover prefetching: + prefetchRoute(item.href)} +> +``` + +**How it works:** + +- **Idle prefetching**: After initial page load, `requestIdleCallback` triggers `import()` for all sidebar routes. The browser caches the modules so `React.lazy()` resolves instantly on navigation. +- **Hover prefetching**: `onMouseEnter` on sidebar links triggers `import()` ~200-400ms before click. Acts as a fallback if idle prefetching hasn't completed yet. +- **Deduplication**: A `Set` tracks prefetched routes to avoid redundant imports. + +**Do NOT:** + +- Add heavy non-page modules to the prefetch map (keep it to route-level page chunks only) +- Prefetch routes that require authentication checks before loading + +--- + +## Quick Checklist for New Features + +- [ ] Page component uses `React.lazy()` in `App.tsx` +- [ ] Route added to `routeImports` in `frontend/src/lib/prefetch-routes.ts` +- [ ] List endpoint returns only display-needed fields (no full graphs/configs) +- [ ] Store fetches are deduplicated (skip-if-loaded or cooldown) +- [ ] Lists with >10 potential items use pagination (limit + offset) +- [ ] Polling stops for terminal/completed states +- [ ] Terminal status checks use shared `TERMINAL_STATUSES` from `@shipsec/shared` +- [ ] Backend services check cached status before calling Temporal diff --git a/.claude/skills/run-reporting/SKILL.md b/.claude/skills/run-reporting/SKILL.md new file mode 100644 index 000000000..a7eb67530 --- /dev/null +++ b/.claude/skills/run-reporting/SKILL.md @@ -0,0 +1,72 @@ +--- +name: run-reporting +description: Report ShipSec workflow, scanner, wiki, and agent runs with a details table and clickable Studio run URL. +--- + +# Run Reporting + +Use this skill whenever the user asks to start, trigger, launch, run, check, +monitor, resume, inspect, or summarize a ShipSec run. This includes workflow +runs, scanner runs, agent runs, security wiki runs, Temporal executions, and +validation/debug runs. + +## Required Response Shape + +Every run response must include a compact markdown table with the run details. +Include the table when a run starts, when status is checked, and when a run +finishes or fails. + +Required fields when known: + +| Field | Value | +| ------------ | ------------------------------------------------------------------------------- | +| Run | `` | +| Status | `PENDING`, `RUNNING`, `COMPLETED`, `FAILED`, or exact observed status | +| URL | `[Open run](http://127.0.0.1:5173/workflows//runs/)` | +| Workflow | `` | +| Temporal run | `` | +| Target | account, repo, cluster, project, domain, or other scanned target | +| Started | exact timestamp when available | +| Notes | one short sentence for important context, error, or next step | + +Omit fields that genuinely are not available yet, but do not omit the URL if it +can be constructed from known IDs. + +## URL Rules + +- Prefer the Studio run page as the primary link: + + ```text + http://127.0.0.1:/workflows//runs/ + ``` + +- Determine the frontend port from the local instance when possible: + - Instance `0` uses `5173` + - Other instances usually use `5173 + (instance * 100)` + - If uncertain, check `just instance show` or the running PM2/docker config + +- Use a clickable markdown link, never a bare URL, when writing the final answer. + +- If only the Temporal workflow/run IDs are known, include those and state that + the Studio URL is not available yet. + +## Safety + +- Never include secrets, API keys, tokens, cloud credentials, or raw auth headers + in the table. +- If a run failed because of auth, summarize the failure without printing the + credential value. +- If multiple runs were started, include one row per run or one table per run, + whichever is easier to scan. + +## Example + +| Field | Value | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------ | +| Run | `code-wiki-shipsec-studio-20260513-145413` | +| Status | `RUNNING` | +| URL | [Open run](http://127.0.0.1:5173/workflows/2a77ec42-aa6a-45df-a7e9-134f4b4d58b3/runs/code-wiki-shipsec-studio-20260513-145413) | +| Workflow | `2a77ec42-aa6a-45df-a7e9-134f4b4d58b3` | +| Temporal run | `019e2166-ed2f-7582-9f7a-894d0aec819b` | +| Target | `security wiki` | +| Notes | Wiki ingestion is running. | diff --git a/.codex/config.toml b/.codex/config.toml deleted file mode 100644 index 959bacf60..000000000 --- a/.codex/config.toml +++ /dev/null @@ -1,3 +0,0 @@ -[mcp_servers.puppeteer] -command = "npx" -args = ["-y", "@modelcontextprotocol/server-puppeteer"] diff --git a/.dockerignore b/.dockerignore index 88261900d..0e10aa578 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,6 +12,10 @@ out/ .env .env.local .env.*.local +backend/.env +worker/.env +frontend/.env +.instances/ *.log # IDE files @@ -70,4 +74,4 @@ jest.config.* .nyc_output .coverage .turbo -.vercel \ No newline at end of file +.vercel diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1593314cd..e2e50adc0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,22 +5,25 @@ on: branches: [main] pull_request: +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + jobs: validate: - runs-on: ubuntu-latest + runs-on: arc-runner-set timeout-minutes: 25 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Bun - uses: oven-sh/setup-bun@v1 + uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: '1.3.10' - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile - name: Run linter run: bun run lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6fad0dae..a93153e56 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ name: Release on: push: tags: - - 'v*.*.*' # Triggers on tags like v1.0.0, v1.2.3, etc. + - 'v*.*.*' # Triggers on tags like v1.0.0, v1.2.3, etc. workflow_dispatch: inputs: version: @@ -12,31 +12,32 @@ on: type: string env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' REGISTRY: ghcr.io IMAGE_PREFIX: ${{ github.repository_owner }}/studio jobs: build-and-push: name: Build and Push Docker Images - runs-on: ubuntu-latest + runs-on: arc-runner-set permissions: contents: write packages: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: - fetch-depth: 0 # Full history for changelog generation + fetch-depth: 0 # Full history for changelog generation - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -62,7 +63,7 @@ jobs: # Check if this is the latest tag (highest semver) CURRENT_TAG="${{ steps.version.outputs.version_clean }}" LATEST_TAG=$(git tag -l 'v*.*.*' | sort -V | tail -1 | sed 's/^v//') - + if [ "$CURRENT_TAG" = "$LATEST_TAG" ]; then echo "is_latest=true" >> $GITHUB_OUTPUT echo "This is the latest release" @@ -81,7 +82,7 @@ jobs: # Convert repository owner to lowercase for Docker registry compatibility IMAGE_PREFIX_LOWER=$(echo "${{ env.IMAGE_PREFIX }}" | tr '[:upper:]' '[:lower:]') VERSION_TAG="${{ env.REGISTRY }}/${IMAGE_PREFIX_LOWER}-backend:${{ steps.version.outputs.version_clean }}" - + if [ "${{ steps.is_latest.outputs.is_latest }}" = "true" ]; then echo "backend_tags<> $GITHUB_OUTPUT echo "$VERSION_TAG" >> $GITHUB_OUTPUT @@ -112,7 +113,7 @@ jobs: fi - name: Build and push backend image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile @@ -128,7 +129,7 @@ jobs: cache-to: type=gha,mode=max - name: Build and push worker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile @@ -144,7 +145,7 @@ jobs: cache-to: type=gha,mode=max - name: Build and push frontend image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile @@ -166,10 +167,10 @@ jobs: run: | VERSION="${{ steps.version.outputs.version }}" PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") - + # Convert repository owner to lowercase for Docker registry compatibility IMAGE_PREFIX_LOWER=$(echo "${{ env.IMAGE_PREFIX }}" | tr '[:upper:]' '[:lower:]') - + if [ -z "$PREVIOUS_TAG" ]; then echo "No previous tag found, generating full changelog" CHANGELOG=$(git log --pretty=format:"- %s (%h)" --no-merges) @@ -177,11 +178,11 @@ jobs: echo "Generating changelog from $PREVIOUS_TAG to $VERSION" CHANGELOG=$(git log ${PREVIOUS_TAG}..HEAD --pretty=format:"- %s (%h)" --no-merges) fi - + if [ -z "$CHANGELOG" ]; then CHANGELOG="No changes detected" fi - + { echo "## Release $VERSION" echo "" @@ -195,7 +196,7 @@ jobs: echo "" echo "$CHANGELOG" } > CHANGELOG.md - + echo "changelog<> $GITHUB_OUTPUT cat CHANGELOG.md >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT @@ -227,57 +228,6 @@ jobs: $PRERELEASE_FLAG fi - - name: Update version check service - env: - VERSION_CHECK_URL: ${{ secrets.VERSION_CHECK_URL || 'https://version.shipsec.ai' }} - VERSION_CHECK_ADMIN_SECRET: ${{ secrets.VERSION_CHECK_ADMIN_SECRET }} - run: | - echo "🚀 Updating version check service..." - echo " App: studio" - echo " Version: ${{ steps.version.outputs.version_clean }}" - echo " Endpoint: $VERSION_CHECK_URL/api/admin/update-version" - - response=$(curl -s --connect-timeout 10 --max-time 30 -w "\n%{http_code}" -X POST "$VERSION_CHECK_URL/api/admin/update-version" \ - -H "Authorization: Bearer $VERSION_CHECK_ADMIN_SECRET" \ - -H "Content-Type: application/json" \ - -d "{\"app\":\"studio\",\"version\":\"${{ steps.version.outputs.version_clean }}\"}") - - http_code=$(echo "$response" | tail -n1) - body=$(echo "$response" | head -n-1) - - echo "Response: $body" - - if [ "$http_code" -eq 200 ]; then - echo "✅ Version check service updated successfully!" - else - echo "❌ Failed to update version check service (HTTP $http_code)" - echo "Response: $body" - exit 1 - fi - - - name: Verify version update - env: - VERSION_CHECK_URL: ${{ secrets.VERSION_CHECK_URL || 'https://version.shipsec.ai' }} - run: | - echo "🔍 Verifying version update..." - - # Wait a moment for cache to update - sleep 2 - - # Check if the version was updated - response=$(curl -s --connect-timeout 10 --max-time 30 "$VERSION_CHECK_URL/api/version/check?app=studio&version=0.0.1") - latest_version=$(echo "$response" | jq -r '.latest_version') - - echo "Latest version returned by API: $latest_version" - echo "Expected version: ${{ steps.version.outputs.version_clean }}" - - if [ "$latest_version" = "${{ steps.version.outputs.version_clean }}" ]; then - echo "✅ Verification successful! Version check service is returning the correct version." - else - echo "⚠️ Warning: API returned different version than expected" - echo " This might be expected if GitHub release fetching is enabled" - fi - - name: Bump package.json version via PR if: steps.is_latest.outputs.is_latest == 'true' continue-on-error: true diff --git a/.gitignore b/.gitignore index 0b0e85af3..5777ab1d0 100644 --- a/.gitignore +++ b/.gitignore @@ -51,9 +51,17 @@ CLAUDE.md # Testing coverage/ .nyc_output/ +e2e-tests/ui/test-results/ + +# Planning files +.planning/ + +# Auto-generated +wiki.json # Temporary files *.tmp +*.bak .cache/ .temp/ @@ -73,5 +81,25 @@ vite.config.ts.timestamp-* .playground/ .playground/ .playground/ +.playwright-mcp/ .omc/ MCP_FLOW_TRACE.md + +# Mutagen (machine-specific sync config) +mutagen.yml +mutagen.yml.lock + +# Terraform / OpenTofu +.terraform/ +*.tfstate +*.tfstate.* +*.tfvars +*.tfvars.json +crash.log +crash.*.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json +.terraform.lock.hcl.bak +.omx/ diff --git a/.playground/test-manual-actions-e2e.ts b/.playground/test-manual-actions-e2e.ts deleted file mode 100644 index 15671ecba..000000000 --- a/.playground/test-manual-actions-e2e.ts +++ /dev/null @@ -1,242 +0,0 @@ -/** - * E2E Test: Manual Action Components with Dynamic Templates - * - * This script tests the complete manual action flow: - * 1. Creates a workflow with Manual Approval, Selection, and Form - * 2. Uses dynamic variables and templates in all of them - * 3. Runs the workflow with runtime inputs - * 4. Verifies the interpolated content in pending requests - * 5. Resolves each request via API - * 6. Verifies workflow completion - */ - -const API_BASE = 'http://localhost:3211/api/v1'; - -const HEADERS = { - 'Content-Type': 'application/json', - 'x-internal-token': 'local-internal-token', -}; - -async function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -async function main() { - console.log('🚀 Starting E2E Manual Actions Test...\n'); - - const TEST_USER = 'betterclever'; - const TEST_PROJECT = 'ShipSec-Studio-Refactor'; - - // 1. Create Workflow - console.log('📝 Creating multi-action workflow...'); - - const workflowGraph = { - name: 'E2E Manual Actions Test ' + Date.now(), - nodes: [ - { - id: 'start', - type: 'core.workflow.entrypoint', - position: { x: 0, y: 0 }, - data: { - label: 'Start', - config: { - runtimeInputs: [ - { id: 'userName', label: 'User Name', type: 'string', required: true } - ] - }, - }, - }, - { - id: 'logic', - type: 'core.logic.script', - position: { x: 200, y: 0 }, - data: { - label: 'Prepare Data', - config: { - code: `export async function script(input: Input): Promise { return { projectName: "${TEST_PROJECT}" }; }`, - returns: [{ name: 'projectName', type: 'string' }] - }, - }, - }, - { - id: 'approval', - type: 'core.manual_action.approval', - position: { x: 400, y: 0 }, - data: { - label: 'Manual Approval', - config: { - title: 'Approve {{projectName}}', - description: 'Hello **{{userName}}**, please approve the release of **{{projectName}}**.', - variables: [ - { name: 'userName', type: 'string' }, - { name: 'projectName', type: 'string' } - ] - }, - }, - }, - { - id: 'selection', - type: 'core.manual_action.selection', - position: { x: 600, y: 0 }, - data: { - label: 'Manual Selection', - config: { - title: 'Select Role for {{userName}}', - description: 'Project context: {{projectName}}', - options: ['Admin', 'Editor', 'Viewer'], - variables: [ - { name: 'userName', type: 'string' }, - { name: 'projectName', type: 'string' } - ] - }, - }, - }, - { - id: 'form', - type: 'core.manual_action.form', - position: { x: 800, y: 0 }, - data: { - label: 'Manual Form', - config: { - title: 'Metadata for {{projectName}}', - description: 'Please provide details for **{{projectName}}** deployment.', - schema: { - type: 'object', - properties: { - environment: { type: 'string', enum: ['prod', 'staging'] }, - nodes: { type: 'number', default: 3 } - }, - required: ['environment'] - }, - variables: [ - { name: 'projectName', type: 'string' } - ] - }, - }, - }, - ], - edges: [ - { id: 'e1', source: 'start', target: 'logic' }, - { id: 'e2', source: 'logic', target: 'approval' }, - { id: 'e3', source: 'approval', target: 'selection' }, - { id: 'e4', source: 'selection', target: 'form' }, - - // Data connections - { id: 'd1', source: 'start', sourceHandle: 'userName', target: 'approval', targetHandle: 'userName' }, - { id: 'd2', source: 'logic', sourceHandle: 'projectName', target: 'approval', targetHandle: 'projectName' }, - { id: 'd3', source: 'start', sourceHandle: 'userName', target: 'selection', targetHandle: 'userName' }, - { id: 'd4', source: 'logic', sourceHandle: 'projectName', target: 'selection', targetHandle: 'projectName' }, - { id: 'd5', source: 'logic', sourceHandle: 'projectName', target: 'form', targetHandle: 'projectName' }, - ], - }; - - let workflowId = ''; - const createRes = await fetch(`${API_BASE}/workflows`, { - method: 'POST', - headers: HEADERS, - body: JSON.stringify(workflowGraph), - }); - const wfData = await createRes.json(); - workflowId = wfData.id; - console.log(' ✅ Workflow created:', workflowId); - - // 2. Run Workflow - console.log('\n▶️ Running workflow with userName:', TEST_USER); - const runRes = await fetch(`${API_BASE}/workflows/${workflowId}/run`, { - method: 'POST', - headers: HEADERS, - body: JSON.stringify({ inputs: { userName: TEST_USER } }) - }); - const runData = await runRes.json(); - const runId = runData.runId; - console.log(' ✅ Run started:', runId); - - const resolveAction = async (expectedType: string, expectedTitle: string, responseData: any) => { - console.log(`\n🔍 Waiting for ${expectedType} request (runId=${runId})...`); - let action = null; - let lastFound = null; - for (let i = 0; i < 20; i++) { - await sleep(1500); - const res = await fetch(`${API_BASE}/human-inputs?runId=${runId}&status=pending`, { headers: HEADERS }); - const list = await res.json(); - lastFound = list; - action = list.find((a: any) => a.inputType === expectedType); - if (action) break; - } - - if (!action) { - console.error(`❌ Timeout waiting for ${expectedType}. Pending actions in list:`, JSON.stringify(lastFound)); - const statusRes = await fetch(`${API_BASE}/workflows/runs/${runId}/status`, { headers: HEADERS }); - console.log('Run status:', await statusRes.json()); - process.exit(1); - } - - console.log(` Found: "${action.title}"`); - if (action.title !== expectedTitle) { - console.error(`❌ Title mismatch! Expected: "${expectedTitle}", Got: "${action.title}"`); - process.exit(1); - } - console.log(` Description check: ${action.description.substring(0, 50)}...`); - if (!action.description.includes(TEST_PROJECT) || !action.description.includes(TEST_USER)) { - if (expectedType !== 'form' || action.description.includes(TEST_PROJECT)) { - // Form only has projectName - } else { - console.error(`❌ Interpolation failed in description: ${action.description}`); - process.exit(1); - } - } - - console.log(`✅ Resolving ${expectedType}...`); - const resolveRes = await fetch(`${API_BASE}/human-inputs/${action.id}/resolve`, { - method: 'POST', - headers: HEADERS, - body: JSON.stringify({ responseData }) - }); - if (!resolveRes.ok) { - console.error(`❌ Resolve failed:`, await resolveRes.text()); - process.exit(1); - } - console.log(` ✅ ${expectedType} resolved.`); - }; - - // 3. Resolve Manual Approval - await resolveAction('approval', `Approve ${TEST_PROJECT}`, { status: 'approved', comment: 'Looks good' }); - - // 4. Resolve Manual Selection - await resolveAction('selection', `Select Role for ${TEST_USER}`, { selection: 'Admin' }); - - // 5. Resolve Manual Form - await resolveAction('form', `Metadata for ${TEST_PROJECT}`, { environment: 'prod', nodes: 5 }); - - // 6. Wait for Completion - console.log('\n⏳ Waiting for completion...'); - let status = 'RUNNING'; - for (let i = 0; i < 20; i++) { - await sleep(1000); - const statusRes = await fetch(`${API_BASE}/workflows/runs/${runId}/status`, { headers: HEADERS }); - const data = await statusRes.json(); - status = data.status; - if (status !== 'RUNNING') break; - process.stdout.write('.'); - } - console.log('\n🏁 Final Status:', status); - - if (status === 'COMPLETED') { - console.log('\n🎉🎉🎉 E2E TEST PASSED! All manual actions interpolated and resolved correctly.'); - console.log(`\nWorkflow ID: ${workflowId}`); - console.log(`Run ID: ${runId}`); - } else { - console.error('\n❌ Test failed with status:', status); - const resultRes = await fetch(`${API_BASE}/workflows/runs/${runId}/result`, { headers: HEADERS }); - console.log('Error info:', await resultRes.text()); - process.exit(1); - } - - // Cleanup skipped as requested - console.log('\n🏁 Test finished. Cleanup skipped by user request.'); -} - -main().catch(e => { - console.error('Fatal error:', e); - process.exit(1); -}); diff --git a/.prettierignore b/.prettierignore index 373eb18fc..e1eb6576f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -11,3 +11,9 @@ node_modules/ # Generated files *.generated.ts + +# Helm templates (Go template syntax is not valid YAML) +deploy/helm/*/templates/ + +# GitHub Actions (uses ${{ }} template syntax) +.github/workflows/ diff --git a/AGENTS.md b/AGENTS.md index 1b65311e0..bea1c1200 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ just instance use 5 # Set active instance for this workspace ```bash just instance-init 5 # Initialize .instances/instance-5/*.env just instance-env init 5 # Create from app/.env or app/.env.example -just instance-env update 5 # Re-apply instance-scoped vars +just instance-env update 5 # Re-apply instance-scoped vars (including worker BACKEND_URL) just instance-env copy 5 6 # Copy env setup from instance 5 -> 6 just instance-env show 6 # Show file status and computed values ``` @@ -49,11 +49,12 @@ Full details: `docs/MULTI-INSTANCE-DEV.md` Local development runs as **multiple app instances** (PM2) on top of **one shared Docker infra stack**. -- Shared infra (Docker Compose project `shipsec-infra`): Postgres/Temporal/Redpanda/Redis/MinIO/Loki on fixed ports. +- Shared infra (Docker Compose project `shipsec-infra`): Postgres/Temporal/Redpanda/Redis/MinIO/Loki/Bifrost/Neo4j on fixed ports. - Per-instance apps: `shipsec-{frontend,backend,worker}-N`. - Isolation is via per-instance DB + Temporal namespace/task queue + Kafka topic suffixing + instance-scoped Kafka consumer groups/client IDs (not per-instance infra containers). - The workspace can have an **active instance** (stored in `.shipsec-instance`, gitignored). - Instance env files are stored at `.instances/instance-N/{backend,worker,frontend}.env` and can be managed with `just instance-env ...`. +- Per-instance frontend envs must keep `VITE_API_URL=""` so frontend requests stay same-origin and route through the matching instance proxy/backend instead of hard-coding port `3211`. **Agent rule:** before running any dev commands, ensure you’re targeting the intended instance. @@ -67,6 +68,8 @@ Local development runs as **multiple app instances** (PM2) on top of **one share - Frontend: `5173 + N*100` - Backend: `3211 + N*100` +- Bifrost (shared): `http://localhost:4001` +- Neo4j Bolt/UI (shared): `bolt://localhost:7687`, `http://localhost:7474` - Temporal UI (shared): http://localhost:8081 **E2E tests** @@ -97,10 +100,39 @@ bun run lint # Lint ```bash just db-reset # Reset database -bun --cwd backend run migration:push # Push schema +bun --cwd backend run migration:generate # Generate migration SQL from schema changes +bun --cwd backend run migration:migrate # Apply committed migrations +bun run migrate # Root alias for migration:migrate bun --cwd backend run db:studio # View data ``` +### Database Schema Safety (CRITICAL) + +We use **`drizzle-kit generate` + `drizzle-kit migrate`** for both dev and production. + +- `migration:generate` creates reviewable SQL files in `backend/drizzle/`. +- `migration:migrate` applies committed SQL migrations in order. +- `migration:push` remains a **manual escape hatch only** and must never run from automated startup paths. + +**Rules for modifying database schema files (`backend/src/database/schema/*.ts`):** + +1. **Never rely on app startup to apply schema changes.** Generate and commit migrations explicitly before restart/deploy. +2. **Required workflow:** schema change → `bun --cwd backend run migration:generate` → review SQL → commit `backend/drizzle/*` (SQL + `meta/`) → `bun --cwd backend run migration:migrate`. +3. **Adding constraints to existing columns** (e.g., `.unique()`, `.notNull()`) can be destructive. Review generated SQL carefully. +4. **NEVER change a column type in place** (e.g., `varchar` → `text`) without a safe multi-step migration. +5. **NEVER rename columns/tables directly** in schema when data exists; use explicit add/backfill/swap migrations. +6. **If generated SQL truncates or drops data, STOP and ask the user.** +7. **Use multi-step migrations for risky changes:** add nullable column/table first, backfill, then enforce constraints in a follow-up migration. + +**SQL migration files in `backend/drizzle/` are the source of truth** and are applied by `migration:migrate`. + +### Backend Query Safety (N+1 Prevention) + +**Never** call a repository or service method inside `.map()`, `for`, or `forEach` on a list of records in service methods that handle list endpoints. This creates N+1 query patterns that scale linearly with data. + +- If a batch method doesn't exist, **create one** (using `IN` / `GROUP BY` / `DISTINCT ON`) before using it in a list context. +- See `docs/development/performance-patterns.md` § "Preventing N+1 Regressions" for patterns and conventions. + ## Rules 1. TypeScript, 2-space indent @@ -108,6 +140,7 @@ bun --cwd backend run db:studio # View data 3. Tests alongside code in `__tests__/` folders 4. **E2E Tests**: Mandatory for significant features. Place in `e2e-tests/` folder. 5. **GitHub CLI**: Use `gh` for all GitHub operations (issues, PRs, actions, releases). Never use browser automation for GitHub tasks. +6. **No shortcuts to avoid best practices.** Never use a non-idiomatic workaround just because the correct approach has a local dev constraint (e.g., an interactive prompt, a manual step, or a one-time migration). Use the proper, idiomatic pattern and handle the constraint explicitly — even if it means asking the user to run a command manually. Hacks that avoid good engineering to save a step are not acceptable. ### Frontend: Read Before Writing Code @@ -190,6 +223,11 @@ When tasks match a skill, load it: `cat .claude/skills//SKILL.md` Run a frontend load testing audit. Seeds data, tests all pages via Chrome DevTools MCP, records network calls, TanStack queries, DOM sizes, and generates a timestamped report. project + +run-reporting +Report workflow, scanner, wiki, and agent runs with a details table and a clickable Studio run URL. +project + diff --git a/Dockerfile b/Dockerfile index c2f28972e..d43534e3e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,16 +4,15 @@ # BASE STAGE # ============================================================================ FROM oven/bun:latest AS base -# Install system deps +# Install system deps (docker-ce-cli for container execution) RUN apt-get update && \ - apt-get install -y ca-certificates curl gnupg lsb-release python3 make g++ && \ + apt-get install -y ca-certificates curl git gnupg lsb-release && \ install -m 0755 -d /etc/apt/keyrings && \ curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg && \ chmod a+r /etc/apt/keyrings/docker.gpg && \ echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(. /etc/os-release && echo \"$VERSION_CODENAME\") stable" > /etc/apt/sources.list.d/docker.list && \ - curl -fsSL https://deb.nodesource.com/setup_current.x | bash - && \ apt-get update && \ - apt-get install -y nodejs docker-ce-cli && \ + apt-get install -y docker-ce-cli && \ update-ca-certificates && \ rm -rf /var/lib/apt/lists/* @@ -25,11 +24,12 @@ RUN groupadd -g 1001 shipsec && useradd -u 1001 -g shipsec -m shipsec # Copy all files COPY --chown=shipsec:shipsec bun.lock package.json bunfig.toml ./ COPY --chown=shipsec:shipsec packages/ packages/ +# scripts/ removed — no more postinstall native module checks COPY --chown=shipsec:shipsec backend/ backend/ COPY --chown=shipsec:shipsec frontend/ frontend/ COPY --chown=shipsec:shipsec worker/ worker/ -# Install ALL dependencies (no filtering) +# Install all workspace dependencies from the public monorepo. RUN bun install --frozen-lockfile # ============================================================================ @@ -52,8 +52,8 @@ WORKDIR /app/backend # Expose port EXPOSE 3211 -# Run migrations first, then start backend -CMD ["sh", "-c", "bun run migration:push && bun src/main.ts"] +# Safely align legacy DBs with baseline, then apply forward migrations. +CMD ["sh", "-c", "bun run migration:deploy && bun src/main.ts"] # ============================================================================ # WORKER SERVICE @@ -72,8 +72,8 @@ ENV POSTHOG_HOST=${POSTHOG_HOST} # Set working directory for worker WORKDIR /app/worker -# Run worker with Node + tsx (not bun, due to SWC binding issues) -CMD ["node", "--import", "tsx/esm", "src/temporal/workers/dev.worker.ts"] +# Run worker with Bun (native PTY support, no Node.js dependency) +CMD ["bun", "run", "src/temporal/workers/dev.worker.ts"] # ============================================================================ # FRONTEND SERVICE @@ -83,13 +83,12 @@ FROM base AS frontend # Frontend build-time configuration ARG VITE_AUTH_PROVIDER=local ARG VITE_CLERK_PUBLISHABLE_KEY="" -ARG VITE_API_URL=http://localhost:3211 -ARG VITE_BACKEND_URL=http://localhost:3211 +ARG VITE_API_URL="" +ARG VITE_BACKEND_URL="" ARG VITE_DEFAULT_ORG_ID=local-dev ARG VITE_GIT_SHA=unknown ARG VITE_PUBLIC_POSTHOG_KEY="" ARG VITE_PUBLIC_POSTHOG_HOST="" -ARG VITE_OPENSEARCH_DASHBOARDS_URL="" ENV VITE_AUTH_PROVIDER=${VITE_AUTH_PROVIDER} ENV VITE_CLERK_PUBLISHABLE_KEY=${VITE_CLERK_PUBLISHABLE_KEY} @@ -99,7 +98,6 @@ ENV VITE_DEFAULT_ORG_ID=${VITE_DEFAULT_ORG_ID} ENV VITE_GIT_SHA=${VITE_GIT_SHA} ENV VITE_PUBLIC_POSTHOG_KEY=${VITE_PUBLIC_POSTHOG_KEY} ENV VITE_PUBLIC_POSTHOG_HOST=${VITE_PUBLIC_POSTHOG_HOST} -ENV VITE_OPENSEARCH_DASHBOARDS_URL=${VITE_OPENSEARCH_DASHBOARDS_URL} # Set working directory for frontend USER shipsec @@ -125,13 +123,12 @@ FROM base AS frontend-debug # Frontend build-time configuration ARG VITE_AUTH_PROVIDER=local ARG VITE_CLERK_PUBLISHABLE_KEY="" -ARG VITE_API_URL=http://localhost:3211 -ARG VITE_BACKEND_URL=http://localhost:3211 +ARG VITE_API_URL="" +ARG VITE_BACKEND_URL="" ARG VITE_DEFAULT_ORG_ID=local-dev ARG VITE_GIT_SHA=unknown ARG VITE_PUBLIC_POSTHOG_KEY="" ARG VITE_PUBLIC_POSTHOG_HOST="" -ARG VITE_OPENSEARCH_DASHBOARDS_URL="" ENV VITE_AUTH_PROVIDER=${VITE_AUTH_PROVIDER} ENV VITE_CLERK_PUBLISHABLE_KEY=${VITE_CLERK_PUBLISHABLE_KEY} @@ -141,7 +138,6 @@ ENV VITE_DEFAULT_ORG_ID=${VITE_DEFAULT_ORG_ID} ENV VITE_GIT_SHA=${VITE_GIT_SHA} ENV VITE_PUBLIC_POSTHOG_KEY=${VITE_PUBLIC_POSTHOG_KEY} ENV VITE_PUBLIC_POSTHOG_HOST=${VITE_PUBLIC_POSTHOG_HOST} -ENV VITE_OPENSEARCH_DASHBOARDS_URL=${VITE_OPENSEARCH_DASHBOARDS_URL} # Set working directory for frontend USER shipsec diff --git a/README.md b/README.md index 9c5f6aa40..4903df8bb 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@
- ShipSec AI + ShipSec Studio

@@ -10,156 +10,89 @@ # ShipSec Studio -**Open-Source Security Workflow Orchestration Platform.** +**The harness for agent-native security workflow orchestration.** -> ShipSec is currently in active development. We are optimizing the platform for stable production use and high-performance security operations. +ShipSec Studio is an open-source platform for building, running, observing, and governing security workflows that combine agents, MCP tools, scanners, APIs, and human approvals in one durable execution system. -ShipSec Studio provides a visual DSL and runtime for building, executing, and monitoring automated security workflows. It decouples security logic from infrastructure management, providing a durable and isolated environment for running security tooling at scale. +This repository is the **public product tree**: -

- - ShipSec Studio Demo - -

Watch the platform in action on YouTube.

-
- ---- - -### 🏗️ Core Pillars - -- **Durable, resumable workflows** powered by Temporal.io for stateful execution across failures. -- **Isolated security runtimes** using ephemeral containers with per-run volume management. -- **Unified telemetry streams** delivering terminal output, events, and logs via a low-latency SSE pipeline. -- **Visual no-code builder** that compiles complex security graphs into an executable DSL. - ---- - -## 🚀 Deployment Options - -### 1. Shipsec Self-Host with Docker (Recommended) - -The easiest way to run ShipSec Studio on your own infrastructure: - -#### One-Line Install +- application code +- shared packages +- image build surface +- minimal CE runtime compose files for local and single-node validation +- CI and release automation for those images -```bash -curl -fsSL https://get.shipsec.ai | bash -``` +It does **not** ship the private deployment overlays, Helm charts, or hosted environment setup. -This installer will: +## Why Studio -- Check and install missing dependencies (docker, just, curl, jq, git) -- Start Docker if not running -- Clone the repository and start all services -- Guide you through any required setup steps +- **Agent-native by design**: combine AI agents, MCP tools, and traditional security components inside real workflows. +- **Durable execution**: retries, resumability, schedules, and long-running waits are backed by Temporal. +- **Human-in-the-loop controls**: approvals, forms, and manual branching are part of the runtime model. +- **Full run visibility**: logs, terminal output, events, artifacts, and replayable execution history. +- **Reusable security automation**: workflows, templates, and report templates for common security operations. -Once complete, visit **http://localhost** to access ShipSec Studio. +## Core Primitives -### 2. ShipSec Cloud (Preview) +- **Workflows**: visual graphs that compile into executable security automations +- **Agents**: tool-using AI steps, chat sessions, and skill-enabled runs +- **Tools**: MCP servers, scanners, HTTP integrations, and runtime credential resolvers +- **Human steps**: approvals, forms, and manual decision points +- **Observability**: traces, terminal streams, logs, artifacts, and audit records -The fastest way to test ShipSec Studio without managing infrastructure. +## What This Repo Publishes -- **Try it out:** [studio.shipsec.ai](https://studio.shipsec.ai) -- **Note:** ShipSec Studio is under active development. The cloud environment is a technical preview for evaluation and sandbox testing. +The public repo is responsible for the Studio images, for example: -### 3. Self-Host (Docker) +- `ghcr.io/shipsecai/studio-backend:latest` +- `ghcr.io/shipsecai/studio-worker:latest` +- `ghcr.io/shipsecai/studio-frontend:latest` -For teams requiring data residency and air-gapped security orchestrations. This setup runs the full stack (Frontend, Backend, Worker, and Infrastructure). +Self-hosters can compose these images into their own deployment model using the infrastructure they prefer. -**Prerequisites:** +## Development -- **[docker](https://www.docker.com/)** - For running the application and security components -- **[just](https://github.com/casey/just)** - Command runner for simplified workflows -- **curl** and **jq** - For fetching release information +For source-level development: ```bash -# Clone and start the latest stable release -git clone https://github.com/ShipSecAI/studio.git -cd studio -just prod start-latest +just init +just infra up +just dev ``` -Access the studio at `http://localhost`. - ---- - -## 🛠️ Capabilities - -### Integrated Tooling - -Native support for industry-standard security tools including: - -- **Discovery**: `Subfinder`, `DNSX`, `Naabu`, `HTTPx` -- **Vulnerability**: `Nuclei`, `TruffleHog` -- **Utility**: `JSON Transform`, `Logic Scripts`, `HTTP Requests` - -### Advanced Orchestration - -- **Human-in-the-Loop**: Pause workflows for approvals, form inputs, or manual validation before continuing. -- **AI-Driven Analysis**: Leverage LLM nodes and MCP providers for intelligent results interpretation. -- **Native Scheduling**: Integrated CRON support for recurring security posture and compliance monitoring. -- **API First**: Trigger and monitor any workflow execution via a comprehensive REST API. - -### MCP Integration - -- **MCP Library**: Centralized MCP server management with multi-server selection and automatic tool registration -- **Built-in MCP Servers**: AWS CloudTrail, CloudWatch, and Filesystem support out-of-the-box -- **Seamless Tool Discovery**: AI Agents automatically discover and use MCP tools via standardized contracts - ---- - -## 🏛️ Architecture Overview - -ShipSec Studio is designed for enterprise-grade durability and horizontal scalability. - -- **Management Plane (Backend)**: NestJS service handling DSL compilation, secret management (AES-256-GCM), and identity. -- **Orchestration Plane (Temporal)**: Manages workflow state, concurrency, and persistent wait states. -- **Execution Plane (Worker)**: Stateless agents that pull tasks from Temporal and execute tool-bound activities in isolated runtimes. -- **Monitoring (SSE/Loki)**: Real-time telemetry pipeline for deterministic execution visibility. - -Learn more about our design decisions and system components in the **[Architecture Deep-dive](/docs/architecture.mdx)**. - ---- - -## 🤝 Community & Support - -- 💬 **[Discord](https://discord.gg/fmMA4BtNXC)** — Real-time support and community discussion. -- 🗣️ **[GitHub Discussions](https://github.com/ShipSecAI/studio/discussions)** — Technical RFCs and feature requests. -- 📚 **[Documentation](https://docs.shipsec.ai)** — Full guides on component development and deployment. - ---- - -## 🔀 Multi-Instance Development - -Run multiple isolated dev instances on one machine for parallel feature work: +For the full CE Docker stack: ```bash -# Instance 0 (default) -just dev - -# Switch active workspace instance -just instance use 1 -just dev - -# Manage per-instance env files -just instance-env init 1 +just ce up ``` -Each instance gets its own frontend port, backend port, database, and Temporal namespace while sharing a single Docker infra stack. See [Multi-Instance Development Guide](docs/MULTI-INSTANCE-DEV.md) for full details. +## OSS vs ShipSec Cloud ---- +The public repo supports the core product directly: -## ✍️ Contributing +- self-hosted workflows +- direct provider configuration +- local or external auth +- AI via model/base URL/API key +- user-managed integrations and credentials -We welcome contributions to the management plane, worker logic, or new security components. -See [CONTRIBUTING.md](CONTRIBUTING.md) for architectural guidelines and setup instructions. +ShipSec Cloud is the managed hosted offering. Public docs mention it at the product level, not the implementation level. ---- +## Learn More -## License +- [Introduction](docs/index.mdx) +- [Quickstart](docs/quickstart.mdx) +- [Installation](docs/installation.mdx) +- [Self-Hosting](docs/self-hosting.mdx) +- [Product / Workflows](docs/product/workflows.mdx) +- [Product / Agents & Tools](docs/product/agents-and-tools.mdx) +- [Integrations](docs/integrations/index.mdx) +- [Deployment Model](docs/deployment-model.mdx) +- [OSS vs ShipSec Cloud](docs/oss-vs-cloud.mdx) +- [Architecture](docs/architecture.mdx) -ShipSec Studio is licensed under the **Apache License 2.0**. +## Community -
-

Engineered for security teams by the ShipSec AI team.

-
+- Discord: https://discord.gg/fmMA4BtNXC +- Discussions: https://github.com/ShipSecAI/studio/discussions +- Docs: https://docs.shipsec.ai diff --git a/backend/.env.docker b/backend/.env.docker index 02bf7d6aa..d5d569859 100644 --- a/backend/.env.docker +++ b/backend/.env.docker @@ -31,9 +31,21 @@ LOKI_PASSWORD="" # Kafka / Redpanda configuration for node I/O, log, and event ingestion (Docker network) LOG_KAFKA_BROKERS="redpanda:9092" - -# GitHub template repository configuration -GITHUB_TEMPLATE_REPO=shipsecai/workflow-templates -GITHUB_TEMPLATE_BRANCH=main -# Optional: GitHub personal access token for higher rate limits (60/hr → 5000/hr) -GITHUB_TEMPLATE_TOKEN= \ No newline at end of file +# GitHub App configuration (required for GitHub integration) +GITHUB_APP_ID="" +GITHUB_APP_SLUG="" +GITHUB_APP_PRIVATE_KEY="" +GITHUB_APP_WEBHOOK_SECRET="" +GITHUB_APP_CLIENT_ID="" +GITHUB_APP_CLIENT_SECRET="" +# GITHUB_API_URL="https://api.github.com" +# GITHUB_URL="https://github.com" + +# Slack OAuth (required for Slack app installation) +SLACK_OAUTH_CLIENT_ID="" +SLACK_OAUTH_CLIENT_SECRET="" + +# AWS integration (required for cloud security scanning) +SHIPSEC_PLATFORM_ROLE_ARN="" +SHIPSEC_AWS_ACCESS_KEY_ID="" +SHIPSEC_AWS_SECRET_ACCESS_KEY="" \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example index e2c3f27b1..7b3ce4c3d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,70 +1,135 @@ -# ShipSec backend environment defaults - +# ── Required ───────────────────────────────────────────────────────── # Primary Postgres database connection +# Instance 0 uses 'shipsec', others use 'shipsec_instance_N' (PM2 overrides per instance) DATABASE_URL="postgresql://shipsec:shipsec@localhost:5433/shipsec" -# HTTP server configuration -PORT="3211" - -# Temporal connection details -TEMPORAL_ADDRESS="localhost:7233" -TEMPORAL_NAMESPACE="shipsec-dev" -TEMPORAL_TASK_QUEUE="shipsec-dev" -# Automatically create and run a demo workflow on startup -TEMPORAL_BOOTSTRAP_DEMO="false" +# Secret encryption key (must be exactly 32 characters, NOT hex-encoded) +# Generate with: openssl rand -base64 24 | head -c 32 +SECRET_STORE_MASTER_KEY="CHANGE_ME_32_CHAR_SECRET_KEY!!!!" -# MinIO credentials for artifact storage -MINIO_ROOT_USER="minioadmin" -MINIO_ROOT_PASSWORD="minioadmin" +# Kafka / Redpanda brokers for log, event, and trace ingestion +LOG_KAFKA_BROKERS="localhost:9092" -# Loki configuration for log retrieval +# Loki for log retrieval LOKI_URL="http://localhost:3100" # Optional: multi-tenant org / basic auth credentials -LOKI_TENANT_ID="" -LOKI_USERNAME="" -LOKI_PASSWORD="" - -# Authentication configuration -# Set AUTH_PROVIDER="local" to enable developer mode or "clerk" to integrate with the platform identity service. -AUTH_PROVIDER="local" - -# Local provider options -# If AUTH_LOCAL_ALLOW_UNAUTHENTICATED=false, clients must present AUTH_LOCAL_API_KEY in the Authorization header. -AUTH_LOCAL_ALLOW_UNAUTHENTICATED="true" -AUTH_LOCAL_API_KEY="" -# Required in production for session auth cookie signing -SESSION_SECRET="" - -# Clerk provider options -# Required when AUTH_PROVIDER="clerk" -CLERK_PUBLISHABLE_KEY="" -CLERK_SECRET_KEY="" - -# Platform service account (optional, used for auth enrichment + workflow linkage) -PLATFORM_API_URL="" -PLATFORM_SERVICE_TOKEN="" -# Optional: override request timeout in milliseconds (default 5000) -PLATFORM_API_TIMEOUT_MS="" - -# OpenSearch configuration for security analytics indexing -# Optional: if not set, security analytics indexing will be disabled -OPENSEARCH_URL="" -OPENSEARCH_USERNAME="" -OPENSEARCH_PASSWORD="" - -# OpenSearch Dashboards configuration for analytics visualization -# Optional: if not set, Dashboards link will not appear in frontend sidebar -# Dev/Prod (via nginx): "http://localhost/analytics" -# Custom domain: "https://dashboards.example.com/analytics" -OPENSEARCH_DASHBOARDS_URL="" +# LOKI_TENANT_ID="" +# LOKI_USERNAME="" +# LOKI_PASSWORD="" -# Secret encryption key (must be exactly 32 characters, NOT hex-encoded) -# Generate with: openssl rand -base64 24 | head -c 32 -SECRET_STORE_MASTER_KEY="CHANGE_ME_32_CHAR_SECRET_KEY!!!!" +# GitHub App configuration (required for GitHub integration) +# Create a GitHub App at https://github.com/settings/apps +GITHUB_APP_ID="" +# GitHub App slug (lowercase, alphanumeric, hyphens) +# https://github.com/settings/apps/ +GITHUB_APP_SLUG="" +# Private key in PEM format or base64-encoded PEM +# Generate from GitHub App settings > Private keys > Generate a private key +GITHUB_APP_PRIVATE_KEY="" +# Webhook secret configured in GitHub App settings +GITHUB_APP_WEBHOOK_SECRET="" +# OAuth credentials from GitHub App settings +GITHUB_APP_CLIENT_ID="" +GITHUB_APP_CLIENT_SECRET="" + +# Jira app integration (optional; for self-managed Jira app installs) +# Configure your app separately, then connect it from Studio using its web trigger URL. + +# Slack OAuth (required for Slack app installation) +# Get it from here : https://api.slack.com/apps/ +SLACK_OAUTH_CLIENT_ID="" +SLACK_OAUTH_CLIENT_SECRET="" +# Override default scopes (comma-separated); defaults: channels:read,channels:history,chat:write,chat:write.public,commands,app_mentions:read,team:read +# SLACK_OAUTH_SCOPES="" + +# Slack Bot (enables Slack thread ↔ chat session bridge) +SLACK_BOT_ENABLED="false" +# Signing secret for verifying inbound Slack events (from Slack app settings → Signing Secret) +SLACK_SIGNING_SECRET="" + +# AWS integration (required for cloud security scanning) +SHIPSEC_PLATFORM_ROLE_ARN="" +SHIPSEC_AWS_ACCESS_KEY_ID="" +SHIPSEC_AWS_SECRET_ACCESS_KEY="" +# Optional: override AWS region (defaults to us-east-1) +SHIPSEC_AWS_REGION="" + +# ── Optional overrides ─────────────────────────────────────────────── +# (All have sensible defaults — uncomment only what you need to change) + +# Backend Server Port +# PORT="3211" +# REQUEST_BODY_LIMIT="1mb" + +# Temporal (defaults: localhost:7233, shipsec-dev namespace & task queue) +# TEMPORAL_ADDRESS="localhost:7233" +# TEMPORAL_NAMESPACE="shipsec-dev" +# TEMPORAL_TASK_QUEUE="shipsec-dev" + +# MinIO +# MINIO_ROOT_USER="minioadmin" +# MINIO_ROOT_PASSWORD="minioadmin" + +# Redis (optional — falls back to in-memory) +# REDIS_URL="" + +# Authentication (public CE uses local/basic auth) +# SESSION_SECRET="" +# ADMIN_USERNAME="admin" +# ADMIN_PASSWORD="admin" + +# ClickHouse (optional — analytics indexing disabled if not set) +# CLICKHOUSE_URL="http://localhost:8123" +# CLICKHOUSE_DATABASE="shipsec" +# CLICKHOUSE_USERNAME="" +# CLICKHOUSE_PASSWORD="" + +# Neo4j / Cartography graph (optional — defaults assume shared local dev infra) +# NEO4J_URI="bolt://localhost:7687" +# NEO4J_USER="" +# NEO4J_PASSWORD="" + +# Hotplug control plane (optional — defaults to local dev port mapping) +# HOTPLUG_URL="http://localhost:10011" +# HOTPLUG_CONTROL_TOKEN="hotplug-local-dev-token" + +# GitHub OAuth (optional — for user-level GitHub access) +# GITHUB_OAUTH_CLIENT_ID="" +# GITHUB_OAUTH_CLIENT_SECRET="" +# GITHUB_OAUTH_SCOPES="repo,read:user" + +# Zoom OAuth (optional) +# ZOOM_OAUTH_CLIENT_ID="" +# ZOOM_OAUTH_CLIENT_SECRET="" +# ZOOM_OAUTH_SCOPES="" + +# Platform service account (optional — for auth enrichment + workflow linkage) +# PLATFORM_API_URL="" +# PLATFORM_SERVICE_TOKEN="" +# PLATFORM_API_TIMEOUT_MS="" + +# Webhook base URL (used for constructing callback URLs) +# WEBHOOK_BASE_URL="" + +# Extra CORS origins (comma-separated) +# CORS_EXTRA_ORIGINS="" + +# Feature flags +# ASSET_DISCOVERY_ENABLED="true" +# MCP_SYNC_TEMPLATES_ON_STARTUP="false" -# Redis configuration for rate limiting and caching -# Optional: if not set, rate limiting will use in-memory storage (not recommended for production) -REDIS_URL="" +# ── Dev-only ────────────────────────────────────────────────────────── +# Skip TLS verification for self-signed certs +# NEVER set this in production! +NODE_TLS_REJECT_UNAUTHORIZED="0" -# Kafka / Redpanda configuration for node I/O, log, and event ingestion -LOG_KAFKA_BROKERS="localhost:19092" +# ── Security-sensitive ──────────────────────────────────────────────── +# WARNING: These control auth and internal access. Review carefully. +# Defaults to true in local dev — set to false in production! +# AUTH_LOCAL_ALLOW_UNAUTHENTICATED="true" +# API key required when AUTH_LOCAL_ALLOW_UNAUTHENTICATED=false +# AUTH_LOCAL_API_KEY="" +# Token for service-to-service calls (empty = no internal auth) +# INTERNAL_SERVICE_TOKEN="" +# Set to true ONLY in dev to allow internal endpoints without a token +# ALLOW_INSECURE_INTERNAL_ENDPOINTS="false" diff --git a/backend/README.md b/backend/README.md index 1b44f7945..4369d67ab 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,173 +1,50 @@ # ShipSec Studio Backend -NestJS REST API server for workflow management, orchestration, and real-time execution monitoring. +NestJS API server for workflows, integrations, auth, storage metadata, traces, templates, and runtime control surfaces. -## Prerequisites - -- Bun latest (see root `README.md` for install instructions) -- Infrastructure services running (`just dev` from repo root) - -## Development Commands +## Development ```bash -# Install workspace dependencies (run once from repo root) bun install - -# Start the API server with hot reload -bun dev - -# Type-check and lint before committing -bun run typecheck -bun run lint - -# Run API tests (NestJS testing utilities) -bun run test +bun --cwd backend run dev ``` -## Architecture Overview - -### Core Technologies - -- **NestJS** with TypeScript for API framework -- **Bun runtime** for fast JavaScript execution -- **PostgreSQL** with Drizzle ORM for data persistence -- **Temporal.io** for workflow orchestration -- **Clerk** for authentication and user management -- **MinIO** for object storage -- **Redis** for caching and session management -- **Kafka/Redpanda** for event streaming -- **Loki** for log aggregation - -### Key Services - -#### Workflows Module - -- **Workflow CRUD**: Create, read, update, delete workflows -- **Graph Compilation**: Convert ReactFlow graphs to executable DSL -- **Temporal Integration**: Workflow scheduling and management -- **Validation**: Component registry validation and type checking - -#### Storage Module - -- **File Management**: Upload, download, and metadata management -- **Artifact Storage**: Component outputs and execution results -- **Terminal Archival**: Convert Redis streams to Asciinema cast files -- **MinIO Integration**: S3-compatible object storage - -#### Secrets Module - -- **Encrypted Storage**: AES-256-GCM encryption for sensitive data -- **Version Control**: Multiple secret versions with rollback -- **Access Control**: Role-based secret access and audit logging - -#### Integrations Module - -- **OAuth Provider**: Multi-provider OAuth orchestration -- **Token Vault**: Encrypted storage of access tokens -- **Connection Management**: OAuth lifecycle and refresh handling - -#### Logging & Events Module - -- **Log Ingestion**: Multi-transport log processing (Kafka, Loki, PostgreSQL) -- **Event Management**: Trace event storage and timeline generation -- **Real-time APIs**: SSE endpoints for live updates -- **Terminal Streaming**: Redis Stream-based terminal output delivery - -## Environment Configuration - -### Required Variables +Before committing: ```bash -# Database -DATABASE_URL=postgresql://user:pass@localhost:5432/shipsec - -# Authentication -CLERK_PUBLISHABLE_KEY=pk_test_... -CLERK_SECRET_KEY=sk_test_... -CLERK_WEBHOOK_SECRET=whsec_... - -# Temporal -TEMPORAL_ADDRESS=localhost:7233 -TEMPORAL_NAMESPACE=shipsec-studio -TEMPORAL_TASK_QUEUE=shipsec-workflows - -# Object Storage -MINIO_ENDPOINT=localhost:9000 -MINIO_ROOT_USER=minioadmin -MINIO_ROOT_PASSWORD=minioadmin - -# Event Streaming / Kafka (Redpanda) -LOG_KAFKA_BROKERS=localhost:9092 -REDIS_URL=redis://localhost:6379 - -# Log Aggregation -LOKI_URL=http://localhost:3100 - -# Security -# Must be exactly 32 characters (raw string, NOT hex-encoded). -# Generate with: openssl rand -base64 24 | head -c 32 -SECRET_STORE_MASTER_KEY=your-32-character-secret-key!!!! -INTEGRATION_STORE_MASTER_KEY=your-32-character-integ-key!!!!! +bun --cwd backend run typecheck +bun --cwd backend run lint +bun --cwd backend run test ``` -## API Endpoints - -### Workflows +The backend expects its dependencies to be reachable through configured environment variables. -- `GET /api/v1/workflows` - List workflows -- `POST /api/v1/workflows` - Create workflow -- `GET /api/v1/workflows/{id}` - Get workflow details -- `PUT /api/v1/workflows/{id}` - Update workflow -- `DELETE /api/v1/workflows/{id}` - Delete workflow -- `POST /api/v1/workflows/{id}/runs` - Execute workflow +## Common Tasks -### Execution Monitoring +Generate OpenAPI: -- `GET /api/v1/runs/{runId}/events` - Get execution trace events -- `GET /api/v1/runs/{runId}/terminal` - Get terminal output chunks -- `GET /api/v1/runs/{runId}/logs` - Query execution logs -- `GET /api/v1/runs/{runId}/stream` - SSE endpoint for live updates - -### File & Artifact Management +```bash +bun --cwd backend run generate:openapi +``` -- `POST /api/v1/files/upload` - Upload file -- `GET /api/v1/files/{id}/download` - Download file -- `GET /api/v1/files/{id}/metadata` - Get file metadata +Run migrations: -### Secrets & Integrations +```bash +bun --cwd backend run migration:migrate +``` -- `GET /api/v1/secrets` - List secrets -- `POST /api/v1/secrets` - Create secret -- `GET /api/v1/integrations/providers` - List OAuth providers -- `POST /api/v1/integrations/{provider}/start` - Start OAuth flow +This uses the repo-aware migration runner. By default it applies the committed +public migrations in `backend/drizzle`. If your environment provides extra +migration directories, those are layered on top by configuration. -## Project Structure +Generate migration SQL: +```bash +bun --cwd backend run migration:generate ``` -src/ -├── workflows/ # Workflow CRUD and compilation -├── storage/ # File and artifact management -├── secrets/ # Encrypted secrets storage -├── integrations/ # OAuth provider orchestration -├── components/ # Component registry API -├── trace/ # Event management and timeline -├── logging/ # Log ingestion and processing -├── events/ # Event processing service -├── temporal/ # Temporal client wrapper -├── database/ # Database schemas and migrations -└── auth/ # Clerk authentication integration -``` - -## Development Workflow - -1. **Infrastructure**: Start required services with `just dev` -2. **Database**: Run migrations with `bun run db:migrate` -3. **Development**: Start API server with `bun dev` -4. **Testing**: Run test suite with `bun run test` -5. **Validation**: Check types with `bun run typecheck` -## Where To Read More +## Read More -- **[Architecture Overview](../docs/architecture.md)** - Complete system design and data flows -- **[Component Development](../docs/component-development.md)** - Building security components -- **[Getting Started](../docs/getting-started.md)** - Development setup and configuration +- [Architecture](../docs/architecture.mdx) +- [Installation](../docs/installation.mdx) +- [Self-Hosting](../docs/self-hosting.mdx) diff --git a/backend/drizzle/0000_initial_schema.sql b/backend/drizzle/0000_initial_schema.sql new file mode 100644 index 000000000..36be39e17 --- /dev/null +++ b/backend/drizzle/0000_initial_schema.sql @@ -0,0 +1,737 @@ +CREATE TYPE "public"."human_input_status" AS ENUM('pending', 'resolved', 'expired', 'cancelled');--> statement-breakpoint +CREATE TYPE "public"."human_input_type" AS ENUM('approval', 'form', 'selection', 'review', 'acknowledge');--> statement-breakpoint +CREATE TABLE "agent_trace_events" ( + "id" bigserial PRIMARY KEY NOT NULL, + "agent_run_id" text NOT NULL, + "workflow_run_id" text NOT NULL, + "node_ref" text NOT NULL, + "sequence" integer NOT NULL, + "timestamp" timestamp with time zone NOT NULL, + "part_type" text NOT NULL, + "payload" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "api_keys" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" varchar(191) NOT NULL, + "description" text, + "key_hash" text NOT NULL, + "key_prefix" varchar(20) NOT NULL, + "key_hint" varchar(8) NOT NULL, + "permissions" jsonb NOT NULL, + "scopes" jsonb DEFAULT '[]'::jsonb, + "organization_id" varchar(191) NOT NULL, + "created_by" varchar(191) NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "expires_at" timestamp with time zone, + "last_used_at" timestamp with time zone, + "usage_count" integer DEFAULT 0 NOT NULL, + "rate_limit" integer, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "api_keys_key_hash_unique" UNIQUE("key_hash") +); +--> statement-breakpoint +CREATE TABLE "artifacts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "run_id" text NOT NULL, + "workflow_id" uuid NOT NULL, + "workflow_version_id" uuid, + "component_id" text, + "component_ref" text NOT NULL, + "file_id" uuid NOT NULL, + "name" text NOT NULL, + "mime_type" varchar(150) NOT NULL, + "size" bigint NOT NULL, + "destinations" jsonb DEFAULT '["run"]'::jsonb NOT NULL, + "metadata" jsonb, + "organization_id" varchar(191), + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "asm_assets" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" varchar(191) NOT NULL, + "domain_id" uuid NOT NULL, + "hostname" varchar(512) NOT NULL, + "type" varchar(50) NOT NULL, + "ip_addresses" text[], + "tech_stack" text[], + "ports" jsonb, + "risk_score" numeric(4, 1), + "dns_records" jsonb, + "tls_info" jsonb, + "first_seen" timestamp with time zone DEFAULT now() NOT NULL, + "last_seen" timestamp with time zone DEFAULT now() NOT NULL, + "metadata" jsonb, + CONSTRAINT "asm_assets_domain_id_hostname_unique" UNIQUE("domain_id","hostname") +); +--> statement-breakpoint +CREATE TABLE "asm_domains" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" varchar(191) NOT NULL, + "domain" varchar(255) NOT NULL, + "scope_include" text[], + "scope_exclude" text[], + "monitoring_enabled" boolean DEFAULT false, + "frequency" varchar(50), + "last_scan_at" timestamp with time zone, + "deleted_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "asset_scan_status" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "asset_id" uuid NOT NULL, + "scan_type" varchar(64) NOT NULL, + "last_scanned_at" timestamp with time zone, + "last_run_id" varchar(255), + "finding_count" integer, + "critical_count" integer, + "high_count" integer, + "status" varchar(16) DEFAULT 'unscanned' +); +--> statement-breakpoint +CREATE TABLE "asset_sync_run_scopes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "sync_run_id" uuid NOT NULL, + "asset_type" varchar(64) NOT NULL, + "region" varchar(64) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "asset_sync_runs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" varchar(255) NOT NULL, + "integration_id" uuid NOT NULL, + "account_identifier" varchar(255), + "temporal_workflow_id" text, + "temporal_run_id" text, + "status" varchar(16) NOT NULL, + "covered_regions" jsonb DEFAULT '[]'::jsonb, + "assets_discovered" integer DEFAULT 0, + "assets_created" integer DEFAULT 0, + "assets_updated" integer DEFAULT 0, + "assets_stale" integer DEFAULT 0, + "errors" jsonb DEFAULT '[]'::jsonb, + "started_at" timestamp with time zone NOT NULL, + "completed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "assets" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" varchar(255) NOT NULL, + "integration_id" uuid, + "provider" varchar(64) NOT NULL, + "account_identifier" varchar(255), + "asset_type" varchar(64) NOT NULL, + "external_id" text NOT NULL, + "name" text, + "region" varchar(64), + "metadata" jsonb DEFAULT '{}'::jsonb, + "first_discovered_at" timestamp with time zone NOT NULL, + "last_seen_at" timestamp with time zone NOT NULL, + "status" varchar(16) DEFAULT 'active', + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "audit_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" varchar(191), + "actor_id" varchar(191), + "actor_type" varchar(32) NOT NULL, + "actor_display" varchar(191), + "action" varchar(64) NOT NULL, + "resource_type" varchar(32) NOT NULL, + "resource_id" varchar(191), + "resource_name" varchar(191), + "metadata" jsonb DEFAULT 'null'::jsonb, + "ip" varchar(64), + "user_agent" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "chat_messages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "session_id" uuid NOT NULL, + "role" text NOT NULL, + "parts" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "chat_partial_responses" ( + "session_id" uuid PRIMARY KEY NOT NULL, + "parts" jsonb DEFAULT '[]'::jsonb NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "chat_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "organization_id" varchar(191), + "title" text DEFAULT 'New Conversation' NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "processing_status" text DEFAULT 'idle' NOT NULL, + "active_run_id" uuid, + "next_event_seq" integer DEFAULT 1 NOT NULL, + "temporal_workflow_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "chat_stream_events" ( + "id" bigserial PRIMARY KEY NOT NULL, + "session_id" uuid NOT NULL, + "run_id" uuid NOT NULL, + "seq" integer NOT NULL, + "event" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "files" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "file_name" varchar(255) NOT NULL, + "mime_type" varchar(100) NOT NULL, + "size" bigint NOT NULL, + "storage_key" varchar(500) NOT NULL, + "organization_id" varchar(191), + "uploaded_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "files_storage_key_unique" UNIQUE("storage_key") +); +--> statement-breakpoint +CREATE TABLE "github_app_installations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "installation_id" integer NOT NULL, + "account_type" varchar(32) NOT NULL, + "account_login" varchar(191) NOT NULL, + "account_id" integer NOT NULL, + "account_avatar_url" text, + "permissions" jsonb DEFAULT '{}'::jsonb, + "repository_selection" varchar(32) DEFAULT 'selected', + "organization_id" varchar(191) NOT NULL, + "installed_by" varchar(191), + "is_active" boolean DEFAULT true NOT NULL, + "suspended_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "github_repositories" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "installation_id" uuid NOT NULL, + "repo_id" integer NOT NULL, + "full_name" varchar(255) NOT NULL, + "name" varchar(191) NOT NULL, + "owner" varchar(191) NOT NULL, + "is_private" boolean DEFAULT false NOT NULL, + "default_branch" varchar(191) DEFAULT 'main', + "description" text, + "language" varchar(64), + "clone_url" text, + "html_url" text, + "organization_id" varchar(191) NOT NULL, + "scans_enabled" boolean DEFAULT true NOT NULL, + "last_synced_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "github_scan_results" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "repository_id" uuid NOT NULL, + "workflow_run_id" varchar(191) NOT NULL, + "source_type" varchar(32) NOT NULL, + "pr_number" integer, + "branch" varchar(191), + "commit_sha" varchar(64), + "status" varchar(32) DEFAULT 'pending' NOT NULL, + "summary" jsonb DEFAULT '{"critical":0,"high":0,"medium":0,"low":0,"info":0}'::jsonb, + "findings_count" integer DEFAULT 0 NOT NULL, + "findings" jsonb DEFAULT '[]'::jsonb, + "check_run_id" bigint, + "pr_comment_id" bigint, + "pr_review_id" bigint, + "results_url" text, + "error_message" text, + "trigger_rule_id" uuid, + "organization_id" varchar(191) NOT NULL, + "started_at" timestamp with time zone, + "completed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "github_trigger_rules" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" varchar(191) NOT NULL, + "description" text, + "repository_pattern" varchar(255) NOT NULL, + "event" varchar(64) NOT NULL, + "actions" jsonb DEFAULT '[]'::jsonb, + "branches" jsonb DEFAULT '[]'::jsonb, + "workflow_id" uuid NOT NULL, + "post_pr_comment" boolean DEFAULT true NOT NULL, + "create_check_run" boolean DEFAULT true NOT NULL, + "post_pr_review" boolean DEFAULT false NOT NULL, + "fail_on" varchar(32) DEFAULT 'high' NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "priority" integer DEFAULT 100 NOT NULL, + "organization_id" varchar(191) NOT NULL, + "created_by" varchar(191), + "deleted_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "human_input_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "run_id" text NOT NULL, + "workflow_id" uuid NOT NULL, + "node_ref" text NOT NULL, + "status" "human_input_status" DEFAULT 'pending' NOT NULL, + "input_type" "human_input_type" DEFAULT 'approval' NOT NULL, + "input_schema" jsonb DEFAULT '{}'::jsonb, + "title" text NOT NULL, + "description" text, + "context" jsonb DEFAULT '{}'::jsonb, + "resolve_token" text NOT NULL, + "timeout_at" timestamp with time zone, + "response_data" jsonb, + "responded_at" timestamp with time zone, + "responded_by" text, + "organization_id" varchar(191), + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "human_input_requests_resolve_token_unique" UNIQUE("resolve_token") +); +--> statement-breakpoint +CREATE TABLE "workflows" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "description" text, + "graph" jsonb NOT NULL, + "organization_id" varchar(191), + "is_system" boolean DEFAULT false NOT NULL, + "template_id" uuid, + "compiled_definition" jsonb DEFAULT 'null'::jsonb, + "icon" text, + "icon_color" text, + "last_run" timestamp with time zone, + "run_count" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "workflow_versions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workflow_id" uuid NOT NULL, + "version" integer NOT NULL, + "graph" jsonb NOT NULL, + "organization_id" varchar(191), + "compiled_definition" jsonb DEFAULT 'null'::jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "workflow_traces" ( + "id" bigserial PRIMARY KEY NOT NULL, + "run_id" text NOT NULL, + "workflow_id" text, + "organization_id" varchar(191), + "type" text NOT NULL, + "node_ref" text NOT NULL, + "timestamp" timestamp with time zone NOT NULL, + "message" text, + "error" jsonb, + "output_summary" jsonb, + "level" text DEFAULT 'info' NOT NULL, + "data" jsonb, + "sequence" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "workflow_runs" ( + "run_id" text PRIMARY KEY NOT NULL, + "workflow_id" uuid NOT NULL, + "workflow_version_id" uuid, + "workflow_version" integer, + "temporal_run_id" text, + "parent_run_id" text, + "parent_node_ref" text, + "total_actions" integer DEFAULT 0 NOT NULL, + "inputs" jsonb DEFAULT '{}'::jsonb NOT NULL, + "trigger_type" text DEFAULT 'manual' NOT NULL, + "trigger_source" text, + "trigger_label" text DEFAULT 'Manual run' NOT NULL, + "input_preview" jsonb DEFAULT '{"runtimeInputs":{},"nodeOverrides":{}}'::jsonb NOT NULL, + "organization_id" varchar(191), + "status" text, + "close_time" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "workflow_log_streams" ( + "id" bigserial PRIMARY KEY NOT NULL, + "run_id" text NOT NULL, + "node_ref" text NOT NULL, + "stream" text NOT NULL, + "organization_id" varchar(191), + "labels" jsonb NOT NULL, + "first_timestamp" timestamp with time zone NOT NULL, + "last_timestamp" timestamp with time zone NOT NULL, + "line_count" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "secret_versions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "secret_id" uuid NOT NULL, + "version" integer NOT NULL, + "encrypted_value" text NOT NULL, + "iv" text NOT NULL, + "auth_tag" text NOT NULL, + "encryption_key_id" varchar(128) NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_by" varchar(191), + "organization_id" varchar(191), + "is_active" boolean DEFAULT false NOT NULL +); +--> statement-breakpoint +CREATE TABLE "secrets" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" varchar(191) NOT NULL, + "description" text, + "tags" jsonb DEFAULT 'null'::jsonb, + "organization_id" varchar(191), + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "secrets_name_unique" UNIQUE("name") +); +--> statement-breakpoint +CREATE TABLE "platform_workflow_links" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL, + "workflow_id" uuid NOT NULL, + "platform_agent_id" varchar(191) NOT NULL, + "organization_id" varchar(191), + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "platform_workflow_links_id_pk" PRIMARY KEY("id") +); +--> statement-breakpoint +CREATE TABLE "workflow_roles" ( + "workflow_id" uuid NOT NULL, + "user_id" varchar(191) NOT NULL, + "organization_id" varchar(191), + "role" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "workflow_roles_workflow_id_user_id_pk" PRIMARY KEY("workflow_id","user_id") +); +--> statement-breakpoint +CREATE TABLE "integration_oauth_states" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "state" text NOT NULL, + "user_id" varchar(191) NOT NULL, + "provider" varchar(64) NOT NULL, + "organization_id" varchar(255), + "code_verifier" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "integration_provider_configs" ( + "provider" varchar(64) PRIMARY KEY NOT NULL, + "client_id" varchar(191) NOT NULL, + "client_secret" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "integration_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" varchar(191) NOT NULL, + "provider" varchar(64) NOT NULL, + "credential_type" varchar(32) DEFAULT 'oauth' NOT NULL, + "display_name" varchar(191) NOT NULL, + "organization_id" varchar(255) NOT NULL, + "scopes" jsonb DEFAULT '[]'::jsonb NOT NULL, + "access_token" jsonb NOT NULL, + "refresh_token" jsonb DEFAULT 'null'::jsonb, + "token_type" varchar(32) DEFAULT 'Bearer', + "expires_at" timestamp with time zone, + "last_validated_at" timestamp with time zone, + "last_validation_status" varchar(16), + "last_validation_error" text, + "last_used_at" timestamp with time zone, + "metadata" jsonb DEFAULT '{}'::jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "workflow_schedules" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workflow_id" uuid NOT NULL, + "workflow_version_id" uuid, + "workflow_version" integer, + "name" text NOT NULL, + "description" text, + "cron_expression" text NOT NULL, + "timezone" text NOT NULL, + "human_label" text, + "overlap_policy" text DEFAULT 'skip' NOT NULL, + "catchup_window_seconds" integer DEFAULT 0 NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "last_run_at" timestamp with time zone, + "next_run_at" timestamp with time zone, + "input_payload" jsonb DEFAULT '{"runtimeInputs":{},"nodeOverrides":{}}'::jsonb NOT NULL, + "temporal_schedule_id" text, + "temporal_snapshot" jsonb DEFAULT '{}'::jsonb NOT NULL, + "organization_id" varchar(191), + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "webhook_configurations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workflow_id" uuid NOT NULL, + "workflow_version_id" uuid, + "workflow_version" integer, + "name" text NOT NULL, + "description" text, + "webhook_path" varchar(255) NOT NULL, + "parsing_script" text NOT NULL, + "expected_inputs" jsonb NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "organization_id" varchar(191), + "created_by" varchar(191), + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "webhook_configurations_webhook_path_unique" UNIQUE("webhook_path") +); +--> statement-breakpoint +CREATE TABLE "webhook_deliveries" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "webhook_id" uuid NOT NULL, + "workflow_run_id" text, + "status" text DEFAULT 'processing' NOT NULL, + "payload" jsonb NOT NULL, + "headers" jsonb, + "parsed_data" jsonb, + "error_message" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "completed_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "workflow_terminal_records" ( + "id" bigserial PRIMARY KEY NOT NULL, + "run_id" text NOT NULL, + "workflow_id" text NOT NULL, + "workflow_version_id" text, + "node_ref" text NOT NULL, + "stream" text NOT NULL, + "file_id" uuid NOT NULL, + "chunk_count" integer DEFAULT 0 NOT NULL, + "duration_ms" integer DEFAULT 0 NOT NULL, + "first_chunk_index" integer, + "last_chunk_index" integer, + "organization_id" varchar(191), + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "completed_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "mcp_group_servers" ( + "group_id" uuid NOT NULL, + "server_id" uuid NOT NULL, + "recommended" boolean DEFAULT false NOT NULL, + "default_selected" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "mcp_group_servers_group_id_server_id_pk" PRIMARY KEY("group_id","server_id") +); +--> statement-breakpoint +CREATE TABLE "mcp_groups" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "slug" varchar(191) NOT NULL, + "name" varchar(191) NOT NULL, + "description" text, + "credential_contract_name" varchar(191) NOT NULL, + "credential_mapping" jsonb DEFAULT 'null'::jsonb, + "default_docker_image" varchar(255), + "template_hash" varchar(64), + "enabled" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "mcp_groups_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +CREATE TABLE "mcp_server_tools" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "server_id" uuid NOT NULL, + "tool_name" varchar(191) NOT NULL, + "description" text, + "input_schema" jsonb DEFAULT 'null'::jsonb, + "enabled" boolean DEFAULT true NOT NULL, + "discovered_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "mcp_servers" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" varchar(191) NOT NULL, + "description" text, + "transport_type" varchar(32) NOT NULL, + "endpoint" text, + "command" text, + "args" jsonb DEFAULT 'null'::jsonb, + "headers" jsonb DEFAULT 'null'::jsonb, + "enabled" boolean DEFAULT true NOT NULL, + "health_check_url" text, + "last_health_check" timestamp with time zone, + "last_health_status" varchar(32), + "group_id" uuid, + "organization_id" varchar(191), + "created_by" varchar(191), + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "node_io" ( + "id" bigserial PRIMARY KEY NOT NULL, + "run_id" text NOT NULL, + "node_ref" text NOT NULL, + "workflow_id" text, + "organization_id" varchar(191), + "component_id" text NOT NULL, + "inputs" jsonb, + "inputs_size" integer DEFAULT 0 NOT NULL, + "inputs_spilled" boolean DEFAULT false NOT NULL, + "inputs_storage_ref" text, + "outputs" jsonb, + "outputs_size" integer DEFAULT 0 NOT NULL, + "outputs_spilled" boolean DEFAULT false NOT NULL, + "outputs_storage_ref" text, + "started_at" timestamp with time zone, + "completed_at" timestamp with time zone, + "duration_ms" integer, + "status" text DEFAULT 'running' NOT NULL, + "error_message" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "organization_settings" ( + "organization_id" varchar(191) PRIMARY KEY NOT NULL, + "analytics_retention_days" integer DEFAULT 30 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "workflow_templates" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "slug" varchar(100), + "description" text, + "category" varchar(50) NOT NULL, + "graph" jsonb NOT NULL, + "icon" varchar(50), + "screenshot_url" varchar(255), + "is_featured" boolean DEFAULT false NOT NULL, + "run_count" integer DEFAULT 0 NOT NULL, + "is_default" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "workflow_templates_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +CREATE TABLE "user_settings" ( + "user_id" varchar(191) PRIMARY KEY NOT NULL, + "aws_external_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "artifacts" ADD CONSTRAINT "artifacts_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "asm_assets" ADD CONSTRAINT "asm_assets_domain_id_asm_domains_id_fk" FOREIGN KEY ("domain_id") REFERENCES "public"."asm_domains"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "asset_scan_status" ADD CONSTRAINT "asset_scan_status_asset_id_assets_id_fk" FOREIGN KEY ("asset_id") REFERENCES "public"."assets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "asset_sync_run_scopes" ADD CONSTRAINT "asset_sync_run_scopes_sync_run_id_asset_sync_runs_id_fk" FOREIGN KEY ("sync_run_id") REFERENCES "public"."asset_sync_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_session_id_chat_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."chat_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "chat_partial_responses" ADD CONSTRAINT "chat_partial_responses_session_id_chat_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."chat_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "chat_stream_events" ADD CONSTRAINT "chat_stream_events_session_id_chat_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."chat_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "github_repositories" ADD CONSTRAINT "github_repositories_installation_id_github_app_installations_id_fk" FOREIGN KEY ("installation_id") REFERENCES "public"."github_app_installations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "github_scan_results" ADD CONSTRAINT "github_scan_results_repository_id_github_repositories_id_fk" FOREIGN KEY ("repository_id") REFERENCES "public"."github_repositories"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "github_scan_results" ADD CONSTRAINT "github_scan_results_trigger_rule_id_github_trigger_rules_id_fk" FOREIGN KEY ("trigger_rule_id") REFERENCES "public"."github_trigger_rules"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "secret_versions" ADD CONSTRAINT "secret_versions_secret_id_secrets_id_fk" FOREIGN KEY ("secret_id") REFERENCES "public"."secrets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workflow_roles" ADD CONSTRAINT "workflow_roles_workflow_id_workflows_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflows"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_webhook_id_webhook_configurations_id_fk" FOREIGN KEY ("webhook_id") REFERENCES "public"."webhook_configurations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mcp_group_servers" ADD CONSTRAINT "mcp_group_servers_group_id_mcp_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."mcp_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mcp_group_servers" ADD CONSTRAINT "mcp_group_servers_server_id_mcp_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."mcp_servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mcp_server_tools" ADD CONSTRAINT "mcp_server_tools_server_id_mcp_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."mcp_servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mcp_servers" ADD CONSTRAINT "mcp_servers_group_id_mcp_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."mcp_groups"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "agent_trace_events_run_idx" ON "agent_trace_events" USING btree ("agent_run_id","sequence");--> statement-breakpoint +CREATE INDEX "agent_trace_events_workflow_idx" ON "agent_trace_events" USING btree ("workflow_run_id");--> statement-breakpoint +CREATE INDEX "api_keys_org_idx" ON "api_keys" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "api_keys_active_idx" ON "api_keys" USING btree ("is_active","organization_id");--> statement-breakpoint +CREATE INDEX "api_keys_created_by_idx" ON "api_keys" USING btree ("created_by");--> statement-breakpoint +CREATE INDEX "api_keys_hash_idx" ON "api_keys" USING btree ("key_hash");--> statement-breakpoint +CREATE INDEX "artifacts_run_idx" ON "artifacts" USING btree ("run_id","created_at");--> statement-breakpoint +CREATE UNIQUE INDEX "asm_domains_org_domain_unique" ON "asm_domains" USING btree ("organization_id","domain") WHERE deleted_at IS NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "asset_scan_status_asset_scan_type_uidx" ON "asset_scan_status" USING btree ("asset_id","scan_type");--> statement-breakpoint +CREATE UNIQUE INDEX "asset_sync_run_scopes_run_type_region_uidx" ON "asset_sync_run_scopes" USING btree ("sync_run_id","asset_type","region");--> statement-breakpoint +CREATE INDEX "asset_sync_run_scopes_sync_run_idx" ON "asset_sync_run_scopes" USING btree ("sync_run_id");--> statement-breakpoint +CREATE INDEX "asset_sync_runs_org_integration_created_idx" ON "asset_sync_runs" USING btree ("organization_id","integration_id","created_at");--> statement-breakpoint +CREATE INDEX "asset_sync_runs_org_account_created_idx" ON "asset_sync_runs" USING btree ("organization_id","account_identifier","created_at");--> statement-breakpoint +CREATE UNIQUE INDEX "assets_org_provider_type_external_uidx" ON "assets" USING btree ("organization_id","provider","asset_type","external_id");--> statement-breakpoint +CREATE INDEX "assets_org_asset_type_idx" ON "assets" USING btree ("organization_id","asset_type");--> statement-breakpoint +CREATE INDEX "assets_org_integration_idx" ON "assets" USING btree ("organization_id","integration_id");--> statement-breakpoint +CREATE INDEX "assets_status_last_seen_idx" ON "assets" USING btree ("status","last_seen_at");--> statement-breakpoint +CREATE INDEX "audit_logs_org_created_at_idx" ON "audit_logs" USING btree ("organization_id","created_at");--> statement-breakpoint +CREATE INDEX "audit_logs_org_resource_idx" ON "audit_logs" USING btree ("organization_id","resource_type","resource_id");--> statement-breakpoint +CREATE INDEX "audit_logs_org_action_created_at_idx" ON "audit_logs" USING btree ("organization_id","action","created_at");--> statement-breakpoint +CREATE INDEX "audit_logs_org_actor_created_at_idx" ON "audit_logs" USING btree ("organization_id","actor_id","created_at");--> statement-breakpoint +CREATE INDEX "chat_messages_session_created_idx" ON "chat_messages" USING btree ("session_id","created_at");--> statement-breakpoint +CREATE INDEX "chat_sessions_user_org_status_idx" ON "chat_sessions" USING btree ("user_id","organization_id","status");--> statement-breakpoint +CREATE INDEX "chat_stream_events_session_run_seq_idx" ON "chat_stream_events" USING btree ("session_id","run_id","seq");--> statement-breakpoint +CREATE UNIQUE INDEX "github_installations_installation_id_uidx" ON "github_app_installations" USING btree ("installation_id");--> statement-breakpoint +CREATE INDEX "github_installations_org_idx" ON "github_app_installations" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "github_installations_account_login_idx" ON "github_app_installations" USING btree ("account_login");--> statement-breakpoint +CREATE UNIQUE INDEX "github_repos_repo_id_uidx" ON "github_repositories" USING btree ("repo_id");--> statement-breakpoint +CREATE INDEX "github_repos_installation_idx" ON "github_repositories" USING btree ("installation_id");--> statement-breakpoint +CREATE INDEX "github_repos_org_idx" ON "github_repositories" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "github_repos_full_name_idx" ON "github_repositories" USING btree ("full_name");--> statement-breakpoint +CREATE INDEX "github_scan_results_repo_idx" ON "github_scan_results" USING btree ("repository_id");--> statement-breakpoint +CREATE INDEX "github_scan_results_org_idx" ON "github_scan_results" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "github_scan_results_pr_idx" ON "github_scan_results" USING btree ("repository_id","pr_number");--> statement-breakpoint +CREATE INDEX "github_scan_results_status_idx" ON "github_scan_results" USING btree ("status");--> statement-breakpoint +CREATE INDEX "github_scan_results_workflow_run_idx" ON "github_scan_results" USING btree ("workflow_run_id");--> statement-breakpoint +CREATE INDEX "github_scan_results_trigger_rule_idx" ON "github_scan_results" USING btree ("trigger_rule_id","created_at");--> statement-breakpoint +CREATE INDEX "github_trigger_rules_org_idx" ON "github_trigger_rules" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "github_trigger_rules_event_idx" ON "github_trigger_rules" USING btree ("event");--> statement-breakpoint +CREATE INDEX "github_trigger_rules_enabled_idx" ON "github_trigger_rules" USING btree ("enabled");--> statement-breakpoint +CREATE UNIQUE INDEX "workflow_versions_workflow_version_uidx" ON "workflow_versions" USING btree ("workflow_id","version");--> statement-breakpoint +CREATE INDEX "workflow_traces_run_idx" ON "workflow_traces" USING btree ("run_id","sequence");--> statement-breakpoint +CREATE INDEX "idx_workflow_runs_workflow_created" ON "workflow_runs" USING btree ("workflow_id","created_at");--> statement-breakpoint +CREATE INDEX "idx_workflow_runs_org_created" ON "workflow_runs" USING btree ("organization_id","created_at");--> statement-breakpoint +CREATE UNIQUE INDEX "workflow_log_streams_run_node_stream_idx" ON "workflow_log_streams" USING btree ("run_id","node_ref","stream");--> statement-breakpoint +CREATE UNIQUE INDEX "workflow_log_streams_run_node_stream_uidx" ON "workflow_log_streams" USING btree ("run_id","node_ref","stream");--> statement-breakpoint +CREATE INDEX "platform_workflow_links_agent_idx" ON "platform_workflow_links" USING btree ("platform_agent_id");--> statement-breakpoint +CREATE INDEX "platform_workflow_links_org_idx" ON "platform_workflow_links" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "workflow_roles_org_idx" ON "workflow_roles" USING btree ("organization_id","role");--> statement-breakpoint +CREATE INDEX "workflow_roles_user_idx" ON "workflow_roles" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "integration_oauth_states_state_uidx" ON "integration_oauth_states" USING btree ("state");--> statement-breakpoint +CREATE INDEX "integration_tokens_user_idx" ON "integration_tokens" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "integration_tokens_org_idx" ON "integration_tokens" USING btree ("organization_id");--> statement-breakpoint +CREATE UNIQUE INDEX "integration_tokens_org_provider_type_name_uidx" ON "integration_tokens" USING btree ("organization_id","provider","credential_type","display_name");--> statement-breakpoint +CREATE INDEX "mcp_group_servers_group_idx" ON "mcp_group_servers" USING btree ("group_id");--> statement-breakpoint +CREATE INDEX "mcp_group_servers_server_idx" ON "mcp_group_servers" USING btree ("server_id");--> statement-breakpoint +CREATE INDEX "mcp_groups_slug_idx" ON "mcp_groups" USING btree ("slug");--> statement-breakpoint +CREATE INDEX "mcp_groups_enabled_idx" ON "mcp_groups" USING btree ("enabled");--> statement-breakpoint +CREATE INDEX "mcp_server_tools_server_idx" ON "mcp_server_tools" USING btree ("server_id");--> statement-breakpoint +CREATE UNIQUE INDEX "mcp_server_tools_server_tool_uidx" ON "mcp_server_tools" USING btree ("server_id","tool_name");--> statement-breakpoint +CREATE INDEX "mcp_servers_org_idx" ON "mcp_servers" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "mcp_servers_enabled_idx" ON "mcp_servers" USING btree ("enabled");--> statement-breakpoint +CREATE INDEX "mcp_servers_group_idx" ON "mcp_servers" USING btree ("group_id");--> statement-breakpoint +CREATE UNIQUE INDEX "mcp_servers_name_org_uidx" ON "mcp_servers" USING btree ("name","organization_id");--> statement-breakpoint +CREATE UNIQUE INDEX "node_io_run_node_idx" ON "node_io" USING btree ("run_id","node_ref");--> statement-breakpoint +CREATE INDEX "node_io_run_idx" ON "node_io" USING btree ("run_id");--> statement-breakpoint +CREATE INDEX "node_io_workflow_idx" ON "node_io" USING btree ("workflow_id"); diff --git a/backend/drizzle/0000_tranquil_vertigo.sql b/backend/drizzle/0000_tranquil_vertigo.sql deleted file mode 100644 index fa08dbeb7..000000000 --- a/backend/drizzle/0000_tranquil_vertigo.sql +++ /dev/null @@ -1,19 +0,0 @@ -CREATE TABLE "files" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "file_name" varchar(255) NOT NULL, - "mime_type" varchar(100) NOT NULL, - "size" bigint NOT NULL, - "storage_key" varchar(500) NOT NULL, - "uploaded_at" timestamp DEFAULT now() NOT NULL, - CONSTRAINT "files_storage_key_unique" UNIQUE("storage_key") -); ---> statement-breakpoint -CREATE TABLE "workflows" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "name" text NOT NULL, - "description" text, - "graph" jsonb NOT NULL, - "compiled_definition" jsonb DEFAULT 'null'::jsonb, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); diff --git a/backend/drizzle/0001_complete_doctor_octopus.sql b/backend/drizzle/0001_complete_doctor_octopus.sql deleted file mode 100644 index 73ed62067..000000000 --- a/backend/drizzle/0001_complete_doctor_octopus.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "workflows" ADD COLUMN "last_run" timestamp with time zone;--> statement-breakpoint -ALTER TABLE "workflows" ADD COLUMN "run_count" integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/backend/drizzle/0001_reconcile_existing_schema.sql b/backend/drizzle/0001_reconcile_existing_schema.sql new file mode 100644 index 000000000..f456ab433 --- /dev/null +++ b/backend/drizzle/0001_reconcile_existing_schema.sql @@ -0,0 +1,221 @@ +-- Reconcile legacy clusters that were bootstrapped via schema push or partial migration chains. +-- This migration is additive and idempotent: no drops, renames, or destructive transforms. + +CREATE TABLE IF NOT EXISTS "asm_domains" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" varchar(191) NOT NULL, + "domain" varchar(255) NOT NULL, + "scope_include" text[], + "scope_exclude" text[], + "monitoring_enabled" boolean DEFAULT false, + "frequency" varchar(50), + "last_scan_at" timestamp with time zone, + "deleted_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS "asm_domains_org_domain_unique" + ON "asm_domains" USING btree ("organization_id", "domain") + WHERE deleted_at IS NULL; + +CREATE TABLE IF NOT EXISTS "asm_assets" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" varchar(191) NOT NULL, + "domain_id" uuid NOT NULL REFERENCES "asm_domains"("id") ON DELETE cascade, + "hostname" varchar(512) NOT NULL, + "type" varchar(50) NOT NULL, + "ip_addresses" text[], + "tech_stack" text[], + "ports" jsonb, + "risk_score" numeric(4, 1), + "dns_records" jsonb, + "tls_info" jsonb, + "first_seen" timestamp with time zone DEFAULT now() NOT NULL, + "last_seen" timestamp with time zone DEFAULT now() NOT NULL, + "metadata" jsonb, + CONSTRAINT "asm_assets_domain_id_hostname_unique" UNIQUE("domain_id", "hostname") +); + +CREATE TABLE IF NOT EXISTS "assets" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" varchar(255) NOT NULL, + "integration_id" uuid, + "provider" varchar(64) NOT NULL, + "account_identifier" varchar(255), + "asset_type" varchar(64) NOT NULL, + "external_id" text NOT NULL, + "name" text, + "region" varchar(64), + "metadata" jsonb DEFAULT '{}'::jsonb, + "first_discovered_at" timestamp with time zone NOT NULL, + "last_seen_at" timestamp with time zone NOT NULL, + "status" varchar(16) DEFAULT 'active', + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS "assets_org_provider_type_external_uidx" + ON "assets" USING btree ("organization_id", "provider", "asset_type", "external_id"); +CREATE INDEX IF NOT EXISTS "assets_org_asset_type_idx" + ON "assets" USING btree ("organization_id", "asset_type"); +CREATE INDEX IF NOT EXISTS "assets_org_integration_idx" + ON "assets" USING btree ("organization_id", "integration_id"); +CREATE INDEX IF NOT EXISTS "assets_status_last_seen_idx" + ON "assets" USING btree ("status", "last_seen_at"); + +CREATE TABLE IF NOT EXISTS "asset_scan_status" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "asset_id" uuid NOT NULL REFERENCES "assets"("id") ON DELETE cascade, + "scan_type" varchar(64) NOT NULL, + "last_scanned_at" timestamp with time zone, + "last_run_id" varchar(255), + "finding_count" integer, + "critical_count" integer, + "high_count" integer, + "status" varchar(16) DEFAULT 'unscanned' +); + +CREATE UNIQUE INDEX IF NOT EXISTS "asset_scan_status_asset_scan_type_uidx" + ON "asset_scan_status" USING btree ("asset_id", "scan_type"); + +CREATE TABLE IF NOT EXISTS "asset_sync_runs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" varchar(255) NOT NULL, + "integration_id" uuid NOT NULL, + "account_identifier" varchar(255), + "temporal_workflow_id" text, + "temporal_run_id" text, + "status" varchar(16) NOT NULL, + "covered_regions" jsonb DEFAULT '[]'::jsonb, + "assets_discovered" integer DEFAULT 0, + "assets_created" integer DEFAULT 0, + "assets_updated" integer DEFAULT 0, + "assets_stale" integer DEFAULT 0, + "errors" jsonb DEFAULT '[]'::jsonb, + "started_at" timestamp with time zone NOT NULL, + "completed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE INDEX IF NOT EXISTS "asset_sync_runs_org_integration_created_idx" + ON "asset_sync_runs" USING btree ("organization_id", "integration_id", "created_at"); +CREATE INDEX IF NOT EXISTS "asset_sync_runs_org_account_created_idx" + ON "asset_sync_runs" USING btree ("organization_id", "account_identifier", "created_at"); + +CREATE TABLE IF NOT EXISTS "asset_sync_run_scopes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "sync_run_id" uuid NOT NULL REFERENCES "asset_sync_runs"("id") ON DELETE cascade, + "asset_type" varchar(64) NOT NULL, + "region" varchar(64) NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS "asset_sync_run_scopes_run_type_region_uidx" + ON "asset_sync_run_scopes" USING btree ("sync_run_id", "asset_type", "region"); +CREATE INDEX IF NOT EXISTS "asset_sync_run_scopes_sync_run_idx" + ON "asset_sync_run_scopes" USING btree ("sync_run_id"); + +CREATE TABLE IF NOT EXISTS "workflow_templates" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "slug" varchar(100), + "description" text, + "category" varchar(50) NOT NULL, + "graph" jsonb NOT NULL, + "icon" varchar(50), + "screenshot_url" varchar(255), + "is_featured" boolean DEFAULT false NOT NULL, + "run_count" integer DEFAULT 0 NOT NULL, + "is_default" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS "workflow_templates_slug_unique" + ON "workflow_templates" USING btree ("slug"); + +CREATE TABLE IF NOT EXISTS "user_settings" ( + "user_id" varchar(191) PRIMARY KEY NOT NULL, + "aws_external_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE TABLE IF NOT EXISTS "audit_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" varchar(191), + "actor_id" varchar(191), + "actor_type" varchar(32) NOT NULL, + "actor_display" varchar(191), + "action" varchar(64) NOT NULL, + "resource_type" varchar(32) NOT NULL, + "resource_id" varchar(191), + "resource_name" varchar(191), + "metadata" jsonb, + "ip" varchar(64), + "user_agent" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE INDEX IF NOT EXISTS "audit_logs_org_created_at_idx" + ON "audit_logs" USING btree ("organization_id", "created_at"); +CREATE INDEX IF NOT EXISTS "audit_logs_org_resource_idx" + ON "audit_logs" USING btree ("organization_id", "resource_type", "resource_id"); +CREATE INDEX IF NOT EXISTS "audit_logs_org_action_created_at_idx" + ON "audit_logs" USING btree ("organization_id", "action", "created_at"); +CREATE INDEX IF NOT EXISTS "audit_logs_org_actor_created_at_idx" + ON "audit_logs" USING btree ("organization_id", "actor_id", "created_at"); + + +CREATE TABLE IF NOT EXISTS "chat_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "organization_id" varchar(191), + "title" text DEFAULT 'New Conversation' NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "processing_status" text DEFAULT 'idle' NOT NULL, + "active_run_id" uuid, + "next_event_seq" integer DEFAULT 1 NOT NULL, + "temporal_workflow_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + +ALTER TABLE IF EXISTS "chat_sessions" + ADD COLUMN IF NOT EXISTS "processing_status" text DEFAULT 'idle' NOT NULL; +ALTER TABLE IF EXISTS "chat_sessions" + ADD COLUMN IF NOT EXISTS "active_run_id" uuid; +ALTER TABLE IF EXISTS "chat_sessions" + ADD COLUMN IF NOT EXISTS "next_event_seq" integer DEFAULT 1 NOT NULL; + +CREATE INDEX IF NOT EXISTS "chat_sessions_user_org_status_idx" + ON "chat_sessions" USING btree ("user_id", "organization_id", "status"); + +CREATE TABLE IF NOT EXISTS "chat_messages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "session_id" uuid NOT NULL REFERENCES "chat_sessions"("id") ON DELETE cascade, + "role" text NOT NULL, + "parts" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE INDEX IF NOT EXISTS "chat_messages_session_created_idx" + ON "chat_messages" USING btree ("session_id", "created_at"); + +CREATE TABLE IF NOT EXISTS "chat_partial_responses" ( + "session_id" uuid PRIMARY KEY REFERENCES "chat_sessions"("id") ON DELETE cascade, + "parts" jsonb DEFAULT '[]'::jsonb NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE TABLE IF NOT EXISTS "chat_stream_events" ( + "id" bigserial PRIMARY KEY NOT NULL, + "session_id" uuid NOT NULL REFERENCES "chat_sessions"("id") ON DELETE cascade, + "run_id" uuid NOT NULL, + "seq" integer NOT NULL, + "event" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE INDEX IF NOT EXISTS "chat_stream_events_session_run_seq_idx" + ON "chat_stream_events" USING btree ("session_id", "run_id", "seq"); diff --git a/backend/drizzle/0002_add-workflow-traces.sql b/backend/drizzle/0002_add-workflow-traces.sql deleted file mode 100644 index 9dc6d823b..000000000 --- a/backend/drizzle/0002_add-workflow-traces.sql +++ /dev/null @@ -1,15 +0,0 @@ -CREATE TABLE "workflow_traces" ( - "id" bigserial PRIMARY KEY NOT NULL, - "run_id" text NOT NULL, - "workflow_id" text, - "type" text NOT NULL, - "node_ref" text NOT NULL, - "timestamp" timestamp with time zone NOT NULL, - "message" text, - "error" text, - "output_summary" jsonb, - "sequence" integer NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE INDEX "workflow_traces_run_idx" ON "workflow_traces" USING btree ("run_id","sequence"); \ No newline at end of file diff --git a/backend/drizzle/0002_reconcile_workflows_columns.sql b/backend/drizzle/0002_reconcile_workflows_columns.sql new file mode 100644 index 000000000..c44a0fd0a --- /dev/null +++ b/backend/drizzle/0002_reconcile_workflows_columns.sql @@ -0,0 +1,29 @@ +-- Reconcile legacy staging/prod databases where `workflows` pre-dates +-- the current schema and is missing columns introduced in 0000. +ALTER TABLE "workflows" + ADD COLUMN IF NOT EXISTS "is_system" boolean DEFAULT false NOT NULL; + +ALTER TABLE "workflows" + ADD COLUMN IF NOT EXISTS "template_id" uuid; + +ALTER TABLE "workflows" + ADD COLUMN IF NOT EXISTS "icon" varchar(50); + +ALTER TABLE "workflows" + ADD COLUMN IF NOT EXISTS "icon_color" text; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'workflows_template_id_workflow_templates_id_fk' + ) THEN + ALTER TABLE "workflows" + ADD CONSTRAINT "workflows_template_id_workflow_templates_id_fk" + FOREIGN KEY ("template_id") + REFERENCES "public"."workflow_templates"("id") + ON DELETE no action + ON UPDATE no action; + END IF; +END $$; diff --git a/backend/drizzle/0003_create-workflow-runs.sql b/backend/drizzle/0003_create-workflow-runs.sql deleted file mode 100644 index c409c9249..000000000 --- a/backend/drizzle/0003_create-workflow-runs.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE "workflow_runs" ( - "run_id" text PRIMARY KEY, - "workflow_id" uuid NOT NULL, - "temporal_run_id" text, - "total_actions" integer NOT NULL DEFAULT 0, - "created_at" timestamp with time zone NOT NULL DEFAULT now(), - "updated_at" timestamp with time zone NOT NULL DEFAULT now() -); diff --git a/backend/drizzle/0003_rare_mimic.sql b/backend/drizzle/0003_rare_mimic.sql new file mode 100644 index 000000000..8665ea843 --- /dev/null +++ b/backend/drizzle/0003_rare_mimic.sql @@ -0,0 +1,2 @@ +ALTER TABLE "github_repositories" ADD COLUMN "stars_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "github_repositories" ADD COLUMN "forks_count" integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/backend/drizzle/0004_chat_v2_turn_timeline.sql b/backend/drizzle/0004_chat_v2_turn_timeline.sql new file mode 100644 index 000000000..47410c3df --- /dev/null +++ b/backend/drizzle/0004_chat_v2_turn_timeline.sql @@ -0,0 +1,59 @@ +ALTER TABLE "chat_sessions" + ADD COLUMN IF NOT EXISTS "steer_target_turn_id" uuid, + ADD COLUMN IF NOT EXISTS "steer_requested_at" timestamp with time zone; + +CREATE TABLE IF NOT EXISTS "chat_turns" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "session_id" uuid NOT NULL REFERENCES "chat_sessions"("id") ON DELETE cascade, + "client_message_id" text, + "user_message" text NOT NULL, + "status" text DEFAULT 'queued' NOT NULL, + "run_id" uuid, + "generation_id" text, + "queued_at" timestamp with time zone DEFAULT now() NOT NULL, + "started_at" timestamp with time zone, + "ended_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "chat_turns_status_check" + CHECK ("status" IN ('queued', 'running', 'completed', 'interrupted', 'failed', 'cancelled')) +); + +CREATE INDEX IF NOT EXISTS "chat_turns_session_queued_idx" + ON "chat_turns" USING btree ("session_id", "queued_at"); + +CREATE UNIQUE INDEX IF NOT EXISTS "chat_turns_session_client_message_uidx" + ON "chat_turns" USING btree ("session_id", "client_message_id") + WHERE "client_message_id" IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS "chat_turns_one_running_per_session_uidx" + ON "chat_turns" USING btree ("session_id") + WHERE "status" = 'running'; + +CREATE TABLE IF NOT EXISTS "chat_session_events" ( + "id" bigserial PRIMARY KEY NOT NULL, + "session_id" uuid NOT NULL REFERENCES "chat_sessions"("id") ON DELETE cascade, + "turn_id" uuid NOT NULL REFERENCES "chat_turns"("id") ON DELETE cascade, + "run_id" uuid, + "generation_id" text, + "event_id" text NOT NULL, + "seq" integer NOT NULL, + "chunk" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS "chat_session_events_session_seq_uidx" + ON "chat_session_events" USING btree ("session_id", "seq"); + +CREATE UNIQUE INDEX IF NOT EXISTS "chat_session_events_turn_event_uidx" + ON "chat_session_events" USING btree ("turn_id", "event_id"); + +CREATE INDEX IF NOT EXISTS "chat_session_events_session_seq_idx" + ON "chat_session_events" USING btree ("session_id", "seq"); + +CREATE TABLE IF NOT EXISTS "chat_turn_drafts" ( + "turn_id" uuid PRIMARY KEY NOT NULL REFERENCES "chat_turns"("id") ON DELETE cascade, + "assistant_message_snapshot" jsonb DEFAULT '{}'::jsonb NOT NULL, + "last_seq" integer DEFAULT 0 NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); diff --git a/backend/drizzle/0004_update-workflow-traces.sql b/backend/drizzle/0004_update-workflow-traces.sql deleted file mode 100644 index 22b871165..000000000 --- a/backend/drizzle/0004_update-workflow-traces.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE "workflow_traces" - ADD COLUMN "level" text NOT NULL DEFAULT 'info', - ADD COLUMN "data" jsonb; diff --git a/backend/drizzle/0005_add-workflow-log-streams.sql b/backend/drizzle/0005_add-workflow-log-streams.sql deleted file mode 100644 index 025a5cb8e..000000000 --- a/backend/drizzle/0005_add-workflow-log-streams.sql +++ /dev/null @@ -1,15 +0,0 @@ -CREATE TABLE IF NOT EXISTS "workflow_log_streams" ( - "id" bigserial PRIMARY KEY, - "run_id" text NOT NULL, - "node_ref" text NOT NULL, - "stream" text NOT NULL, - "labels" jsonb NOT NULL, - "first_timestamp" timestamptz NOT NULL, - "last_timestamp" timestamptz NOT NULL, - "line_count" integer NOT NULL DEFAULT 0, - "created_at" timestamptz NOT NULL DEFAULT now(), - "updated_at" timestamptz NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS "workflow_log_streams_run_node_stream_idx" - ON "workflow_log_streams" ("run_id", "node_ref", "stream"); diff --git a/backend/drizzle/0005_chat_vnext_session_control_plane.sql b/backend/drizzle/0005_chat_vnext_session_control_plane.sql new file mode 100644 index 000000000..51c0ca4f2 --- /dev/null +++ b/backend/drizzle/0005_chat_vnext_session_control_plane.sql @@ -0,0 +1,3 @@ +ALTER TABLE "chat_sessions" + ADD COLUMN IF NOT EXISTS "active_turn_id" uuid, + ADD COLUMN IF NOT EXISTS "active_generation_id" integer NOT NULL DEFAULT 0; diff --git a/backend/drizzle/0005_create-secret-store.sql b/backend/drizzle/0005_create-secret-store.sql deleted file mode 100644 index f439fbe78..000000000 --- a/backend/drizzle/0005_create-secret-store.sql +++ /dev/null @@ -1,27 +0,0 @@ -CREATE TABLE IF NOT EXISTS "secrets" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), - "name" varchar(191) NOT NULL UNIQUE, - "description" text, - "tags" jsonb, - "created_at" timestamptz NOT NULL DEFAULT now(), - "updated_at" timestamptz NOT NULL DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS "secret_versions" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), - "secret_id" uuid NOT NULL REFERENCES "secrets"("id") ON DELETE CASCADE, - "version" integer NOT NULL, - "encrypted_value" text NOT NULL, - "iv" text NOT NULL, - "auth_tag" text NOT NULL, - "encryption_key_id" varchar(128) NOT NULL, - "created_at" timestamptz NOT NULL DEFAULT now(), - "created_by" varchar(191), - "is_active" boolean NOT NULL DEFAULT false -); - -CREATE UNIQUE INDEX IF NOT EXISTS "secret_versions_secret_id_version_idx" - ON "secret_versions" ("secret_id", "version"); - -CREATE INDEX IF NOT EXISTS "secret_versions_secret_id_idx" - ON "secret_versions" ("secret_id"); diff --git a/backend/drizzle/0006_add-workflow-log-streams-unique-index.sql b/backend/drizzle/0006_add-workflow-log-streams-unique-index.sql deleted file mode 100644 index c42d48d9c..000000000 --- a/backend/drizzle/0006_add-workflow-log-streams-unique-index.sql +++ /dev/null @@ -1,23 +0,0 @@ --- Ensure unique constraint for workflow log streams metadata upserts -BEGIN; - -WITH ranked_streams AS ( - SELECT - id, - ROW_NUMBER() OVER ( - PARTITION BY run_id, node_ref, stream - ORDER BY id - ) AS row_number - FROM workflow_log_streams -) -DELETE FROM workflow_log_streams -WHERE id IN ( - SELECT id - FROM ranked_streams - WHERE row_number > 1 -); - -CREATE UNIQUE INDEX IF NOT EXISTS workflow_log_streams_run_node_stream_uidx - ON workflow_log_streams (run_id, node_ref, stream); - -COMMIT; diff --git a/backend/drizzle/0007_create-integration-tokens.sql b/backend/drizzle/0007_create-integration-tokens.sql deleted file mode 100644 index 6f65ba842..000000000 --- a/backend/drizzle/0007_create-integration-tokens.sql +++ /dev/null @@ -1,31 +0,0 @@ -CREATE TABLE IF NOT EXISTS "integration_tokens" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), - "user_id" varchar(191) NOT NULL, - "provider" varchar(64) NOT NULL, - "scopes" jsonb NOT NULL DEFAULT '[]'::jsonb, - "access_token" jsonb NOT NULL, - "refresh_token" jsonb, - "token_type" varchar(32) DEFAULT 'Bearer', - "expires_at" timestamptz, - "metadata" jsonb DEFAULT '{}'::jsonb, - "created_at" timestamptz NOT NULL DEFAULT now(), - "updated_at" timestamptz NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS "integration_tokens_user_idx" - ON "integration_tokens" ("user_id"); - -CREATE UNIQUE INDEX IF NOT EXISTS "integration_tokens_user_provider_uidx" - ON "integration_tokens" ("user_id", "provider"); - -CREATE TABLE IF NOT EXISTS "integration_oauth_states" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), - "state" text NOT NULL, - "user_id" varchar(191) NOT NULL, - "provider" varchar(64) NOT NULL, - "code_verifier" text, - "created_at" timestamptz NOT NULL DEFAULT now() -); - -CREATE UNIQUE INDEX IF NOT EXISTS "integration_oauth_states_state_uidx" - ON "integration_oauth_states" ("state"); diff --git a/backend/drizzle/0008_add-org-scoping.sql b/backend/drizzle/0008_add-org-scoping.sql deleted file mode 100644 index aa0c25f16..000000000 --- a/backend/drizzle/0008_add-org-scoping.sql +++ /dev/null @@ -1,33 +0,0 @@ --- Add optional organization scoping columns. These default to NULL until backfill logic runs. - -ALTER TABLE workflows - ADD COLUMN IF NOT EXISTS organization_id VARCHAR(191); - -ALTER TABLE workflow_versions - ADD COLUMN IF NOT EXISTS organization_id VARCHAR(191); - -ALTER TABLE workflow_runs - ADD COLUMN IF NOT EXISTS organization_id VARCHAR(191); - -ALTER TABLE workflow_traces - ADD COLUMN IF NOT EXISTS organization_id VARCHAR(191); - -ALTER TABLE workflow_log_streams - ADD COLUMN IF NOT EXISTS organization_id VARCHAR(191); - -ALTER TABLE secrets - ADD COLUMN IF NOT EXISTS organization_id VARCHAR(191); - -ALTER TABLE secret_versions - ADD COLUMN IF NOT EXISTS organization_id VARCHAR(191); - -ALTER TABLE files - ADD COLUMN IF NOT EXISTS organization_id VARCHAR(191); - -CREATE INDEX IF NOT EXISTS workflows_organization_idx ON workflows (organization_id); -CREATE INDEX IF NOT EXISTS workflow_runs_organization_idx ON workflow_runs (organization_id); -CREATE INDEX IF NOT EXISTS workflow_traces_organization_idx ON workflow_traces (organization_id); -CREATE INDEX IF NOT EXISTS workflow_log_streams_organization_idx ON workflow_log_streams (organization_id); -CREATE INDEX IF NOT EXISTS secrets_organization_idx ON secrets (organization_id); -CREATE INDEX IF NOT EXISTS secret_versions_organization_idx ON secret_versions (organization_id); -CREATE INDEX IF NOT EXISTS files_organization_idx ON files (organization_id); diff --git a/backend/drizzle/0008_add-workflow-versions.sql b/backend/drizzle/0008_add-workflow-versions.sql deleted file mode 100644 index c06167666..000000000 --- a/backend/drizzle/0008_add-workflow-versions.sql +++ /dev/null @@ -1,40 +0,0 @@ -CREATE TABLE "workflow_versions" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), - "workflow_id" uuid NOT NULL, - "version" integer NOT NULL, - "graph" jsonb NOT NULL, - "compiled_definition" jsonb DEFAULT NULL, - "created_at" timestamp with time zone NOT NULL DEFAULT now() -); - -CREATE UNIQUE INDEX "workflow_versions_workflow_version_uidx" - ON "workflow_versions" ("workflow_id", "version"); - -ALTER TABLE "workflow_runs" ADD COLUMN "workflow_version_id" uuid; -ALTER TABLE "workflow_runs" ADD COLUMN "workflow_version" integer; - -INSERT INTO "workflow_versions" ( - "workflow_id", - "version", - "graph", - "compiled_definition", - "created_at" -) -SELECT - w."id", - 1 AS "version", - w."graph", - w."compiled_definition", - COALESCE(w."updated_at", w."created_at") -FROM "workflows" w; - -UPDATE "workflow_runs" -SET "workflow_version" = 1 -WHERE "workflow_version" IS NULL; - -UPDATE "workflow_runs" wr -SET "workflow_version_id" = v."id" -FROM "workflow_versions" v -WHERE wr."workflow_id" = v."workflow_id" - AND wr."workflow_version" = v."version" - AND wr."workflow_version_id" IS NULL; diff --git a/backend/drizzle/0009_backfill-org-columns.sql b/backend/drizzle/0009_backfill-org-columns.sql deleted file mode 100644 index 995544bbc..000000000 --- a/backend/drizzle/0009_backfill-org-columns.sql +++ /dev/null @@ -1,33 +0,0 @@ --- Backfill organization IDs for existing records so new auth enforcement can rely on scoped data. - -UPDATE workflows -SET organization_id = 'local-dev' -WHERE organization_id IS NULL; - -UPDATE workflow_versions -SET organization_id = 'local-dev' -WHERE organization_id IS NULL; - -UPDATE workflow_runs -SET organization_id = 'local-dev' -WHERE organization_id IS NULL; - -UPDATE workflow_traces -SET organization_id = 'local-dev' -WHERE organization_id IS NULL; - -UPDATE workflow_log_streams -SET organization_id = 'local-dev' -WHERE organization_id IS NULL; - -UPDATE secrets -SET organization_id = 'local-dev' -WHERE organization_id IS NULL; - -UPDATE secret_versions -SET organization_id = 'local-dev' -WHERE organization_id IS NULL; - -UPDATE files -SET organization_id = 'local-dev' -WHERE organization_id IS NULL; diff --git a/backend/drizzle/0010_create_workflow_roles.sql b/backend/drizzle/0010_create_workflow_roles.sql deleted file mode 100644 index 34831e46b..000000000 --- a/backend/drizzle/0010_create_workflow_roles.sql +++ /dev/null @@ -1,15 +0,0 @@ -CREATE TABLE IF NOT EXISTS workflow_roles ( - workflow_id UUID NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, - user_id VARCHAR(191) NOT NULL, - organization_id VARCHAR(191), - role TEXT NOT NULL CHECK (role IN ('ADMIN', 'MEMBER')), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (workflow_id, user_id) -); - -CREATE INDEX IF NOT EXISTS workflow_roles_org_idx - ON workflow_roles (organization_id, role); - -CREATE INDEX IF NOT EXISTS workflow_roles_user_idx - ON workflow_roles (user_id); diff --git a/backend/drizzle/0011_create-platform-workflow-links.sql b/backend/drizzle/0011_create-platform-workflow-links.sql deleted file mode 100644 index 7333e87b5..000000000 --- a/backend/drizzle/0011_create-platform-workflow-links.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE TABLE IF NOT EXISTS platform_workflow_links ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - workflow_id UUID NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, - platform_agent_id VARCHAR(191) NOT NULL, - organization_id VARCHAR(191), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE UNIQUE INDEX IF NOT EXISTS platform_workflow_links_agent_idx - ON platform_workflow_links (platform_agent_id); - -CREATE INDEX IF NOT EXISTS platform_workflow_links_org_idx - ON platform_workflow_links (organization_id); diff --git a/backend/drizzle/0012_add-workflow-run-inputs.sql b/backend/drizzle/0012_add-workflow-run-inputs.sql deleted file mode 100644 index 7bb067f7b..000000000 --- a/backend/drizzle/0012_add-workflow-run-inputs.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "workflow_runs" -ADD COLUMN "inputs" jsonb NOT NULL DEFAULT '{}'::jsonb; diff --git a/backend/drizzle/0013_create-artifacts.sql b/backend/drizzle/0013_create-artifacts.sql deleted file mode 100644 index 98e25f4df..000000000 --- a/backend/drizzle/0013_create-artifacts.sql +++ /dev/null @@ -1,18 +0,0 @@ -CREATE TABLE IF NOT EXISTS "artifacts" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), - "run_id" text NOT NULL, - "workflow_id" uuid NOT NULL, - "workflow_version_id" uuid, - "component_id" text, - "component_ref" text NOT NULL, - "file_id" uuid NOT NULL REFERENCES "files"("id") ON DELETE cascade, - "name" text NOT NULL, - "mime_type" varchar(150) NOT NULL, - "size" bigint NOT NULL, - "destinations" jsonb NOT NULL DEFAULT '["run"]'::jsonb, - "metadata" jsonb, - "organization_id" varchar(191), - "created_at" timestamptz DEFAULT now() NOT NULL -); - -CREATE INDEX IF NOT EXISTS "artifacts_run_idx" ON "artifacts" ("run_id", "created_at"); diff --git a/backend/drizzle/0014_create-terminal-records.sql b/backend/drizzle/0014_create-terminal-records.sql deleted file mode 100644 index 4078ac3f2..000000000 --- a/backend/drizzle/0014_create-terminal-records.sql +++ /dev/null @@ -1,19 +0,0 @@ -CREATE TABLE IF NOT EXISTS workflow_terminal_records ( - id BIGSERIAL PRIMARY KEY, - run_id TEXT NOT NULL, - workflow_id TEXT NOT NULL, - workflow_version_id TEXT, - node_ref TEXT NOT NULL, - stream TEXT NOT NULL, - file_id UUID NOT NULL, - chunk_count INTEGER NOT NULL DEFAULT 0, - duration_ms INTEGER NOT NULL DEFAULT 0, - first_chunk_index INTEGER, - last_chunk_index INTEGER, - organization_id VARCHAR(191), - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - completed_at TIMESTAMPTZ -); - -CREATE INDEX IF NOT EXISTS workflow_terminal_records_run_idx - ON workflow_terminal_records (run_id, node_ref); diff --git a/backend/drizzle/0015_create-workflow-schedules.sql b/backend/drizzle/0015_create-workflow-schedules.sql deleted file mode 100644 index 64e31b266..000000000 --- a/backend/drizzle/0015_create-workflow-schedules.sql +++ /dev/null @@ -1,29 +0,0 @@ -CREATE TABLE IF NOT EXISTS "workflow_schedules" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), - "workflow_id" uuid NOT NULL, - "workflow_version_id" uuid, - "workflow_version" integer, - "name" text NOT NULL, - "description" text, - "cron_expression" text NOT NULL, - "timezone" text NOT NULL, - "human_label" text, - "overlap_policy" text NOT NULL DEFAULT 'skip', - "catchup_window_seconds" integer NOT NULL DEFAULT 0, - "status" text NOT NULL DEFAULT 'active', - "last_run_at" timestamptz, - "next_run_at" timestamptz, - "input_payload" jsonb NOT NULL DEFAULT '{"runtimeInputs":{},"nodeOverrides":{}}'::jsonb, - "temporal_schedule_id" text, - "temporal_snapshot" jsonb NOT NULL DEFAULT '{}'::jsonb, - "organization_id" varchar(191), - "created_at" timestamptz NOT NULL DEFAULT now(), - "updated_at" timestamptz NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS "workflow_schedules_workflow_idx" ON "workflow_schedules" ("workflow_id", "status"); - -ALTER TABLE "workflow_runs" ADD COLUMN IF NOT EXISTS "trigger_type" text NOT NULL DEFAULT 'manual'; -ALTER TABLE "workflow_runs" ADD COLUMN IF NOT EXISTS "trigger_source" text; -ALTER TABLE "workflow_runs" ADD COLUMN IF NOT EXISTS "trigger_label" text NOT NULL DEFAULT 'Manual run'; -ALTER TABLE "workflow_runs" ADD COLUMN IF NOT EXISTS "input_preview" jsonb NOT NULL DEFAULT '{"runtimeInputs":{},"nodeOverrides":{}}'::jsonb; diff --git a/backend/drizzle/0016_create-api-keys.sql b/backend/drizzle/0016_create-api-keys.sql deleted file mode 100644 index 9873e9152..000000000 --- a/backend/drizzle/0016_create-api-keys.sql +++ /dev/null @@ -1,25 +0,0 @@ -CREATE TABLE "api_keys" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "name" varchar(191) NOT NULL, - "description" text, - "key_hash" text NOT NULL, - "key_prefix" varchar(20) NOT NULL, - "key_hint" varchar(8) NOT NULL, - "permissions" jsonb NOT NULL, - "scopes" jsonb DEFAULT '[]'::jsonb, - "organization_id" varchar(191) NOT NULL, - "created_by" varchar(191) NOT NULL, - "is_active" boolean DEFAULT true NOT NULL, - "expires_at" timestamp with time zone, - "last_used_at" timestamp with time zone, - "usage_count" integer DEFAULT 0 NOT NULL, - "rate_limit" integer, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "api_keys_key_hash_unique" UNIQUE("key_hash") -); ---> statement-breakpoint -CREATE INDEX "api_keys_org_idx" ON "api_keys" USING btree ("organization_id");--> statement-breakpoint -CREATE INDEX "api_keys_active_idx" ON "api_keys" USING btree ("is_active","organization_id");--> statement-breakpoint -CREATE INDEX "api_keys_created_by_idx" ON "api_keys" USING btree ("created_by");--> statement-breakpoint -CREATE INDEX "api_keys_hash_idx" ON "api_keys" USING btree ("key_hash"); diff --git a/backend/drizzle/0017_create-approval-requests.sql b/backend/drizzle/0017_create-approval-requests.sql deleted file mode 100644 index 4e566d181..000000000 --- a/backend/drizzle/0017_create-approval-requests.sql +++ /dev/null @@ -1,59 +0,0 @@ --- Drop the old approval_requests table (v1, no legacy data) -DROP TABLE IF EXISTS approval_requests; - --- Drop old enum -DROP TYPE IF EXISTS approval_status; - --- Create new enum for human input status -CREATE TYPE human_input_status AS ENUM ('pending', 'resolved', 'expired', 'cancelled'); - --- Create new enum for input types -CREATE TYPE human_input_type AS ENUM ('approval', 'form', 'selection', 'review', 'acknowledge'); - --- Human Input Requests table - generalized HITL system -CREATE TABLE human_input_requests ( - -- Primary key - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Workflow context - run_id TEXT NOT NULL, - workflow_id UUID NOT NULL, - node_ref TEXT NOT NULL, - - -- Status - status human_input_status NOT NULL DEFAULT 'pending', - - -- Input type and schema - input_type human_input_type NOT NULL DEFAULT 'approval', - input_schema JSONB NOT NULL DEFAULT '{}', - - -- Display metadata - title TEXT NOT NULL, - description TEXT, - context JSONB DEFAULT '{}', - - -- Secure token for public links - resolve_token TEXT NOT NULL UNIQUE, - - -- Timeout handling - timeout_at TIMESTAMPTZ, - - -- Response tracking - response_data JSONB, - responded_at TIMESTAMPTZ, - responded_by TEXT, - - -- Multi-tenancy - organization_id VARCHAR(191), - - -- Audit timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- Indexes for common queries -CREATE INDEX idx_human_input_requests_status ON human_input_requests(status); -CREATE INDEX idx_human_input_requests_run_id ON human_input_requests(run_id); -CREATE INDEX idx_human_input_requests_workflow_id ON human_input_requests(workflow_id); -CREATE INDEX idx_human_input_requests_organization_id ON human_input_requests(organization_id); -CREATE INDEX idx_human_input_requests_resolve_token ON human_input_requests(resolve_token); diff --git a/backend/drizzle/0018_add-subworkflow-linkage.sql b/backend/drizzle/0018_add-subworkflow-linkage.sql deleted file mode 100644 index e1b599541..000000000 --- a/backend/drizzle/0018_add-subworkflow-linkage.sql +++ /dev/null @@ -1,8 +0,0 @@ -ALTER TABLE workflow_runs -ADD COLUMN parent_run_id text REFERENCES workflow_runs(run_id) ON DELETE SET NULL; - -ALTER TABLE workflow_runs -ADD COLUMN parent_node_ref text; - -CREATE INDEX IF NOT EXISTS workflow_runs_parent_run_idx ON workflow_runs(parent_run_id); - diff --git a/backend/drizzle/0018_create-webhooks.sql b/backend/drizzle/0018_create-webhooks.sql deleted file mode 100644 index 1b648cd73..000000000 --- a/backend/drizzle/0018_create-webhooks.sql +++ /dev/null @@ -1,34 +0,0 @@ -CREATE TABLE IF NOT EXISTS "webhook_configurations" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), - "workflow_id" uuid NOT NULL, - "workflow_version_id" uuid, - "workflow_version" integer, - "name" text NOT NULL, - "description" text, - "webhook_path" varchar(255) UNIQUE NOT NULL, - "parsing_script" text NOT NULL, - "expected_inputs" jsonb NOT NULL, - "status" text NOT NULL DEFAULT 'active', - "organization_id" varchar(191), - "created_by" varchar(191), - "created_at" timestamptz NOT NULL DEFAULT now(), - "updated_at" timestamptz NOT NULL DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS "webhook_deliveries" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), - "webhook_id" uuid NOT NULL REFERENCES "webhook_configurations"("id") ON DELETE CASCADE, - "workflow_run_id" text, - "status" text NOT NULL DEFAULT 'processing', - "payload" jsonb NOT NULL, - "headers" jsonb, - "parsed_data" jsonb, - "error_message" text, - "created_at" timestamptz NOT NULL DEFAULT now(), - "completed_at" timestamptz -); - -CREATE INDEX IF NOT EXISTS "webhook_configurations_workflow_idx" ON "webhook_configurations" ("workflow_id", "status"); -CREATE INDEX IF NOT EXISTS "webhook_configurations_path_idx" ON "webhook_configurations" ("webhook_path"); -CREATE INDEX IF NOT EXISTS "webhook_deliveries_webhook_idx" ON "webhook_deliveries" ("webhook_id", "created_at" DESC); -CREATE INDEX IF NOT EXISTS "webhook_deliveries_run_id_idx" ON "webhook_deliveries" ("workflow_run_id"); diff --git a/backend/drizzle/0019_migrate-error-to-jsonb.sql b/backend/drizzle/0019_migrate-error-to-jsonb.sql deleted file mode 100644 index ac9115425..000000000 --- a/backend/drizzle/0019_migrate-error-to-jsonb.sql +++ /dev/null @@ -1,39 +0,0 @@ --- Migration: Convert workflow_traces.error from TEXT to JSONB --- Required for: Structured error representation (commit c7282f9d) --- Note: This migration preserves existing JSON text by parsing it, with fallback for plain text --- Safe to run on databases that already have JSONB (will be a no-op) - --- Helper function to safely parse JSON text, falling back to wrapping as JSON string -CREATE OR REPLACE FUNCTION pg_temp.try_parse_jsonb(text_value TEXT) RETURNS JSONB AS $$ -BEGIN - IF text_value IS NULL THEN - RETURN NULL; - END IF; - -- Try to parse as JSON - RETURN text_value::jsonb; -EXCEPTION WHEN OTHERS THEN - -- If parsing fails, wrap the plain text as a JSON string - RETURN to_jsonb(text_value); -END; -$$ LANGUAGE plpgsql IMMUTABLE; - -DO $$ -BEGIN - -- Check if the column is still TEXT type - IF EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_name = 'workflow_traces' - AND column_name = 'error' - AND data_type = 'text' - ) THEN - -- Convert TEXT to JSONB, parsing existing JSON text or wrapping plain text - ALTER TABLE workflow_traces - ALTER COLUMN error TYPE jsonb - USING pg_temp.try_parse_jsonb(error); - - RAISE NOTICE 'Successfully migrated workflow_traces.error from TEXT to JSONB'; - ELSE - RAISE NOTICE 'workflow_traces.error is already JSONB or does not exist, skipping migration'; - END IF; -END $$; diff --git a/backend/drizzle/0020_add-billing-usage-indexes.sql b/backend/drizzle/0020_add-billing-usage-indexes.sql new file mode 100644 index 000000000..370dfed39 --- /dev/null +++ b/backend/drizzle/0020_add-billing-usage-indexes.sql @@ -0,0 +1,9 @@ +-- Indexes to support org-scoped monthly usage queries for billing/credits metering. + +CREATE INDEX IF NOT EXISTS workflow_runs_org_created_at_idx + ON workflow_runs (organization_id, created_at DESC); + +-- Agent trace events include an event timestamp (when the part occurred). +CREATE INDEX IF NOT EXISTS agent_trace_events_type_timestamp_idx + ON agent_trace_events (part_type, "timestamp" DESC); + diff --git a/backend/drizzle/0020_create-audit-logs.sql b/backend/drizzle/0020_create-audit-logs.sql deleted file mode 100644 index 9763e81ba..000000000 --- a/backend/drizzle/0020_create-audit-logs.sql +++ /dev/null @@ -1,21 +0,0 @@ -CREATE TABLE IF NOT EXISTS "audit_logs" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), - "organization_id" varchar(191), - "actor_id" varchar(191), - "actor_type" varchar(32) NOT NULL, - "actor_display" varchar(191), - "action" varchar(64) NOT NULL, - "resource_type" varchar(32) NOT NULL, - "resource_id" varchar(191), - "resource_name" varchar(191), - "metadata" jsonb, - "ip" varchar(64), - "user_agent" text, - "created_at" timestamptz NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS "audit_logs_org_created_at_idx" ON "audit_logs" ("organization_id", "created_at" DESC); -CREATE INDEX IF NOT EXISTS "audit_logs_org_resource_idx" ON "audit_logs" ("organization_id", "resource_type", "resource_id"); -CREATE INDEX IF NOT EXISTS "audit_logs_org_action_created_at_idx" ON "audit_logs" ("organization_id", "action", "created_at" DESC); -CREATE INDEX IF NOT EXISTS "audit_logs_org_actor_created_at_idx" ON "audit_logs" ("organization_id", "actor_id", "created_at" DESC); - diff --git a/backend/drizzle/0023_add_onboarding_preferences.sql b/backend/drizzle/0023_add_onboarding_preferences.sql new file mode 100644 index 000000000..664e77762 --- /dev/null +++ b/backend/drizzle/0023_add_onboarding_preferences.sql @@ -0,0 +1,6 @@ +ALTER TABLE user_settings + ALTER COLUMN aws_external_id DROP NOT NULL; + +ALTER TABLE user_settings + ADD COLUMN IF NOT EXISTS app_navigation_tour_completed BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS workflow_builder_tour_completed BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/drizzle/0024_add_slack_thread_mappings.sql b/backend/drizzle/0024_add_slack_thread_mappings.sql new file mode 100644 index 000000000..3323c588e --- /dev/null +++ b/backend/drizzle/0024_add_slack_thread_mappings.sql @@ -0,0 +1,18 @@ +-- Add origin column to chat_sessions +ALTER TABLE chat_sessions + ADD COLUMN IF NOT EXISTS origin TEXT NOT NULL DEFAULT 'web'; + +-- Create slack thread mappings table +CREATE TABLE IF NOT EXISTS slack_thread_mappings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE, + slack_team_id TEXT NOT NULL, + slack_channel_id TEXT NOT NULL, + slack_thread_ts TEXT NOT NULL, + connection_id UUID NOT NULL, + bot_user_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT slack_thread_mappings_thread_uidx UNIQUE (slack_team_id, slack_channel_id, slack_thread_ts) +); + +CREATE INDEX IF NOT EXISTS slack_thread_mappings_session_idx ON slack_thread_mappings (session_id); diff --git a/backend/drizzle/0025_add_slack_turn_posts.sql b/backend/drizzle/0025_add_slack_turn_posts.sql new file mode 100644 index 000000000..3b078c424 --- /dev/null +++ b/backend/drizzle/0025_add_slack_turn_posts.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS slack_turn_posts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE, + turn_id UUID NOT NULL REFERENCES chat_turns(id) ON DELETE CASCADE UNIQUE, + primary_message_ts TEXT, + continuation_message_ts JSONB NOT NULL DEFAULT '[]'::jsonb, + last_content_hash TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS slack_turn_posts_session_idx ON slack_turn_posts (session_id); +CREATE INDEX IF NOT EXISTS slack_turn_posts_turn_idx ON slack_turn_posts (turn_id); diff --git a/backend/drizzle/0026_add-run-status-cache.sql b/backend/drizzle/0026_add-run-status-cache.sql deleted file mode 100644 index cbb55148f..000000000 --- a/backend/drizzle/0026_add-run-status-cache.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE "workflow_runs" ADD COLUMN IF NOT EXISTS "status" text; -ALTER TABLE "workflow_runs" ADD COLUMN IF NOT EXISTS "close_time" timestamp with time zone; -CREATE INDEX IF NOT EXISTS "idx_workflow_runs_status" ON "workflow_runs" ("status"); diff --git a/backend/drizzle/0026_add_mcp_server_templates.sql b/backend/drizzle/0026_add_mcp_server_templates.sql new file mode 100644 index 000000000..cee2c30a0 --- /dev/null +++ b/backend/drizzle/0026_add_mcp_server_templates.sql @@ -0,0 +1,6 @@ +ALTER TABLE "mcp_servers" +ADD COLUMN "template_slug" varchar(191), +ADD COLUMN "template_hash" varchar(64), +ADD COLUMN "template_config" jsonb DEFAULT null; + +CREATE INDEX "mcp_servers_template_slug_idx" ON "mcp_servers" USING btree ("template_slug"); diff --git a/backend/drizzle/0027_add_github_installation_id_to_mcp_servers.sql b/backend/drizzle/0027_add_github_installation_id_to_mcp_servers.sql new file mode 100644 index 000000000..e37c3a9b5 --- /dev/null +++ b/backend/drizzle/0027_add_github_installation_id_to_mcp_servers.sql @@ -0,0 +1,15 @@ +ALTER TABLE "mcp_servers" +ADD COLUMN "github_installation_id" bigint; + +UPDATE "mcp_servers" +SET "github_installation_id" = ("template_config"->>'installationId')::bigint +WHERE "template_slug" = 'github' + AND "template_config" IS NOT NULL + AND "template_config" ? 'installationId'; + +CREATE INDEX "mcp_servers_github_installation_idx" + ON "mcp_servers" USING btree ("github_installation_id"); + +CREATE UNIQUE INDEX "mcp_servers_github_installation_org_uidx" + ON "mcp_servers" USING btree ("organization_id", "template_slug", "github_installation_id") + WHERE "template_slug" = 'github' AND "github_installation_id" IS NOT NULL; diff --git a/backend/drizzle/0028_add_report_templates.sql b/backend/drizzle/0028_add_report_templates.sql new file mode 100644 index 000000000..e07190611 --- /dev/null +++ b/backend/drizzle/0028_add_report_templates.sql @@ -0,0 +1,54 @@ +CREATE TABLE IF NOT EXISTS report_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id VARCHAR(191), + name TEXT NOT NULL, + slug VARCHAR(120) NOT NULL, + description TEXT, + category VARCHAR(80) NOT NULL, + status VARCHAR(20) NOT NULL, + scope VARCHAR(20) NOT NULL, + visibility VARCHAR(20) NOT NULL, + created_by VARCHAR(191), + published_at TIMESTAMPTZ, + published_version_id UUID, + draft_version_id UUID, + draft_session_id UUID, + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL +); + +CREATE TABLE IF NOT EXISTS report_template_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + template_id UUID NOT NULL REFERENCES report_templates(id) ON DELETE CASCADE, + version_number INTEGER NOT NULL, + kind VARCHAR(20) NOT NULL, + name TEXT NOT NULL, + description TEXT, + html_template TEXT NOT NULL, + css TEXT NOT NULL DEFAULT '', + input_schema JSONB NOT NULL DEFAULT '{}'::jsonb, + sample_data JSONB NOT NULL DEFAULT '{}'::jsonb, + created_by VARCHAR(191), + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL +); + +CREATE INDEX IF NOT EXISTS report_templates_org_idx + ON report_templates (organization_id); + +CREATE INDEX IF NOT EXISTS report_templates_scope_visibility_idx + ON report_templates (scope, visibility, updated_at DESC); + +CREATE INDEX IF NOT EXISTS report_templates_published_version_idx + ON report_templates (published_version_id); + +CREATE INDEX IF NOT EXISTS report_templates_draft_version_idx + ON report_templates (draft_version_id); + +CREATE INDEX IF NOT EXISTS report_templates_draft_session_idx + ON report_templates (draft_session_id); + +CREATE INDEX IF NOT EXISTS report_template_versions_template_idx + ON report_template_versions (template_id, version_number DESC); + +CREATE UNIQUE INDEX IF NOT EXISTS report_template_versions_template_kind_version_unique + ON report_template_versions (template_id, kind, version_number); diff --git a/backend/drizzle/0029_add_integration_natural_key.sql b/backend/drizzle/0029_add_integration_natural_key.sql new file mode 100644 index 000000000..1d4dcb1e2 --- /dev/null +++ b/backend/drizzle/0029_add_integration_natural_key.sql @@ -0,0 +1,14 @@ +ALTER TABLE integration_tokens +ADD COLUMN IF NOT EXISTS natural_key VARCHAR(255); + +UPDATE integration_tokens +SET natural_key = display_name +WHERE natural_key IS NULL; + +ALTER TABLE integration_tokens +ALTER COLUMN natural_key SET NOT NULL; + +DROP INDEX IF EXISTS integration_tokens_org_provider_type_name_uidx; + +CREATE UNIQUE INDEX IF NOT EXISTS integration_tokens_org_provider_type_natural_key_uidx + ON integration_tokens (organization_id, provider, credential_type, natural_key); diff --git a/backend/drizzle/0030_add_security_ingestion_surfaces.sql b/backend/drizzle/0030_add_security_ingestion_surfaces.sql new file mode 100644 index 000000000..ce3b477fc --- /dev/null +++ b/backend/drizzle/0030_add_security_ingestion_surfaces.sql @@ -0,0 +1,255 @@ +CREATE TABLE IF NOT EXISTS security_scanner_runs ( + id TEXT PRIMARY KEY, + organization_id VARCHAR(255) NOT NULL, + source_kind VARCHAR(64) NOT NULL, + source_version VARCHAR(128), + scope JSONB NOT NULL DEFAULT '{}'::jsonb, + trigger_kind VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL, + started_at TIMESTAMPTZ NOT NULL, + completed_at TIMESTAMPTZ, + parent_run_id TEXT, + artifact_refs JSONB NOT NULL DEFAULT '[]'::jsonb, + config_hash VARCHAR(128), + workflow_id TEXT, + run_id TEXT, + node_ref TEXT, + created_by TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS security_scanner_runs_org_source_started_idx + ON security_scanner_runs (organization_id, source_kind, started_at); +CREATE INDEX IF NOT EXISTS security_scanner_runs_org_run_idx + ON security_scanner_runs (organization_id, run_id); +CREATE INDEX IF NOT EXISTS security_scanner_runs_org_status_idx + ON security_scanner_runs (organization_id, status); + +CREATE TABLE IF NOT EXISTS security_ingestion_batches ( + id TEXT PRIMARY KEY, + organization_id VARCHAR(255) NOT NULL, + source_kind VARCHAR(64) NOT NULL, + scanner_run_id TEXT NOT NULL REFERENCES security_scanner_runs(id) ON DELETE CASCADE, + stats JSONB NOT NULL DEFAULT '{}'::jsonb, + validation JSONB NOT NULL DEFAULT '{}'::jsonb, + raw_batch JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL, + ingested_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS security_ingestion_batches_org_created_idx + ON security_ingestion_batches (organization_id, created_at); +CREATE INDEX IF NOT EXISTS security_ingestion_batches_scanner_run_idx + ON security_ingestion_batches (scanner_run_id); + +CREATE TABLE IF NOT EXISTS security_assets ( + id TEXT PRIMARY KEY, + organization_id VARCHAR(255) NOT NULL, + asset_kind VARCHAR(64) NOT NULL, + provider VARCHAR(64), + native_id TEXT, + stable_key TEXT NOT NULL, + display_name TEXT NOT NULL, + aliases JSONB NOT NULL DEFAULT '[]'::jsonb, + confidence VARCHAR(32) NOT NULL, + owner_ref TEXT, + environment VARCHAR(64), + criticality VARCHAR(32) NOT NULL DEFAULT 'unknown', + first_seen_at TIMESTAMPTZ, + last_seen_at TIMESTAMPTZ, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS security_assets_org_stable_key_uidx + ON security_assets (organization_id, stable_key); +CREATE INDEX IF NOT EXISTS security_assets_org_kind_idx + ON security_assets (organization_id, asset_kind); +CREATE INDEX IF NOT EXISTS security_assets_org_provider_idx + ON security_assets (organization_id, provider); + +CREATE TABLE IF NOT EXISTS security_asset_aliases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id VARCHAR(255) NOT NULL, + asset_id TEXT NOT NULL REFERENCES security_assets(id) ON DELETE CASCADE, + alias_type VARCHAR(64) NOT NULL DEFAULT 'native', + alias_value TEXT NOT NULL, + source_kind VARCHAR(64), + confidence VARCHAR(32) NOT NULL DEFAULT 'deterministic', + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS security_asset_aliases_org_alias_uidx + ON security_asset_aliases (organization_id, alias_type, alias_value); +CREATE INDEX IF NOT EXISTS security_asset_aliases_asset_idx + ON security_asset_aliases (asset_id); + +CREATE TABLE IF NOT EXISTS security_evidence_items ( + id TEXT PRIMARY KEY, + organization_id VARCHAR(255) NOT NULL, + kind VARCHAR(64) NOT NULL, + source_kind VARCHAR(64), + scanner_run_id TEXT REFERENCES security_scanner_runs(id) ON DELETE SET NULL, + artifact_uri TEXT NOT NULL, + summary TEXT, + hash VARCHAR(128), + captured_at TIMESTAMPTZ NOT NULL, + retention_class VARCHAR(32) NOT NULL DEFAULT 'standard', + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS security_evidence_items_org_kind_captured_idx + ON security_evidence_items (organization_id, kind, captured_at); +CREATE INDEX IF NOT EXISTS security_evidence_items_scanner_run_idx + ON security_evidence_items (scanner_run_id); + +CREATE TABLE IF NOT EXISTS security_observations ( + id TEXT PRIMARY KEY, + organization_id VARCHAR(255) NOT NULL, + scanner_run_id TEXT NOT NULL REFERENCES security_scanner_runs(id) ON DELETE CASCADE, + source_kind VARCHAR(64) NOT NULL, + source_rule_id TEXT, + asset_id TEXT REFERENCES security_assets(id) ON DELETE SET NULL, + status VARCHAR(64), + severity VARCHAR(32) NOT NULL DEFAULT 'unknown', + title TEXT NOT NULL, + raw_artifact_ref JSONB, + evidence_refs JSONB NOT NULL DEFAULT '[]'::jsonb, + observed_at TIMESTAMPTZ NOT NULL, + normalized JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS security_observations_org_observed_idx + ON security_observations (organization_id, observed_at); +CREATE INDEX IF NOT EXISTS security_observations_org_rule_idx + ON security_observations (organization_id, source_kind, source_rule_id); +CREATE INDEX IF NOT EXISTS security_observations_asset_idx + ON security_observations (asset_id); +CREATE INDEX IF NOT EXISTS security_observations_scanner_run_idx + ON security_observations (scanner_run_id); + +CREATE TABLE IF NOT EXISTS security_finding_instances ( + id TEXT PRIMARY KEY, + organization_id VARCHAR(255) NOT NULL, + scanner_run_id TEXT NOT NULL REFERENCES security_scanner_runs(id) ON DELETE CASCADE, + asset_id TEXT REFERENCES security_assets(id) ON DELETE SET NULL, + finding_key TEXT NOT NULL, + status VARCHAR(32) NOT NULL, + severity VARCHAR(32) NOT NULL, + confidence REAL NOT NULL DEFAULT 1, + first_seen_at TIMESTAMPTZ NOT NULL, + last_seen_at TIMESTAMPTZ NOT NULL, + observation_refs JSONB NOT NULL DEFAULT '[]'::jsonb, + evidence_refs JSONB NOT NULL DEFAULT '[]'::jsonb, + suppression_ref TEXT, + accepted_risk_ref TEXT, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS security_finding_instances_org_key_uidx + ON security_finding_instances (organization_id, finding_key); +CREATE INDEX IF NOT EXISTS security_findings_org_severity_status_idx + ON security_finding_instances (organization_id, severity, status); +CREATE INDEX IF NOT EXISTS security_finding_instances_asset_idx + ON security_finding_instances (asset_id); +CREATE INDEX IF NOT EXISTS security_finding_instances_scanner_run_idx + ON security_finding_instances (scanner_run_id); + +CREATE TABLE IF NOT EXISTS security_control_mappings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id VARCHAR(255), + source_kind VARCHAR(64) NOT NULL, + provider VARCHAR(64), + source_rule_id TEXT NOT NULL, + framework_key VARCHAR(64) NOT NULL, + control_id VARCHAR(128) NOT NULL, + control_title TEXT, + mapping_source VARCHAR(64) NOT NULL DEFAULT 'scanner', + confidence REAL NOT NULL DEFAULT 0.8, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS security_control_mappings_source_control_uidx + ON security_control_mappings ( + organization_id, + source_kind, + provider, + source_rule_id, + framework_key, + control_id + ); +CREATE INDEX IF NOT EXISTS security_control_mappings_framework_control_idx + ON security_control_mappings (framework_key, control_id); + +CREATE TABLE IF NOT EXISTS security_control_results ( + id TEXT PRIMARY KEY, + organization_id VARCHAR(255) NOT NULL, + framework_key VARCHAR(64) NOT NULL, + control_id VARCHAR(128) NOT NULL, + scope_ref TEXT NOT NULL, + status VARCHAR(32) NOT NULL, + reason TEXT, + observation_id TEXT REFERENCES security_observations(id) ON DELETE SET NULL, + finding_refs JSONB NOT NULL DEFAULT '[]'::jsonb, + evidence_refs JSONB NOT NULL DEFAULT '[]'::jsonb, + waiver_ref TEXT, + evaluated_at TIMESTAMPTZ NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS security_control_results_org_framework_status_idx + ON security_control_results (organization_id, framework_key, status); +CREATE INDEX IF NOT EXISTS security_control_results_org_control_scope_idx + ON security_control_results (organization_id, framework_key, control_id, scope_ref); +CREATE INDEX IF NOT EXISTS security_control_results_observation_idx + ON security_control_results (observation_id); + +CREATE TABLE IF NOT EXISTS security_finding_control_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id VARCHAR(255) NOT NULL, + finding_id TEXT NOT NULL REFERENCES security_finding_instances(id) ON DELETE CASCADE, + control_result_id TEXT REFERENCES security_control_results(id) ON DELETE CASCADE, + framework_key VARCHAR(64) NOT NULL, + control_id VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'fail', + evidence_refs JSONB NOT NULL DEFAULT '[]'::jsonb, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS security_finding_control_links_finding_control_uidx + ON security_finding_control_links (finding_id, framework_key, control_id); +CREATE INDEX IF NOT EXISTS security_finding_control_links_org_control_idx + ON security_finding_control_links (organization_id, framework_key, control_id); + +CREATE TABLE IF NOT EXISTS security_graph_edges ( + id TEXT PRIMARY KEY, + organization_id VARCHAR(255) NOT NULL, + edge_kind VARCHAR(64) NOT NULL, + from_node_ref TEXT NOT NULL, + to_node_ref TEXT NOT NULL, + confidence REAL NOT NULL DEFAULT 1, + evidence_refs JSONB NOT NULL DEFAULT '[]'::jsonb, + properties JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS security_graph_edges_org_kind_idx + ON security_graph_edges (organization_id, edge_kind); +CREATE INDEX IF NOT EXISTS security_graph_edges_org_from_idx + ON security_graph_edges (organization_id, from_node_ref); +CREATE INDEX IF NOT EXISTS security_graph_edges_org_to_idx + ON security_graph_edges (organization_id, to_node_ref); diff --git a/backend/drizzle/0031_add_security_finding_links.sql b/backend/drizzle/0031_add_security_finding_links.sql new file mode 100644 index 000000000..423758ebd --- /dev/null +++ b/backend/drizzle/0031_add_security_finding_links.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS security_finding_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id VARCHAR(255) NOT NULL, + from_finding_id TEXT NOT NULL REFERENCES security_finding_instances(id) ON DELETE CASCADE, + to_finding_id TEXT NOT NULL REFERENCES security_finding_instances(id) ON DELETE CASCADE, + relationship_type VARCHAR(64) NOT NULL, + confidence REAL NOT NULL DEFAULT 0.7, + rationale TEXT NOT NULL, + evidence_refs JSONB NOT NULL DEFAULT '[]'::jsonb, + created_by_agent_run_id TEXT, + created_by TEXT, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS security_finding_links_org_from_idx + ON security_finding_links (organization_id, from_finding_id); +CREATE INDEX IF NOT EXISTS security_finding_links_org_to_idx + ON security_finding_links (organization_id, to_finding_id); +CREATE UNIQUE INDEX IF NOT EXISTS security_finding_links_org_pair_type_uidx + ON security_finding_links ( + organization_id, + from_finding_id, + to_finding_id, + relationship_type + ); diff --git a/backend/drizzle/0032_add_security_threat_stories.sql b/backend/drizzle/0032_add_security_threat_stories.sql new file mode 100644 index 000000000..560cc5c3f --- /dev/null +++ b/backend/drizzle/0032_add_security_threat_stories.sql @@ -0,0 +1,45 @@ +CREATE TABLE IF NOT EXISTS security_threat_stories ( + id TEXT PRIMARY KEY, + organization_id VARCHAR(255) NOT NULL, + story_key TEXT NOT NULL, + title TEXT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'open', + severity VARCHAR(32) NOT NULL, + confidence REAL NOT NULL DEFAULT 0.7, + summary TEXT NOT NULL, + impact TEXT NOT NULL, + attack_path TEXT, + recommended_action TEXT, + affected_asset_refs JSONB NOT NULL DEFAULT '[]'::jsonb, + evidence_refs JSONB NOT NULL DEFAULT '[]'::jsonb, + created_by_agent_run_id TEXT, + created_by TEXT, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + first_seen_at TIMESTAMPTZ NOT NULL, + last_seen_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS security_threat_stories_org_key_uidx + ON security_threat_stories (organization_id, story_key); +CREATE INDEX IF NOT EXISTS security_threat_stories_org_severity_status_idx + ON security_threat_stories (organization_id, severity, status); +CREATE INDEX IF NOT EXISTS security_threat_stories_org_updated_idx + ON security_threat_stories (organization_id, updated_at); + +CREATE TABLE IF NOT EXISTS security_threat_story_findings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id VARCHAR(255) NOT NULL, + story_id TEXT NOT NULL REFERENCES security_threat_stories(id) ON DELETE CASCADE, + finding_id TEXT NOT NULL REFERENCES security_finding_instances(id) ON DELETE CASCADE, + role VARCHAR(64) NOT NULL DEFAULT 'supporting', + rationale TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS security_threat_story_findings_story_finding_uidx + ON security_threat_story_findings (story_id, finding_id); +CREATE INDEX IF NOT EXISTS security_threat_story_findings_org_finding_idx + ON security_threat_story_findings (organization_id, finding_id); diff --git a/backend/drizzle/0033_add_agent_skills.sql b/backend/drizzle/0033_add_agent_skills.sql new file mode 100644 index 000000000..ed8409d96 --- /dev/null +++ b/backend/drizzle/0033_add_agent_skills.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS "agent_skills" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" varchar(191), + "name" varchar(64) NOT NULL, + "description" text NOT NULL, + "content" text NOT NULL, + "license" varchar(191), + "compatibility" varchar(500), + "metadata" jsonb DEFAULT NULL, + "allowed_tools" varchar(1000), + "is_enabled" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE INDEX IF NOT EXISTS "agent_skills_org_idx" ON "agent_skills" ("organization_id"); +CREATE INDEX IF NOT EXISTS "agent_skills_enabled_idx" ON "agent_skills" ("is_enabled"); +CREATE UNIQUE INDEX IF NOT EXISTS "agent_skills_name_org_uidx" ON "agent_skills" ("name", "organization_id"); diff --git a/backend/drizzle/0034_add_security_wikis.sql b/backend/drizzle/0034_add_security_wikis.sql new file mode 100644 index 000000000..cbfbc2c62 --- /dev/null +++ b/backend/drizzle/0034_add_security_wikis.sql @@ -0,0 +1,54 @@ +CREATE TABLE IF NOT EXISTS "security_wiki_configs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" varchar(191) NOT NULL, + "provider" varchar(32) DEFAULT 'local' NOT NULL, + "repo_owner" varchar(191), + "repo_name" varchar(191) NOT NULL, + "repo_url" text, + "local_path" text, + "default_branch" varchar(191) DEFAULT 'main' NOT NULL, + "root_path" text DEFAULT '/' NOT NULL, + "status" varchar(32) DEFAULT 'active' NOT NULL, + "path_policy" jsonb DEFAULT '{}'::jsonb NOT NULL, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS "security_wiki_configs_org_active_uidx" + ON "security_wiki_configs" ("organization_id", "status"); + +CREATE INDEX IF NOT EXISTS "security_wiki_configs_org_idx" + ON "security_wiki_configs" ("organization_id"); + +CREATE TABLE IF NOT EXISTS "security_wiki_runs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" varchar(191) NOT NULL, + "wiki_config_id" uuid NOT NULL REFERENCES "security_wiki_configs"("id") ON DELETE cascade, + "run_id" varchar(191) NOT NULL, + "agent_name" varchar(191) NOT NULL, + "repo_slug" varchar(255) NOT NULL, + "repo_path" text, + "repo_commit" varchar(128), + "model" varchar(191), + "branch_name" varchar(255), + "wiki_base_commit" varchar(128), + "output_commit" varchar(128), + "merge_commit" varchar(128), + "status" varchar(32) DEFAULT 'queued' NOT NULL, + "changed_files" jsonb DEFAULT '[]'::jsonb NOT NULL, + "validation" jsonb DEFAULT '{}'::jsonb NOT NULL, + "error" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "started_at" timestamp with time zone, + "completed_at" timestamp with time zone +); + +CREATE UNIQUE INDEX IF NOT EXISTS "security_wiki_runs_org_run_uidx" + ON "security_wiki_runs" ("organization_id", "run_id"); + +CREATE INDEX IF NOT EXISTS "security_wiki_runs_org_created_idx" + ON "security_wiki_runs" ("organization_id", "created_at"); + +CREATE INDEX IF NOT EXISTS "security_wiki_runs_config_idx" + ON "security_wiki_runs" ("wiki_config_id"); diff --git a/backend/drizzle/0035_agent_skill_bundle_files.sql b/backend/drizzle/0035_agent_skill_bundle_files.sql new file mode 100644 index 000000000..e41852d87 --- /dev/null +++ b/backend/drizzle/0035_agent_skill_bundle_files.sql @@ -0,0 +1,2 @@ +ALTER TABLE agent_skills + ADD COLUMN IF NOT EXISTS files JSONB NOT NULL DEFAULT '[]'::jsonb; diff --git a/backend/drizzle/0036_seed_security_agent_skills.sql b/backend/drizzle/0036_seed_security_agent_skills.sql new file mode 100644 index 000000000..a2a6daa7d --- /dev/null +++ b/backend/drizzle/0036_seed_security_agent_skills.sql @@ -0,0 +1,38 @@ +INSERT INTO agent_skills (organization_id, name, description, content, files, allowed_tools, is_enabled) +SELECT + NULL, + 'security-wiki-maintainer', + 'Maintain a persistent security wiki that future agents can read or edit as durable product memory.', + 'Use this skill whenever an agent is asked to create, update, review, or rely on a security wiki. The wiki is persistent compounding security memory for an engineering organization. It is not a one-time scanner report, a raw file index, or a source dump. Raw repositories, scanner outputs, cloud inventories, tickets, traces, and human notes are evidence. The wiki is the maintained synthesis future agents read first so they do not rediscover the same product facts from scratch. In a given run, the wiki may be mounted read-only, mounted read-write, represented as markdown files in the workspace, or exposed through tools. Detect what access you have before acting. If read-only, use it as context and report proposed edits. If writable, update files directly. If Git tools are available, keep changes reviewable as normal file edits or commits according to the task. Prefer product concepts over repository silos: services, trust boundaries, deployment lineage, data flows, identities, sensitive resources, external integrations, security hotspots, stale claims, contradictions, and open questions. Always preserve evidence paths/resource IDs and write missing bridges explicitly instead of guessing.', + '[{"path":"README.md","content":"# Security Wiki Maintainer\n\nThis skill teaches an agent how to maintain a durable security wiki.\n\n## Mental Model\n\nThe wiki is the organization memory layer. It should compound across runs. Future isolated agents should be able to read it first and quickly answer: what product exists, what is exposed, what identity it runs as, what data it can reach, which findings matter, which scanner findings are noise, and what evidence is missing.\n\n## Access Modes\n\nThe current run may provide the wiki in one of several ways:\n\n- Read-only markdown directory: read it before analysis and return proposed edits.\n- Writable markdown directory: edit or create pages directly.\n- Git checkout: edit files in-place; if asked, commit or prepare a PR using available Git/GitHub tools.\n- API/tool access: use the available tools to read, write, list, or persist wiki pages.\n- No wiki mounted: produce a clean initial wiki structure as output and state that no existing wiki was available.\n\nNever assume write access. Check the workspace and tools first.\n\n## Operating Rules\n\n- Read existing wiki pages first when available.\n- Update product-level pages before repo-level pages.\n- Convert raw facts into reusable security knowledge.\n- Cite evidence with file paths, resource IDs, run IDs, or tool outputs.\n- Preserve uncertainty and stale/conflicting evidence.\n- Keep pages concise and navigable.\n- Prefer stable page names and sections over ad hoc one-off reports.\n"},{"path":"templates/PRODUCT/overview.md","content":"# Product Overview\n\n## What The Product Does\n\n## Users And Tenants\n\n## Major Capabilities\n\n## Security-Relevant Architecture\n\n## Evidence\n\n## Open Questions\n"},{"path":"templates/PRODUCT/services.md","content":"# Services\n\n| Service | Purpose | Repo | Entrypoints | Dependencies | Runtime | Evidence |\n| --- | --- | --- | --- | --- | --- | --- |\n\n## Service Boundaries\n\n## Trust Assumptions\n\n## Open Questions\n"},{"path":"templates/PRODUCT/deployment-lineage.md","content":"# Deployment Lineage\n\nTrack repo -> build -> image/artifact -> deployment -> workload -> endpoint -> identity -> data/resource.\n\n## Proven Lineage\n\n## Partial Lineage\n\n## Missing Bridges\n\n## Evidence\n"},{"path":"templates/PRODUCT/auth-and-tenancy.md","content":"# Auth And Tenancy\n\n## Identities\n\n## Tenant Model\n\n## Authn/Authz Enforcement\n\n## Internal Trust Boundaries\n\n## Known Gaps / Questions\n"},{"path":"templates/PRODUCT/data-and-secrets.md","content":"# Data And Secrets\n\n## Data Stores\n\n## Secret Stores And Key Material\n\n## Sensitive Flows\n\n## Runtime Access Paths\n\n## Evidence\n"},{"path":"templates/PRODUCT/external-integrations.md","content":"# External Integrations\n\n## Providers\n\n## Credentials And Scopes\n\n## Webhooks / Callbacks\n\n## Data Shared\n\n## Evidence\n"},{"path":"templates/PRODUCT/security-hotspots.md","content":"# Security Hotspots\n\n## Highest-Risk Code Paths\n\n## Highest-Risk Runtime Surfaces\n\n## Scanner Findings That Need Product Context\n\n## Agent Review Priorities\n"}]'::jsonb, + NULL, + true +WHERE NOT EXISTS ( + SELECT 1 FROM agent_skills WHERE organization_id IS NULL AND name = 'security-wiki-maintainer' +); + +INSERT INTO agent_skills (organization_id, name, description, content, files, allowed_tools, is_enabled) +SELECT + NULL, + 'multi-repo-security-review', + 'Reason across cooperating repositories before producing code security findings.', + 'Use this skill when more than one repository is mounted. Start by mapping repository responsibilities, shared auth boundaries, shared secrets/config, API contracts, webhook flows, package/deployment coupling, and trust assumptions. Only then search for concrete exploitable findings. Prefer issues that require understanding more than one repo. Return findings in the workflow-requested JSON shape and include repo names in evidence.', + '[{"path":"checklists/cross-repo-review.md","content":"# Cross-Repo Security Review Checklist\n\n- Identify every mounted repository and its role.\n- Map inbound HTTP/API/webhook entrypoints per repo.\n- Map auth/session/token handling per repo.\n- Map shared env vars, secrets, encryption keys, and internal tokens.\n- Map deployment boundaries and which repo owns public surfaces.\n- Look for mismatched assumptions between caller and callee repos.\n- Prove findings with file paths and concrete code facts."}]'::jsonb, + NULL, + true +WHERE NOT EXISTS ( + SELECT 1 FROM agent_skills WHERE organization_id IS NULL AND name = 'multi-repo-security-review' +); + +INSERT INTO agent_skills (organization_id, name, description, content, files, allowed_tools, is_enabled) +SELECT + NULL, + 'security-wiki-code-ingestion', + 'Build thorough file-first CODE wiki pages that later agents can mount as product context.', + 'Use this skill for security wiki ingestion. Pair it with the security-wiki-maintainer skill. Ingestion should read mounted repositories and any mounted/current wiki, infer the product and platform shape, and produce or update concise markdown pages with evidence paths. Do not stop at repo inventory; convert source facts into durable security knowledge future agents can reuse.', + '[{"path":"templates/CODE-service-map.md","content":"# Service Map\n\n## Purpose\n\n## Entry Points\n\n## Auth And Trust Boundaries\n\n## Data Stores\n\n## Deployments\n\n## Security Hotspots\n"},{"path":"templates/CODE-auth-map.md","content":"# Auth Map\n\n## Identities\n\n## Tokens / Sessions\n\n## Authorization Checks\n\n## Internal Trust\n\n## Known Gaps / Questions\n"}]'::jsonb, + NULL, + true +WHERE NOT EXISTS ( + SELECT 1 FROM agent_skills WHERE organization_id IS NULL AND name = 'security-wiki-code-ingestion' +); diff --git a/backend/drizzle/0037_add_security_finding_assessments.sql b/backend/drizzle/0037_add_security_finding_assessments.sql new file mode 100644 index 000000000..fce3231eb --- /dev/null +++ b/backend/drizzle/0037_add_security_finding_assessments.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS security_finding_assessments ( + id TEXT PRIMARY KEY, + organization_id VARCHAR(255) NOT NULL, + finding_id TEXT NOT NULL REFERENCES security_finding_instances(id) ON DELETE CASCADE, + verdict VARCHAR(64) NOT NULL, + importance VARCHAR(32) NOT NULL, + confidence REAL NOT NULL DEFAULT 0.7, + reasoning_markdown TEXT NOT NULL, + evidence_refs JSONB NOT NULL DEFAULT '[]'::jsonb, + wiki_refs JSONB NOT NULL DEFAULT '[]'::jsonb, + tool_calls_summary JSONB NOT NULL DEFAULT '{}'::jsonb, + created_by_agent_run_id TEXT, + created_by TEXT, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS security_finding_assessments_org_finding_created_idx + ON security_finding_assessments (organization_id, finding_id, created_at DESC); +CREATE INDEX IF NOT EXISTS security_finding_assessments_org_verdict_idx + ON security_finding_assessments (organization_id, verdict, importance); +CREATE INDEX IF NOT EXISTS security_finding_assessments_agent_run_idx + ON security_finding_assessments (created_by_agent_run_id); diff --git a/backend/drizzle/0038_seed_finding_assessment_skill.sql b/backend/drizzle/0038_seed_finding_assessment_skill.sql new file mode 100644 index 000000000..dcbd0e69f --- /dev/null +++ b/backend/drizzle/0038_seed_finding_assessment_skill.sql @@ -0,0 +1,12 @@ +INSERT INTO agent_skills (organization_id, name, description, content, files, allowed_tools, is_enabled) +SELECT + NULL, + 'finding-assessment-triage', + 'Assess scanner findings against product context so agents can hide noise and preserve durable markdown reasoning.', + 'Use this skill when reviewing durable scanner findings before they are shown to users. Treat scanner output as evidence, not product truth. Fetch the current open findings, inspect the mix of source kinds and repeated patterns, then decide your own batching and parallelization strategy. Use subagents or parallel workers when useful, but reconcile final verdicts yourself. Do not stop while there are currently open findings in scope without a proper markdown assessment note. For each finding, decide whether it is actionable, not relevant, false positive, accepted risk, needs context, or resolved candidate. Write concise markdown reasoning that explains why the issue should remain visible or be hidden. Be aggressive about suppressing findings for unused providers, managed platform/system resources, or generic benchmark checks with no product impact. Preserve raw evidence and uncertainty.', + '[{"path":"README.md","content":"# Finding Assessment Triage\n\n## Purpose\n\nTurn raw scanner findings into product judgment before the human sees them.\n\n## Verdicts\n\n| Verdict | Meaning |\n| --- | --- |\n| actionable | Real, product-relevant, and should be shown. |\n| not_relevant | Real scanner condition but outside current product/runtime/scope. |\n| false_positive | Scanner claim appears wrong. |\n| accepted_risk | Real and relevant, but intentionally tolerated. |\n| needs_context | Missing a specific bridge or fact needed to decide. |\n| resolved_candidate | Evidence suggests this may be fixed, pending validation. |\n\n## Rules\n\n- Read the latest finding and prior assessments first.\n- Use product/wiki/runtime context to suppress scanner noise.\n- Do not create threat stories from noise.\n- Cite evidence refs, resource IDs, wiki paths, run IDs, and missing bridges.\n- Keep reasoning markdown durable and useful for a future agent.\n- Prefer `noise` importance for not_relevant and false_positive.\n"},{"path":"checklists/cloud-noise.md","content":"# Cloud Scanner Noise Checklist\n\nMark as likely noise when:\n\n- The provider/account is not used by the product runtime.\n- The asset is managed by the cloud provider or platform control plane.\n- The check is a generic benchmark item with no reachable service, identity, data path, or customer impact.\n- The resource is stale, test-only, or outside declared scope.\n\nKeep visible when:\n\n- It touches public ingress, workload identity, secrets, data stores, CI/CD, agent execution, customer data, or production namespaces.\n- It enables privilege escalation, exfiltration, tenant bypass, public exposure, or blast radius expansion.\n- The wiki or inventory says this resource is product-owned or on a deployment path.\n"}]'::jsonb, + NULL, + true +WHERE NOT EXISTS ( + SELECT 1 FROM agent_skills WHERE organization_id IS NULL AND name = 'finding-assessment-triage' +); diff --git a/backend/drizzle/meta/0000_snapshot.json b/backend/drizzle/meta/0000_snapshot.json index 5c3cf27fa..ed0da8eb9 100644 --- a/backend/drizzle/meta/0000_snapshot.json +++ b/backend/drizzle/meta/0000_snapshot.json @@ -1,70 +1,5219 @@ { - "id": "ba7eaa54-002b-460f-9ec1-ba9b39f9afa8", + "id": "ddadc481-6442-4a79-bcc2-fcf9ffd2df4d", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", "tables": { + "public.agent_trace_events": { + "name": "agent_trace_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_run_id": { + "name": "workflow_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_ref": { + "name": "node_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "part_type": { + "name": "part_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_trace_events_run_idx": { + "name": "agent_trace_events_run_idx", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_trace_events_workflow_idx": { + "name": "agent_trace_events_workflow_idx", + "columns": [ + { + "expression": "workflow_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "key_hint": { + "name": "key_hint", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rate_limit": { + "name": "rate_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_keys_org_idx": { + "name": "api_keys_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_active_idx": { + "name": "api_keys_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_created_by_idx": { + "name": "api_keys_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_hash_idx": { + "name": "api_keys_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": ["key_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifacts": { + "name": "artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_version_id": { + "name": "workflow_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "component_id": { + "name": "component_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "component_ref": { + "name": "component_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "destinations": { + "name": "destinations", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"run\"]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "artifacts_run_idx": { + "name": "artifacts_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "artifacts_file_id_files_id_fk": { + "name": "artifacts_file_id_files_id_fk", + "tableFrom": "artifacts", + "tableTo": "files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asm_assets": { + "name": "asm_assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "domain_id": { + "name": "domain_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "ip_addresses": { + "name": "ip_addresses", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tech_stack": { + "name": "tech_stack", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "ports": { + "name": "ports", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "risk_score": { + "name": "risk_score", + "type": "numeric(4, 1)", + "primaryKey": false, + "notNull": false + }, + "dns_records": { + "name": "dns_records", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tls_info": { + "name": "tls_info", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "first_seen": { + "name": "first_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "asm_assets_domain_id_asm_domains_id_fk": { + "name": "asm_assets_domain_id_asm_domains_id_fk", + "tableFrom": "asm_assets", + "tableTo": "asm_domains", + "columnsFrom": ["domain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "asm_assets_domain_id_hostname_unique": { + "name": "asm_assets_domain_id_hostname_unique", + "nullsNotDistinct": false, + "columns": ["domain_id", "hostname"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asm_domains": { + "name": "asm_domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_include": { + "name": "scope_include", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scope_exclude": { + "name": "scope_exclude", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "monitoring_enabled": { + "name": "monitoring_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "frequency": { + "name": "frequency", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "last_scan_at": { + "name": "last_scan_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asm_domains_org_domain_unique": { + "name": "asm_domains_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "deleted_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset_scan_status": { + "name": "asset_scan_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scan_type": { + "name": "scan_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "last_scanned_at": { + "name": "last_scanned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_run_id": { + "name": "last_run_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "finding_count": { + "name": "finding_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "critical_count": { + "name": "critical_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "high_count": { + "name": "high_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "default": "'unscanned'" + } + }, + "indexes": { + "asset_scan_status_asset_scan_type_uidx": { + "name": "asset_scan_status_asset_scan_type_uidx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scan_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_scan_status_asset_id_assets_id_fk": { + "name": "asset_scan_status_asset_id_assets_id_fk", + "tableFrom": "asset_scan_status", + "tableTo": "assets", + "columnsFrom": ["asset_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset_sync_run_scopes": { + "name": "asset_sync_run_scopes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "sync_run_id": { + "name": "sync_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_type": { + "name": "asset_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "region": { + "name": "region", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "asset_sync_run_scopes_run_type_region_uidx": { + "name": "asset_sync_run_scopes_run_type_region_uidx", + "columns": [ + { + "expression": "sync_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "asset_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "region", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_sync_run_scopes_sync_run_idx": { + "name": "asset_sync_run_scopes_sync_run_idx", + "columns": [ + { + "expression": "sync_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_sync_run_scopes_sync_run_id_asset_sync_runs_id_fk": { + "name": "asset_sync_run_scopes_sync_run_id_asset_sync_runs_id_fk", + "tableFrom": "asset_sync_run_scopes", + "tableTo": "asset_sync_runs", + "columnsFrom": ["sync_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset_sync_runs": { + "name": "asset_sync_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "integration_id": { + "name": "integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_identifier": { + "name": "account_identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "temporal_workflow_id": { + "name": "temporal_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "temporal_run_id": { + "name": "temporal_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "covered_regions": { + "name": "covered_regions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "assets_discovered": { + "name": "assets_discovered", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "assets_created": { + "name": "assets_created", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "assets_updated": { + "name": "assets_updated", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "assets_stale": { + "name": "assets_stale", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asset_sync_runs_org_integration_created_idx": { + "name": "asset_sync_runs_org_integration_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_sync_runs_org_account_created_idx": { + "name": "asset_sync_runs_org_account_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "integration_id": { + "name": "integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "account_identifier": { + "name": "account_identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "asset_type": { + "name": "asset_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "first_discovered_at": { + "name": "first_discovered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_org_provider_type_external_uidx": { + "name": "assets_org_provider_type_external_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "asset_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_org_asset_type_idx": { + "name": "assets_org_asset_type_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "asset_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_org_integration_idx": { + "name": "assets_org_integration_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_status_last_seen_idx": { + "name": "assets_status_last_seen_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "actor_type": { + "name": "actor_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "actor_display": { + "name": "actor_display", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "ip": { + "name": "ip", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_org_created_at_idx": { + "name": "audit_logs_org_created_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_org_resource_idx": { + "name": "audit_logs_org_resource_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_org_action_created_at_idx": { + "name": "audit_logs_org_action_created_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_org_actor_created_at_idx": { + "name": "audit_logs_org_actor_created_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_messages": { + "name": "chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parts": { + "name": "parts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_messages_session_created_idx": { + "name": "chat_messages_session_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_messages_session_id_chat_sessions_id_fk": { + "name": "chat_messages_session_id_chat_sessions_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_partial_responses": { + "name": "chat_partial_responses", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "parts": { + "name": "parts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_partial_responses_session_id_chat_sessions_id_fk": { + "name": "chat_partial_responses_session_id_chat_sessions_id_fk", + "tableFrom": "chat_partial_responses", + "tableTo": "chat_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_sessions": { + "name": "chat_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New Conversation'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "active_run_id": { + "name": "active_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "next_event_seq": { + "name": "next_event_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "temporal_workflow_id": { + "name": "temporal_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_sessions_user_org_status_idx": { + "name": "chat_sessions_user_org_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_stream_events": { + "name": "chat_stream_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_stream_events_session_run_seq_idx": { + "name": "chat_stream_events_session_run_seq_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_stream_events_session_id_chat_sessions_id_fk": { + "name": "chat_stream_events_session_id_chat_sessions_id_fk", + "tableFrom": "chat_stream_events", + "tableTo": "chat_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.files": { "name": "files", "schema": "", "columns": { "id": { "name": "id", - "type": "uuid", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "file_name": { + "name": "file_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "files_storage_key_unique": { + "name": "files_storage_key_unique", + "nullsNotDistinct": false, + "columns": ["storage_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_app_installations": { + "name": "github_app_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_id": { + "name": "installation_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "account_avatar_url": { + "name": "account_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "repository_selection": { + "name": "repository_selection", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "default": "'selected'" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "installed_by": { + "name": "installed_by", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_installations_installation_id_uidx": { + "name": "github_installations_installation_id_uidx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_installations_org_idx": { + "name": "github_installations_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_installations_account_login_idx": { + "name": "github_installations_account_login_idx", + "columns": [ + { + "expression": "account_login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_repositories": { + "name": "github_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repo_id": { + "name": "repo_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false, + "default": "'main'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "clone_url": { + "name": "clone_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "scans_enabled": { + "name": "scans_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_repos_repo_id_uidx": { + "name": "github_repos_repo_id_uidx", + "columns": [ + { + "expression": "repo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repos_installation_idx": { + "name": "github_repos_installation_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repos_org_idx": { + "name": "github_repos_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repos_full_name_idx": { + "name": "github_repos_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_repositories_installation_id_github_app_installations_id_fk": { + "name": "github_repositories_installation_id_github_app_installations_id_fk", + "tableFrom": "github_repositories", + "tableTo": "github_app_installations", + "columnsFrom": ["installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_scan_results": { + "name": "github_scan_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_run_id": { + "name": "workflow_run_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "source_type": { + "name": "source_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "branch": { + "name": "branch", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "summary": { + "name": "summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"critical\":0,\"high\":0,\"medium\":0,\"low\":0,\"info\":0}'::jsonb" + }, + "findings_count": { + "name": "findings_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "findings": { + "name": "findings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "check_run_id": { + "name": "check_run_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "pr_comment_id": { + "name": "pr_comment_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "pr_review_id": { + "name": "pr_review_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "results_url": { + "name": "results_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_rule_id": { + "name": "trigger_rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_scan_results_repo_idx": { + "name": "github_scan_results_repo_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_scan_results_org_idx": { + "name": "github_scan_results_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_scan_results_pr_idx": { + "name": "github_scan_results_pr_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_scan_results_status_idx": { + "name": "github_scan_results_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_scan_results_workflow_run_idx": { + "name": "github_scan_results_workflow_run_idx", + "columns": [ + { + "expression": "workflow_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_scan_results_trigger_rule_idx": { + "name": "github_scan_results_trigger_rule_idx", + "columns": [ + { + "expression": "trigger_rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_scan_results_repository_id_github_repositories_id_fk": { + "name": "github_scan_results_repository_id_github_repositories_id_fk", + "tableFrom": "github_scan_results", + "tableTo": "github_repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_scan_results_trigger_rule_id_github_trigger_rules_id_fk": { + "name": "github_scan_results_trigger_rule_id_github_trigger_rules_id_fk", + "tableFrom": "github_scan_results", + "tableTo": "github_trigger_rules", + "columnsFrom": ["trigger_rule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_trigger_rules": { + "name": "github_trigger_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_pattern": { + "name": "repository_pattern", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "branches": { + "name": "branches", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "post_pr_comment": { + "name": "post_pr_comment", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "create_check_run": { + "name": "create_check_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "post_pr_review": { + "name": "post_pr_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fail_on": { + "name": "fail_on", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'high'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_trigger_rules_org_idx": { + "name": "github_trigger_rules_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_trigger_rules_event_idx": { + "name": "github_trigger_rules_event_idx", + "columns": [ + { + "expression": "event", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_trigger_rules_enabled_idx": { + "name": "github_trigger_rules_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.human_input_requests": { + "name": "human_input_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "node_ref": { + "name": "node_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "human_input_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "input_type": { + "name": "input_type", + "type": "human_input_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'approval'" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "resolve_token": { + "name": "resolve_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timeout_at": { + "name": "timeout_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "response_data": { + "name": "response_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "responded_at": { + "name": "responded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "responded_by": { + "name": "responded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "human_input_requests_resolve_token_unique": { + "name": "human_input_requests_resolve_token_unique", + "nullsNotDistinct": false, + "columns": ["resolve_token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflows": { + "name": "workflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "graph": { + "name": "graph", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "compiled_definition": { + "name": "compiled_definition", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_color": { + "name": "icon_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run": { + "name": "last_run", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_versions": { + "name": "workflow_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "graph": { + "name": "graph", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "compiled_definition": { + "name": "compiled_definition", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_versions_workflow_version_uidx": { + "name": "workflow_versions_workflow_version_uidx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_traces": { + "name": "workflow_traces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_ref": { + "name": "node_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "output_summary": { + "name": "output_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_traces_run_idx": { + "name": "workflow_traces_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_runs": { + "name": "workflow_runs", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_version_id": { + "name": "workflow_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workflow_version": { + "name": "workflow_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "temporal_run_id": { + "name": "temporal_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_node_ref": { + "name": "parent_node_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_actions": { + "name": "total_actions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "inputs": { + "name": "inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "trigger_source": { + "name": "trigger_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_label": { + "name": "trigger_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Manual run'" + }, + "input_preview": { + "name": "input_preview", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"runtimeInputs\":{},\"nodeOverrides\":{}}'::jsonb" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "close_time": { + "name": "close_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_workflow_runs_workflow_created": { + "name": "idx_workflow_runs_workflow_created", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_runs_org_created": { + "name": "idx_workflow_runs_org_created", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_log_streams": { + "name": "workflow_log_streams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_ref": { + "name": "node_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream": { + "name": "stream", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "first_timestamp": { + "name": "first_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_timestamp": { + "name": "last_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_log_streams_run_node_stream_idx": { + "name": "workflow_log_streams_run_node_stream_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "node_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_log_streams_run_node_stream_uidx": { + "name": "workflow_log_streams_run_node_stream_uidx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "node_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_versions": { + "name": "secret_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret_id": { + "name": "secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_tag": { + "name": "auth_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encryption_key_id": { + "name": "encryption_key_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "secret_versions_secret_id_secrets_id_fk": { + "name": "secret_versions_secret_id_secrets_id_fk", + "tableFrom": "secret_versions", + "tableTo": "secrets", + "columnsFrom": ["secret_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secrets": { + "name": "secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "secrets_name_unique": { + "name": "secrets_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_workflow_links": { + "name": "platform_workflow_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform_agent_id": { + "name": "platform_agent_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "platform_workflow_links_agent_idx": { + "name": "platform_workflow_links_agent_idx", + "columns": [ + { + "expression": "platform_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "platform_workflow_links_org_idx": { + "name": "platform_workflow_links_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "platform_workflow_links_id_pk": { + "name": "platform_workflow_links_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_roles": { + "name": "workflow_roles", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_roles_org_idx": { + "name": "workflow_roles_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_roles_user_idx": { + "name": "workflow_roles_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_roles_workflow_id_workflows_id_fk": { + "name": "workflow_roles_workflow_id_workflows_id_fk", + "tableFrom": "workflow_roles", + "tableTo": "workflows", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workflow_roles_workflow_id_user_id_pk": { + "name": "workflow_roles_workflow_id_user_id_pk", + "columns": ["workflow_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_oauth_states": { + "name": "integration_oauth_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_oauth_states_state_uidx": { + "name": "integration_oauth_states_state_uidx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_provider_configs": { + "name": "integration_provider_configs", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_tokens": { + "name": "integration_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "credential_type": { + "name": "credential_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'oauth'" + }, + "display_name": { + "name": "display_name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "access_token": { + "name": "access_token", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "token_type": { + "name": "token_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "default": "'Bearer'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_validated_at": { + "name": "last_validated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_validation_status": { + "name": "last_validation_status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "last_validation_error": { + "name": "last_validation_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_tokens_user_idx": { + "name": "integration_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_tokens_org_idx": { + "name": "integration_tokens_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_tokens_org_provider_type_name_uidx": { + "name": "integration_tokens_org_provider_type_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedules": { + "name": "workflow_schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_version_id": { + "name": "workflow_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workflow_version": { + "name": "workflow_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "human_label": { + "name": "human_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "overlap_policy": { + "name": "overlap_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'skip'" + }, + "catchup_window_seconds": { + "name": "catchup_window_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "input_payload": { + "name": "input_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"runtimeInputs\":{},\"nodeOverrides\":{}}'::jsonb" + }, + "temporal_schedule_id": { + "name": "temporal_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "temporal_snapshot": { + "name": "temporal_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_configurations": { + "name": "webhook_configurations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_version_id": { + "name": "workflow_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workflow_version": { + "name": "workflow_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_path": { + "name": "webhook_path", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "parsing_script": { + "name": "parsing_script", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expected_inputs": { + "name": "expected_inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_configurations_webhook_path_unique": { + "name": "webhook_configurations_webhook_path_unique", + "nullsNotDistinct": false, + "columns": ["webhook_path"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "webhook_id": { + "name": "webhook_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_run_id": { + "name": "workflow_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'processing'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "parsed_data": { + "name": "parsed_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_deliveries_webhook_id_webhook_configurations_id_fk": { + "name": "webhook_deliveries_webhook_id_webhook_configurations_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_configurations", + "columnsFrom": ["webhook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_terminal_records": { + "name": "workflow_terminal_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_version_id": { + "name": "workflow_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "node_ref": { + "name": "node_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream": { + "name": "stream", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_chunk_index": { + "name": "first_chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_chunk_index": { + "name": "last_chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_group_servers": { + "name": "mcp_group_servers", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recommended": { + "name": "recommended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_selected": { + "name": "default_selected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_group_servers_group_idx": { + "name": "mcp_group_servers_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_group_servers_server_idx": { + "name": "mcp_group_servers_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_group_servers_group_id_mcp_groups_id_fk": { + "name": "mcp_group_servers_group_id_mcp_groups_id_fk", + "tableFrom": "mcp_group_servers", + "tableTo": "mcp_groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_group_servers_server_id_mcp_servers_id_fk": { + "name": "mcp_group_servers_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_group_servers", + "tableTo": "mcp_servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_group_servers_group_id_server_id_pk": { + "name": "mcp_group_servers_group_id_server_id_pk", + "columns": ["group_id", "server_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_groups": { + "name": "mcp_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_contract_name": { + "name": "credential_contract_name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "credential_mapping": { + "name": "credential_mapping", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "default_docker_image": { + "name": "default_docker_image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "template_hash": { + "name": "template_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_groups_slug_idx": { + "name": "mcp_groups_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_groups_enabled_idx": { + "name": "mcp_groups_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_groups_slug_unique": { + "name": "mcp_groups_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_tools": { + "name": "mcp_server_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "discovered_at": { + "name": "discovered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_tools_server_idx": { + "name": "mcp_server_tools_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_tools_server_tool_uidx": { + "name": "mcp_server_tools_server_tool_uidx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_server_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_tools", + "tableTo": "mcp_servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport_type": { + "name": "transport_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "health_check_url": { + "name": "health_check_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health_check": { + "name": "last_health_check", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_health_status": { + "name": "last_health_status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_org_idx": { + "name": "mcp_servers_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_enabled_idx": { + "name": "mcp_servers_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_group_idx": { + "name": "mcp_servers_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_name_org_uidx": { + "name": "mcp_servers_name_org_uidx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_group_id_mcp_groups_id_fk": { + "name": "mcp_servers_group_id_mcp_groups_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "mcp_groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.node_io": { + "name": "node_io", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" + "notNull": true }, - "file_name": { - "name": "file_name", - "type": "varchar(255)", + "run_id": { + "name": "run_id", + "type": "text", "primaryKey": false, "notNull": true }, - "mime_type": { - "name": "mime_type", - "type": "varchar(100)", + "node_ref": { + "name": "node_ref", + "type": "text", "primaryKey": false, "notNull": true }, - "size": { - "name": "size", - "type": "bigint", + "workflow_id": { + "name": "workflow_id", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, - "storage_key": { - "name": "storage_key", - "type": "varchar(500)", + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "component_id": { + "name": "component_id", + "type": "text", "primaryKey": false, "notNull": true }, - "uploaded_at": { - "name": "uploaded_at", - "type": "timestamp", + "inputs": { + "name": "inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "inputs_size": { + "name": "inputs_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "inputs_spilled": { + "name": "inputs_spilled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inputs_storage_ref": { + "name": "inputs_storage_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "outputs_size": { + "name": "outputs_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "outputs_spilled": { + "name": "outputs_spilled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "outputs_storage_ref": { + "name": "outputs_storage_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" } }, - "indexes": {}, + "indexes": { + "node_io_run_node_idx": { + "name": "node_io_run_node_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "node_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "node_io_run_idx": { + "name": "node_io_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "node_io_workflow_idx": { + "name": "node_io_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, "foreignKeys": {}, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "files_storage_key_unique": { - "name": "files_storage_key_unique", - "nullsNotDistinct": false, - "columns": [ - "storage_key" - ] + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_settings": { + "name": "organization_settings", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": true, + "notNull": true + }, + "analytics_retention_days": { + "name": "analytics_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" } }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, - "public.workflows": { - "name": "workflows", + "public.workflow_templates": { + "name": "workflow_templates", "schema": "", "columns": { "id": { @@ -80,24 +5229,107 @@ "primaryKey": false, "notNull": true }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, "description": { "name": "description", "type": "text", "primaryKey": false, "notNull": false }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, "graph": { "name": "graph", "type": "jsonb", "primaryKey": false, "notNull": true }, - "compiled_definition": { - "name": "compiled_definition", - "type": "jsonb", + "icon": { + "name": "icon", + "type": "varchar(50)", "primaryKey": false, - "notNull": false, - "default": "'null'::jsonb" + "notNull": false + }, + "screenshot_url": { + "name": "screenshot_url", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "is_featured": { + "name": "is_featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workflow_templates_slug_unique": { + "name": "workflow_templates_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_settings": { + "name": "user_settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(191)", + "primaryKey": true, + "notNull": true + }, + "aws_external_id": { + "name": "aws_external_id", + "type": "text", + "primaryKey": false, + "notNull": true }, "created_at": { "name": "created_at", @@ -123,7 +5355,18 @@ "isRLSEnabled": false } }, - "enums": {}, + "enums": { + "public.human_input_status": { + "name": "human_input_status", + "schema": "public", + "values": ["pending", "resolved", "expired", "cancelled"] + }, + "public.human_input_type": { + "name": "human_input_type", + "schema": "public", + "values": ["approval", "form", "selection", "review", "acknowledge"] + } + }, "schemas": {}, "sequences": {}, "roles": {}, @@ -134,4 +5377,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/backend/drizzle/meta/0001_snapshot.json b/backend/drizzle/meta/0001_snapshot.json index 551a798bd..12362f8af 100644 --- a/backend/drizzle/meta/0001_snapshot.json +++ b/backend/drizzle/meta/0001_snapshot.json @@ -1,70 +1,5219 @@ { - "id": "bf2302d9-9426-4fa8-9d29-16d8e44c5952", - "prevId": "ba7eaa54-002b-460f-9ec1-ba9b39f9afa8", + "id": "7c5fab9d-7847-4368-93fc-ab6dd94240e5", + "prevId": "ddadc481-6442-4a79-bcc2-fcf9ffd2df4d", "version": "7", "dialect": "postgresql", "tables": { + "public.agent_trace_events": { + "name": "agent_trace_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_run_id": { + "name": "workflow_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_ref": { + "name": "node_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "part_type": { + "name": "part_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_trace_events_run_idx": { + "name": "agent_trace_events_run_idx", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_trace_events_workflow_idx": { + "name": "agent_trace_events_workflow_idx", + "columns": [ + { + "expression": "workflow_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "key_hint": { + "name": "key_hint", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rate_limit": { + "name": "rate_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_keys_org_idx": { + "name": "api_keys_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_active_idx": { + "name": "api_keys_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_created_by_idx": { + "name": "api_keys_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_hash_idx": { + "name": "api_keys_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": ["key_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifacts": { + "name": "artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_version_id": { + "name": "workflow_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "component_id": { + "name": "component_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "component_ref": { + "name": "component_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "destinations": { + "name": "destinations", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"run\"]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "artifacts_run_idx": { + "name": "artifacts_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "artifacts_file_id_files_id_fk": { + "name": "artifacts_file_id_files_id_fk", + "tableFrom": "artifacts", + "tableTo": "files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asm_assets": { + "name": "asm_assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "domain_id": { + "name": "domain_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "ip_addresses": { + "name": "ip_addresses", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tech_stack": { + "name": "tech_stack", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "ports": { + "name": "ports", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "risk_score": { + "name": "risk_score", + "type": "numeric(4, 1)", + "primaryKey": false, + "notNull": false + }, + "dns_records": { + "name": "dns_records", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tls_info": { + "name": "tls_info", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "first_seen": { + "name": "first_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "asm_assets_domain_id_asm_domains_id_fk": { + "name": "asm_assets_domain_id_asm_domains_id_fk", + "tableFrom": "asm_assets", + "tableTo": "asm_domains", + "columnsFrom": ["domain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "asm_assets_domain_id_hostname_unique": { + "name": "asm_assets_domain_id_hostname_unique", + "nullsNotDistinct": false, + "columns": ["domain_id", "hostname"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asm_domains": { + "name": "asm_domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_include": { + "name": "scope_include", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scope_exclude": { + "name": "scope_exclude", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "monitoring_enabled": { + "name": "monitoring_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "frequency": { + "name": "frequency", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "last_scan_at": { + "name": "last_scan_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asm_domains_org_domain_unique": { + "name": "asm_domains_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "deleted_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset_scan_status": { + "name": "asset_scan_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scan_type": { + "name": "scan_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "last_scanned_at": { + "name": "last_scanned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_run_id": { + "name": "last_run_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "finding_count": { + "name": "finding_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "critical_count": { + "name": "critical_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "high_count": { + "name": "high_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "default": "'unscanned'" + } + }, + "indexes": { + "asset_scan_status_asset_scan_type_uidx": { + "name": "asset_scan_status_asset_scan_type_uidx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scan_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_scan_status_asset_id_assets_id_fk": { + "name": "asset_scan_status_asset_id_assets_id_fk", + "tableFrom": "asset_scan_status", + "tableTo": "assets", + "columnsFrom": ["asset_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset_sync_run_scopes": { + "name": "asset_sync_run_scopes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "sync_run_id": { + "name": "sync_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_type": { + "name": "asset_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "region": { + "name": "region", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "asset_sync_run_scopes_run_type_region_uidx": { + "name": "asset_sync_run_scopes_run_type_region_uidx", + "columns": [ + { + "expression": "sync_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "asset_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "region", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_sync_run_scopes_sync_run_idx": { + "name": "asset_sync_run_scopes_sync_run_idx", + "columns": [ + { + "expression": "sync_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_sync_run_scopes_sync_run_id_asset_sync_runs_id_fk": { + "name": "asset_sync_run_scopes_sync_run_id_asset_sync_runs_id_fk", + "tableFrom": "asset_sync_run_scopes", + "tableTo": "asset_sync_runs", + "columnsFrom": ["sync_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset_sync_runs": { + "name": "asset_sync_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "integration_id": { + "name": "integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_identifier": { + "name": "account_identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "temporal_workflow_id": { + "name": "temporal_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "temporal_run_id": { + "name": "temporal_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "covered_regions": { + "name": "covered_regions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "assets_discovered": { + "name": "assets_discovered", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "assets_created": { + "name": "assets_created", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "assets_updated": { + "name": "assets_updated", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "assets_stale": { + "name": "assets_stale", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asset_sync_runs_org_integration_created_idx": { + "name": "asset_sync_runs_org_integration_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_sync_runs_org_account_created_idx": { + "name": "asset_sync_runs_org_account_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "integration_id": { + "name": "integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "account_identifier": { + "name": "account_identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "asset_type": { + "name": "asset_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "first_discovered_at": { + "name": "first_discovered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_org_provider_type_external_uidx": { + "name": "assets_org_provider_type_external_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "asset_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_org_asset_type_idx": { + "name": "assets_org_asset_type_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "asset_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_org_integration_idx": { + "name": "assets_org_integration_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_status_last_seen_idx": { + "name": "assets_status_last_seen_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "actor_type": { + "name": "actor_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "actor_display": { + "name": "actor_display", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "ip": { + "name": "ip", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_org_created_at_idx": { + "name": "audit_logs_org_created_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_org_resource_idx": { + "name": "audit_logs_org_resource_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_org_action_created_at_idx": { + "name": "audit_logs_org_action_created_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_org_actor_created_at_idx": { + "name": "audit_logs_org_actor_created_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_messages": { + "name": "chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parts": { + "name": "parts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_messages_session_created_idx": { + "name": "chat_messages_session_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_messages_session_id_chat_sessions_id_fk": { + "name": "chat_messages_session_id_chat_sessions_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_partial_responses": { + "name": "chat_partial_responses", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "parts": { + "name": "parts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_partial_responses_session_id_chat_sessions_id_fk": { + "name": "chat_partial_responses_session_id_chat_sessions_id_fk", + "tableFrom": "chat_partial_responses", + "tableTo": "chat_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_sessions": { + "name": "chat_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New Conversation'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "active_run_id": { + "name": "active_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "next_event_seq": { + "name": "next_event_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "temporal_workflow_id": { + "name": "temporal_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_sessions_user_org_status_idx": { + "name": "chat_sessions_user_org_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_stream_events": { + "name": "chat_stream_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_stream_events_session_run_seq_idx": { + "name": "chat_stream_events_session_run_seq_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_stream_events_session_id_chat_sessions_id_fk": { + "name": "chat_stream_events_session_id_chat_sessions_id_fk", + "tableFrom": "chat_stream_events", + "tableTo": "chat_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.files": { "name": "files", "schema": "", "columns": { "id": { "name": "id", - "type": "uuid", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "file_name": { + "name": "file_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "files_storage_key_unique": { + "name": "files_storage_key_unique", + "nullsNotDistinct": false, + "columns": ["storage_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_app_installations": { + "name": "github_app_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_id": { + "name": "installation_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "account_avatar_url": { + "name": "account_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "repository_selection": { + "name": "repository_selection", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "default": "'selected'" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "installed_by": { + "name": "installed_by", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_installations_installation_id_uidx": { + "name": "github_installations_installation_id_uidx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_installations_org_idx": { + "name": "github_installations_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_installations_account_login_idx": { + "name": "github_installations_account_login_idx", + "columns": [ + { + "expression": "account_login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_repositories": { + "name": "github_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repo_id": { + "name": "repo_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false, + "default": "'main'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "clone_url": { + "name": "clone_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "scans_enabled": { + "name": "scans_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_repos_repo_id_uidx": { + "name": "github_repos_repo_id_uidx", + "columns": [ + { + "expression": "repo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repos_installation_idx": { + "name": "github_repos_installation_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repos_org_idx": { + "name": "github_repos_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repos_full_name_idx": { + "name": "github_repos_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_repositories_installation_id_github_app_installations_id_fk": { + "name": "github_repositories_installation_id_github_app_installations_id_fk", + "tableFrom": "github_repositories", + "tableTo": "github_app_installations", + "columnsFrom": ["installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_scan_results": { + "name": "github_scan_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_run_id": { + "name": "workflow_run_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "source_type": { + "name": "source_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "branch": { + "name": "branch", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "summary": { + "name": "summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"critical\":0,\"high\":0,\"medium\":0,\"low\":0,\"info\":0}'::jsonb" + }, + "findings_count": { + "name": "findings_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "findings": { + "name": "findings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "check_run_id": { + "name": "check_run_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "pr_comment_id": { + "name": "pr_comment_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "pr_review_id": { + "name": "pr_review_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "results_url": { + "name": "results_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_rule_id": { + "name": "trigger_rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_scan_results_repo_idx": { + "name": "github_scan_results_repo_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_scan_results_org_idx": { + "name": "github_scan_results_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_scan_results_pr_idx": { + "name": "github_scan_results_pr_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_scan_results_status_idx": { + "name": "github_scan_results_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_scan_results_workflow_run_idx": { + "name": "github_scan_results_workflow_run_idx", + "columns": [ + { + "expression": "workflow_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_scan_results_trigger_rule_idx": { + "name": "github_scan_results_trigger_rule_idx", + "columns": [ + { + "expression": "trigger_rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_scan_results_repository_id_github_repositories_id_fk": { + "name": "github_scan_results_repository_id_github_repositories_id_fk", + "tableFrom": "github_scan_results", + "tableTo": "github_repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_scan_results_trigger_rule_id_github_trigger_rules_id_fk": { + "name": "github_scan_results_trigger_rule_id_github_trigger_rules_id_fk", + "tableFrom": "github_scan_results", + "tableTo": "github_trigger_rules", + "columnsFrom": ["trigger_rule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_trigger_rules": { + "name": "github_trigger_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_pattern": { + "name": "repository_pattern", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "branches": { + "name": "branches", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "post_pr_comment": { + "name": "post_pr_comment", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "create_check_run": { + "name": "create_check_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "post_pr_review": { + "name": "post_pr_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fail_on": { + "name": "fail_on", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'high'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_trigger_rules_org_idx": { + "name": "github_trigger_rules_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_trigger_rules_event_idx": { + "name": "github_trigger_rules_event_idx", + "columns": [ + { + "expression": "event", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_trigger_rules_enabled_idx": { + "name": "github_trigger_rules_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.human_input_requests": { + "name": "human_input_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "node_ref": { + "name": "node_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "human_input_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "input_type": { + "name": "input_type", + "type": "human_input_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'approval'" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "resolve_token": { + "name": "resolve_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timeout_at": { + "name": "timeout_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "response_data": { + "name": "response_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "responded_at": { + "name": "responded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "responded_by": { + "name": "responded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "human_input_requests_resolve_token_unique": { + "name": "human_input_requests_resolve_token_unique", + "nullsNotDistinct": false, + "columns": ["resolve_token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflows": { + "name": "workflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "graph": { + "name": "graph", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "compiled_definition": { + "name": "compiled_definition", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_color": { + "name": "icon_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run": { + "name": "last_run", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_versions": { + "name": "workflow_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "graph": { + "name": "graph", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "compiled_definition": { + "name": "compiled_definition", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_versions_workflow_version_uidx": { + "name": "workflow_versions_workflow_version_uidx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_traces": { + "name": "workflow_traces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_ref": { + "name": "node_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "output_summary": { + "name": "output_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_traces_run_idx": { + "name": "workflow_traces_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_runs": { + "name": "workflow_runs", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_version_id": { + "name": "workflow_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workflow_version": { + "name": "workflow_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "temporal_run_id": { + "name": "temporal_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_node_ref": { + "name": "parent_node_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_actions": { + "name": "total_actions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "inputs": { + "name": "inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "trigger_source": { + "name": "trigger_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_label": { + "name": "trigger_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Manual run'" + }, + "input_preview": { + "name": "input_preview", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"runtimeInputs\":{},\"nodeOverrides\":{}}'::jsonb" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "close_time": { + "name": "close_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_workflow_runs_workflow_created": { + "name": "idx_workflow_runs_workflow_created", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_runs_org_created": { + "name": "idx_workflow_runs_org_created", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_log_streams": { + "name": "workflow_log_streams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_ref": { + "name": "node_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream": { + "name": "stream", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "first_timestamp": { + "name": "first_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_timestamp": { + "name": "last_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_log_streams_run_node_stream_idx": { + "name": "workflow_log_streams_run_node_stream_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "node_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_log_streams_run_node_stream_uidx": { + "name": "workflow_log_streams_run_node_stream_uidx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "node_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_versions": { + "name": "secret_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret_id": { + "name": "secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_tag": { + "name": "auth_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encryption_key_id": { + "name": "encryption_key_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "secret_versions_secret_id_secrets_id_fk": { + "name": "secret_versions_secret_id_secrets_id_fk", + "tableFrom": "secret_versions", + "tableTo": "secrets", + "columnsFrom": ["secret_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secrets": { + "name": "secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "secrets_name_unique": { + "name": "secrets_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_workflow_links": { + "name": "platform_workflow_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform_agent_id": { + "name": "platform_agent_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "platform_workflow_links_agent_idx": { + "name": "platform_workflow_links_agent_idx", + "columns": [ + { + "expression": "platform_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "platform_workflow_links_org_idx": { + "name": "platform_workflow_links_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "platform_workflow_links_id_pk": { + "name": "platform_workflow_links_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_roles": { + "name": "workflow_roles", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_roles_org_idx": { + "name": "workflow_roles_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_roles_user_idx": { + "name": "workflow_roles_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_roles_workflow_id_workflows_id_fk": { + "name": "workflow_roles_workflow_id_workflows_id_fk", + "tableFrom": "workflow_roles", + "tableTo": "workflows", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workflow_roles_workflow_id_user_id_pk": { + "name": "workflow_roles_workflow_id_user_id_pk", + "columns": ["workflow_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_oauth_states": { + "name": "integration_oauth_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_oauth_states_state_uidx": { + "name": "integration_oauth_states_state_uidx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_provider_configs": { + "name": "integration_provider_configs", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_tokens": { + "name": "integration_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "credential_type": { + "name": "credential_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'oauth'" + }, + "display_name": { + "name": "display_name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "access_token": { + "name": "access_token", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "token_type": { + "name": "token_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "default": "'Bearer'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_validated_at": { + "name": "last_validated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_validation_status": { + "name": "last_validation_status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "last_validation_error": { + "name": "last_validation_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_tokens_user_idx": { + "name": "integration_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_tokens_org_idx": { + "name": "integration_tokens_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_tokens_org_provider_type_name_uidx": { + "name": "integration_tokens_org_provider_type_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedules": { + "name": "workflow_schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_version_id": { + "name": "workflow_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workflow_version": { + "name": "workflow_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "human_label": { + "name": "human_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "overlap_policy": { + "name": "overlap_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'skip'" + }, + "catchup_window_seconds": { + "name": "catchup_window_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "input_payload": { + "name": "input_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"runtimeInputs\":{},\"nodeOverrides\":{}}'::jsonb" + }, + "temporal_schedule_id": { + "name": "temporal_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "temporal_snapshot": { + "name": "temporal_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_configurations": { + "name": "webhook_configurations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_version_id": { + "name": "workflow_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workflow_version": { + "name": "workflow_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_path": { + "name": "webhook_path", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "parsing_script": { + "name": "parsing_script", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expected_inputs": { + "name": "expected_inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_configurations_webhook_path_unique": { + "name": "webhook_configurations_webhook_path_unique", + "nullsNotDistinct": false, + "columns": ["webhook_path"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "webhook_id": { + "name": "webhook_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_run_id": { + "name": "workflow_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'processing'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "parsed_data": { + "name": "parsed_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_deliveries_webhook_id_webhook_configurations_id_fk": { + "name": "webhook_deliveries_webhook_id_webhook_configurations_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_configurations", + "columnsFrom": ["webhook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_terminal_records": { + "name": "workflow_terminal_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_version_id": { + "name": "workflow_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "node_ref": { + "name": "node_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream": { + "name": "stream", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_chunk_index": { + "name": "first_chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_chunk_index": { + "name": "last_chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_group_servers": { + "name": "mcp_group_servers", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recommended": { + "name": "recommended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_selected": { + "name": "default_selected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_group_servers_group_idx": { + "name": "mcp_group_servers_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_group_servers_server_idx": { + "name": "mcp_group_servers_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_group_servers_group_id_mcp_groups_id_fk": { + "name": "mcp_group_servers_group_id_mcp_groups_id_fk", + "tableFrom": "mcp_group_servers", + "tableTo": "mcp_groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_group_servers_server_id_mcp_servers_id_fk": { + "name": "mcp_group_servers_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_group_servers", + "tableTo": "mcp_servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_group_servers_group_id_server_id_pk": { + "name": "mcp_group_servers_group_id_server_id_pk", + "columns": ["group_id", "server_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_groups": { + "name": "mcp_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_contract_name": { + "name": "credential_contract_name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "credential_mapping": { + "name": "credential_mapping", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "default_docker_image": { + "name": "default_docker_image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "template_hash": { + "name": "template_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_groups_slug_idx": { + "name": "mcp_groups_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_groups_enabled_idx": { + "name": "mcp_groups_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_groups_slug_unique": { + "name": "mcp_groups_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_tools": { + "name": "mcp_server_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "discovered_at": { + "name": "discovered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_tools_server_idx": { + "name": "mcp_server_tools_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_tools_server_tool_uidx": { + "name": "mcp_server_tools_server_tool_uidx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_server_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_tools", + "tableTo": "mcp_servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport_type": { + "name": "transport_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "health_check_url": { + "name": "health_check_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health_check": { + "name": "last_health_check", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_health_status": { + "name": "last_health_status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_org_idx": { + "name": "mcp_servers_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_enabled_idx": { + "name": "mcp_servers_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_group_idx": { + "name": "mcp_servers_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_name_org_uidx": { + "name": "mcp_servers_name_org_uidx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_group_id_mcp_groups_id_fk": { + "name": "mcp_servers_group_id_mcp_groups_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "mcp_groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.node_io": { + "name": "node_io", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" + "notNull": true }, - "file_name": { - "name": "file_name", - "type": "varchar(255)", + "run_id": { + "name": "run_id", + "type": "text", "primaryKey": false, "notNull": true }, - "mime_type": { - "name": "mime_type", - "type": "varchar(100)", + "node_ref": { + "name": "node_ref", + "type": "text", "primaryKey": false, "notNull": true }, - "size": { - "name": "size", - "type": "bigint", + "workflow_id": { + "name": "workflow_id", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, - "storage_key": { - "name": "storage_key", - "type": "varchar(500)", + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "component_id": { + "name": "component_id", + "type": "text", "primaryKey": false, "notNull": true }, - "uploaded_at": { - "name": "uploaded_at", - "type": "timestamp", + "inputs": { + "name": "inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "inputs_size": { + "name": "inputs_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "inputs_spilled": { + "name": "inputs_spilled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inputs_storage_ref": { + "name": "inputs_storage_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "outputs_size": { + "name": "outputs_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "outputs_spilled": { + "name": "outputs_spilled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "outputs_storage_ref": { + "name": "outputs_storage_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" } }, - "indexes": {}, + "indexes": { + "node_io_run_node_idx": { + "name": "node_io_run_node_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "node_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "node_io_run_idx": { + "name": "node_io_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "node_io_workflow_idx": { + "name": "node_io_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, "foreignKeys": {}, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "files_storage_key_unique": { - "name": "files_storage_key_unique", - "nullsNotDistinct": false, - "columns": [ - "storage_key" - ] + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_settings": { + "name": "organization_settings", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": true, + "notNull": true + }, + "analytics_retention_days": { + "name": "analytics_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" } }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, - "public.workflows": { - "name": "workflows", + "public.workflow_templates": { + "name": "workflow_templates", "schema": "", "columns": { "id": { @@ -80,31 +5229,49 @@ "primaryKey": false, "notNull": true }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, "description": { "name": "description", "type": "text", "primaryKey": false, "notNull": false }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, "graph": { "name": "graph", "type": "jsonb", "primaryKey": false, "notNull": true }, - "compiled_definition": { - "name": "compiled_definition", - "type": "jsonb", + "icon": { + "name": "icon", + "type": "varchar(50)", "primaryKey": false, - "notNull": false, - "default": "'null'::jsonb" + "notNull": false }, - "last_run": { - "name": "last_run", - "type": "timestamp with time zone", + "screenshot_url": { + "name": "screenshot_url", + "type": "varchar(255)", "primaryKey": false, "notNull": false }, + "is_featured": { + "name": "is_featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, "run_count": { "name": "run_count", "type": "integer", @@ -112,6 +5279,58 @@ "notNull": true, "default": 0 }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workflow_templates_slug_unique": { + "name": "workflow_templates_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_settings": { + "name": "user_settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(191)", + "primaryKey": true, + "notNull": true + }, + "aws_external_id": { + "name": "aws_external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, "created_at": { "name": "created_at", "type": "timestamp with time zone", @@ -136,7 +5355,18 @@ "isRLSEnabled": false } }, - "enums": {}, + "enums": { + "public.human_input_status": { + "name": "human_input_status", + "schema": "public", + "values": ["pending", "resolved", "expired", "cancelled"] + }, + "public.human_input_type": { + "name": "human_input_type", + "schema": "public", + "values": ["approval", "form", "selection", "review", "acknowledge"] + } + }, "schemas": {}, "sequences": {}, "roles": {}, @@ -147,4 +5377,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/backend/drizzle/meta/0002_snapshot.json b/backend/drizzle/meta/0002_snapshot.json deleted file mode 100644 index bf00419d2..000000000 --- a/backend/drizzle/meta/0002_snapshot.json +++ /dev/null @@ -1,252 +0,0 @@ -{ - "id": "7c75c339-5607-4491-a221-9ae317e3c41e", - "prevId": "bf2302d9-9426-4fa8-9d29-16d8e44c5952", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.files": { - "name": "files", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "file_name": { - "name": "file_name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "mime_type": { - "name": "mime_type", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "size": { - "name": "size", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "storage_key": { - "name": "storage_key", - "type": "varchar(500)", - "primaryKey": false, - "notNull": true - }, - "uploaded_at": { - "name": "uploaded_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "files_storage_key_unique": { - "name": "files_storage_key_unique", - "nullsNotDistinct": false, - "columns": [ - "storage_key" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workflows": { - "name": "workflows", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "graph": { - "name": "graph", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "compiled_definition": { - "name": "compiled_definition", - "type": "jsonb", - "primaryKey": false, - "notNull": false, - "default": "'null'::jsonb" - }, - "last_run": { - "name": "last_run", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "run_count": { - "name": "run_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workflow_traces": { - "name": "workflow_traces", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "run_id": { - "name": "run_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "workflow_id": { - "name": "workflow_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "node_ref": { - "name": "node_ref", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "timestamp": { - "name": "timestamp", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "message": { - "name": "message", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "output_summary": { - "name": "output_summary", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "sequence": { - "name": "sequence", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "workflow_traces_run_idx": { - "name": "workflow_traces_run_idx", - "columns": [ - { - "expression": "run_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "sequence", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/backend/drizzle/meta/0003_snapshot.json b/backend/drizzle/meta/0003_snapshot.json index f17d80fd1..de0d37ff9 100644 --- a/backend/drizzle/meta/0003_snapshot.json +++ b/backend/drizzle/meta/0003_snapshot.json @@ -1,11 +1,116 @@ { - "id": "f83d9a8e-7d9c-4f1a-8f55-0c2ef1d9b6bb", - "prevId": "7c75c339-5607-4491-a221-9ae317e3c41e", + "id": "017d6c60-1533-4793-a22c-95d36aa578ee", + "prevId": "7c5fab9d-7847-4368-93fc-ab6dd94240e5", "version": "7", "dialect": "postgresql", "tables": { - "public.files": { - "name": "files", + "public.agent_trace_events": { + "name": "agent_trace_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_run_id": { + "name": "workflow_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_ref": { + "name": "node_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "part_type": { + "name": "part_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_trace_events_run_idx": { + "name": "agent_trace_events_run_idx", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_trace_events_workflow_idx": { + "name": "agent_trace_events_workflow_idx", + "columns": [ + { + "expression": "workflow_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", "schema": "", "columns": { "id": { @@ -15,62 +120,204 @@ "notNull": true, "default": "gen_random_uuid()" }, - "file_name": { - "name": "file_name", - "type": "varchar(255)", + "name": { + "name": "name", + "type": "varchar(191)", "primaryKey": false, "notNull": true }, - "mime_type": { - "name": "mime_type", - "type": "varchar(100)", + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", "primaryKey": false, "notNull": true }, - "size": { - "name": "size", - "type": "bigint", + "key_prefix": { + "name": "key_prefix", + "type": "varchar(20)", "primaryKey": false, "notNull": true }, - "storage_key": { - "name": "storage_key", - "type": "varchar(500)", + "key_hint": { + "name": "key_hint", + "type": "varchar(8)", "primaryKey": false, "notNull": true }, - "uploaded_at": { - "name": "uploaded_at", - "type": "timestamp", + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rate_limit": { + "name": "rate_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" } }, - "indexes": {}, + "indexes": { + "api_keys_org_idx": { + "name": "api_keys_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_active_idx": { + "name": "api_keys_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_created_by_idx": { + "name": "api_keys_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_hash_idx": { + "name": "api_keys_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": { - "files_storage_key_unique": { - "name": "files_storage_key_unique", + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", "nullsNotDistinct": false, - "columns": [ - "storage_key" - ] + "columns": ["key_hash"] } }, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, - "public.workflow_runs": { - "name": "workflow_runs", + "public.artifacts": { + "name": "artifacts", "schema": "", "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, "run_id": { "name": "run_id", "type": "text", - "primaryKey": true, + "primaryKey": false, "notNull": true }, "workflow_id": { @@ -79,18 +326,4567 @@ "primaryKey": false, "notNull": true }, - "temporal_run_id": { - "name": "temporal_run_id", + "workflow_version_id": { + "name": "workflow_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "component_id": { + "name": "component_id", "type": "text", "primaryKey": false, "notNull": false }, - "total_actions": { - "name": "total_actions", - "type": "integer", + "component_ref": { + "name": "component_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "destinations": { + "name": "destinations", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"run\"]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "artifacts_run_idx": { + "name": "artifacts_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "artifacts_file_id_files_id_fk": { + "name": "artifacts_file_id_files_id_fk", + "tableFrom": "artifacts", + "tableTo": "files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asm_assets": { + "name": "asm_assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "domain_id": { + "name": "domain_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "ip_addresses": { + "name": "ip_addresses", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tech_stack": { + "name": "tech_stack", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "ports": { + "name": "ports", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "risk_score": { + "name": "risk_score", + "type": "numeric(4, 1)", + "primaryKey": false, + "notNull": false + }, + "dns_records": { + "name": "dns_records", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tls_info": { + "name": "tls_info", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "first_seen": { + "name": "first_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "asm_assets_domain_id_asm_domains_id_fk": { + "name": "asm_assets_domain_id_asm_domains_id_fk", + "tableFrom": "asm_assets", + "tableTo": "asm_domains", + "columnsFrom": ["domain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "asm_assets_domain_id_hostname_unique": { + "name": "asm_assets_domain_id_hostname_unique", + "nullsNotDistinct": false, + "columns": ["domain_id", "hostname"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asm_domains": { + "name": "asm_domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_include": { + "name": "scope_include", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scope_exclude": { + "name": "scope_exclude", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "monitoring_enabled": { + "name": "monitoring_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "frequency": { + "name": "frequency", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "last_scan_at": { + "name": "last_scan_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asm_domains_org_domain_unique": { + "name": "asm_domains_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "deleted_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset_scan_status": { + "name": "asset_scan_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scan_type": { + "name": "scan_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "last_scanned_at": { + "name": "last_scanned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_run_id": { + "name": "last_run_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "finding_count": { + "name": "finding_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "critical_count": { + "name": "critical_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "high_count": { + "name": "high_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "default": "'unscanned'" + } + }, + "indexes": { + "asset_scan_status_asset_scan_type_uidx": { + "name": "asset_scan_status_asset_scan_type_uidx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scan_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_scan_status_asset_id_assets_id_fk": { + "name": "asset_scan_status_asset_id_assets_id_fk", + "tableFrom": "asset_scan_status", + "tableTo": "assets", + "columnsFrom": ["asset_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset_sync_run_scopes": { + "name": "asset_sync_run_scopes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "sync_run_id": { + "name": "sync_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_type": { + "name": "asset_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "region": { + "name": "region", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "asset_sync_run_scopes_run_type_region_uidx": { + "name": "asset_sync_run_scopes_run_type_region_uidx", + "columns": [ + { + "expression": "sync_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "asset_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "region", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_sync_run_scopes_sync_run_idx": { + "name": "asset_sync_run_scopes_sync_run_idx", + "columns": [ + { + "expression": "sync_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_sync_run_scopes_sync_run_id_asset_sync_runs_id_fk": { + "name": "asset_sync_run_scopes_sync_run_id_asset_sync_runs_id_fk", + "tableFrom": "asset_sync_run_scopes", + "tableTo": "asset_sync_runs", + "columnsFrom": ["sync_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset_sync_runs": { + "name": "asset_sync_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "integration_id": { + "name": "integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_identifier": { + "name": "account_identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "temporal_workflow_id": { + "name": "temporal_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "temporal_run_id": { + "name": "temporal_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "covered_regions": { + "name": "covered_regions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "assets_discovered": { + "name": "assets_discovered", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "assets_created": { + "name": "assets_created", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "assets_updated": { + "name": "assets_updated", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "assets_stale": { + "name": "assets_stale", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asset_sync_runs_org_integration_created_idx": { + "name": "asset_sync_runs_org_integration_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_sync_runs_org_account_created_idx": { + "name": "asset_sync_runs_org_account_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "integration_id": { + "name": "integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "account_identifier": { + "name": "account_identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "asset_type": { + "name": "asset_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "first_discovered_at": { + "name": "first_discovered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_org_provider_type_external_uidx": { + "name": "assets_org_provider_type_external_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "asset_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_org_asset_type_idx": { + "name": "assets_org_asset_type_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "asset_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_org_integration_idx": { + "name": "assets_org_integration_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_status_last_seen_idx": { + "name": "assets_status_last_seen_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "actor_type": { + "name": "actor_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "actor_display": { + "name": "actor_display", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "ip": { + "name": "ip", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_org_created_at_idx": { + "name": "audit_logs_org_created_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_org_resource_idx": { + "name": "audit_logs_org_resource_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_org_action_created_at_idx": { + "name": "audit_logs_org_action_created_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_org_actor_created_at_idx": { + "name": "audit_logs_org_actor_created_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_messages": { + "name": "chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parts": { + "name": "parts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_messages_session_created_idx": { + "name": "chat_messages_session_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_messages_session_id_chat_sessions_id_fk": { + "name": "chat_messages_session_id_chat_sessions_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_partial_responses": { + "name": "chat_partial_responses", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "parts": { + "name": "parts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_partial_responses_session_id_chat_sessions_id_fk": { + "name": "chat_partial_responses_session_id_chat_sessions_id_fk", + "tableFrom": "chat_partial_responses", + "tableTo": "chat_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_sessions": { + "name": "chat_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New Conversation'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "active_run_id": { + "name": "active_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "next_event_seq": { + "name": "next_event_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "temporal_workflow_id": { + "name": "temporal_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_sessions_user_org_status_idx": { + "name": "chat_sessions_user_org_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_stream_events": { + "name": "chat_stream_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_stream_events_session_run_seq_idx": { + "name": "chat_stream_events_session_run_seq_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_stream_events_session_id_chat_sessions_id_fk": { + "name": "chat_stream_events_session_id_chat_sessions_id_fk", + "tableFrom": "chat_stream_events", + "tableTo": "chat_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "file_name": { + "name": "file_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "files_storage_key_unique": { + "name": "files_storage_key_unique", + "nullsNotDistinct": false, + "columns": ["storage_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_app_installations": { + "name": "github_app_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_id": { + "name": "installation_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "account_avatar_url": { + "name": "account_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "repository_selection": { + "name": "repository_selection", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "default": "'selected'" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "installed_by": { + "name": "installed_by", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_installations_installation_id_uidx": { + "name": "github_installations_installation_id_uidx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_installations_org_idx": { + "name": "github_installations_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_installations_account_login_idx": { + "name": "github_installations_account_login_idx", + "columns": [ + { + "expression": "account_login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_repositories": { + "name": "github_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repo_id": { + "name": "repo_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false, + "default": "'main'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "clone_url": { + "name": "clone_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stars_count": { + "name": "stars_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "forks_count": { + "name": "forks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "scans_enabled": { + "name": "scans_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_repos_repo_id_uidx": { + "name": "github_repos_repo_id_uidx", + "columns": [ + { + "expression": "repo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repos_installation_idx": { + "name": "github_repos_installation_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repos_org_idx": { + "name": "github_repos_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repos_full_name_idx": { + "name": "github_repos_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_repositories_installation_id_github_app_installations_id_fk": { + "name": "github_repositories_installation_id_github_app_installations_id_fk", + "tableFrom": "github_repositories", + "tableTo": "github_app_installations", + "columnsFrom": ["installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_scan_results": { + "name": "github_scan_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_run_id": { + "name": "workflow_run_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "source_type": { + "name": "source_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "branch": { + "name": "branch", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "summary": { + "name": "summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"critical\":0,\"high\":0,\"medium\":0,\"low\":0,\"info\":0}'::jsonb" + }, + "findings_count": { + "name": "findings_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "findings": { + "name": "findings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "check_run_id": { + "name": "check_run_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "pr_comment_id": { + "name": "pr_comment_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "pr_review_id": { + "name": "pr_review_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "results_url": { + "name": "results_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_rule_id": { + "name": "trigger_rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_scan_results_repo_idx": { + "name": "github_scan_results_repo_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_scan_results_org_idx": { + "name": "github_scan_results_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_scan_results_pr_idx": { + "name": "github_scan_results_pr_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_scan_results_status_idx": { + "name": "github_scan_results_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_scan_results_workflow_run_idx": { + "name": "github_scan_results_workflow_run_idx", + "columns": [ + { + "expression": "workflow_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_scan_results_trigger_rule_idx": { + "name": "github_scan_results_trigger_rule_idx", + "columns": [ + { + "expression": "trigger_rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_scan_results_repository_id_github_repositories_id_fk": { + "name": "github_scan_results_repository_id_github_repositories_id_fk", + "tableFrom": "github_scan_results", + "tableTo": "github_repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_scan_results_trigger_rule_id_github_trigger_rules_id_fk": { + "name": "github_scan_results_trigger_rule_id_github_trigger_rules_id_fk", + "tableFrom": "github_scan_results", + "tableTo": "github_trigger_rules", + "columnsFrom": ["trigger_rule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_trigger_rules": { + "name": "github_trigger_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_pattern": { + "name": "repository_pattern", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "branches": { + "name": "branches", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "post_pr_comment": { + "name": "post_pr_comment", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "create_check_run": { + "name": "create_check_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "post_pr_review": { + "name": "post_pr_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fail_on": { + "name": "fail_on", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'high'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_trigger_rules_org_idx": { + "name": "github_trigger_rules_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_trigger_rules_event_idx": { + "name": "github_trigger_rules_event_idx", + "columns": [ + { + "expression": "event", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_trigger_rules_enabled_idx": { + "name": "github_trigger_rules_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.human_input_requests": { + "name": "human_input_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "node_ref": { + "name": "node_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "human_input_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "input_type": { + "name": "input_type", + "type": "human_input_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'approval'" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "resolve_token": { + "name": "resolve_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timeout_at": { + "name": "timeout_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "response_data": { + "name": "response_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "responded_at": { + "name": "responded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "responded_by": { + "name": "responded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "human_input_requests_resolve_token_unique": { + "name": "human_input_requests_resolve_token_unique", + "nullsNotDistinct": false, + "columns": ["resolve_token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflows": { + "name": "workflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "graph": { + "name": "graph", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "compiled_definition": { + "name": "compiled_definition", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_color": { + "name": "icon_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run": { + "name": "last_run", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_versions": { + "name": "workflow_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "graph": { + "name": "graph", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "compiled_definition": { + "name": "compiled_definition", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_versions_workflow_version_uidx": { + "name": "workflow_versions_workflow_version_uidx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_traces": { + "name": "workflow_traces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_ref": { + "name": "node_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "output_summary": { + "name": "output_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_traces_run_idx": { + "name": "workflow_traces_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_runs": { + "name": "workflow_runs", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_version_id": { + "name": "workflow_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workflow_version": { + "name": "workflow_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "temporal_run_id": { + "name": "temporal_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_node_ref": { + "name": "parent_node_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_actions": { + "name": "total_actions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "inputs": { + "name": "inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "trigger_source": { + "name": "trigger_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_label": { + "name": "trigger_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Manual run'" + }, + "input_preview": { + "name": "input_preview", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"runtimeInputs\":{},\"nodeOverrides\":{}}'::jsonb" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "close_time": { + "name": "close_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_workflow_runs_workflow_created": { + "name": "idx_workflow_runs_workflow_created", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_runs_org_created": { + "name": "idx_workflow_runs_org_created", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_log_streams": { + "name": "workflow_log_streams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_ref": { + "name": "node_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream": { + "name": "stream", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "first_timestamp": { + "name": "first_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_timestamp": { + "name": "last_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_log_streams_run_node_stream_idx": { + "name": "workflow_log_streams_run_node_stream_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "node_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_log_streams_run_node_stream_uidx": { + "name": "workflow_log_streams_run_node_stream_uidx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "node_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_versions": { + "name": "secret_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret_id": { + "name": "secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_tag": { + "name": "auth_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encryption_key_id": { + "name": "encryption_key_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "secret_versions_secret_id_secrets_id_fk": { + "name": "secret_versions_secret_id_secrets_id_fk", + "tableFrom": "secret_versions", + "tableTo": "secrets", + "columnsFrom": ["secret_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secrets": { + "name": "secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "secrets_name_unique": { + "name": "secrets_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_workflow_links": { + "name": "platform_workflow_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform_agent_id": { + "name": "platform_agent_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "platform_workflow_links_agent_idx": { + "name": "platform_workflow_links_agent_idx", + "columns": [ + { + "expression": "platform_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "platform_workflow_links_org_idx": { + "name": "platform_workflow_links_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "platform_workflow_links_id_pk": { + "name": "platform_workflow_links_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_roles": { + "name": "workflow_roles", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_roles_org_idx": { + "name": "workflow_roles_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_roles_user_idx": { + "name": "workflow_roles_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_roles_workflow_id_workflows_id_fk": { + "name": "workflow_roles_workflow_id_workflows_id_fk", + "tableFrom": "workflow_roles", + "tableTo": "workflows", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workflow_roles_workflow_id_user_id_pk": { + "name": "workflow_roles_workflow_id_user_id_pk", + "columns": ["workflow_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_oauth_states": { + "name": "integration_oauth_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_oauth_states_state_uidx": { + "name": "integration_oauth_states_state_uidx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_provider_configs": { + "name": "integration_provider_configs", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_tokens": { + "name": "integration_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "credential_type": { + "name": "credential_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'oauth'" + }, + "display_name": { + "name": "display_name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "access_token": { + "name": "access_token", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "token_type": { + "name": "token_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "default": "'Bearer'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_validated_at": { + "name": "last_validated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_validation_status": { + "name": "last_validation_status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "last_validation_error": { + "name": "last_validation_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_tokens_user_idx": { + "name": "integration_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_tokens_org_idx": { + "name": "integration_tokens_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_tokens_org_provider_type_name_uidx": { + "name": "integration_tokens_org_provider_type_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedules": { + "name": "workflow_schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_version_id": { + "name": "workflow_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workflow_version": { + "name": "workflow_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "human_label": { + "name": "human_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "overlap_policy": { + "name": "overlap_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'skip'" + }, + "catchup_window_seconds": { + "name": "catchup_window_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "input_payload": { + "name": "input_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"runtimeInputs\":{},\"nodeOverrides\":{}}'::jsonb" + }, + "temporal_schedule_id": { + "name": "temporal_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "temporal_snapshot": { + "name": "temporal_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_configurations": { + "name": "webhook_configurations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_version_id": { + "name": "workflow_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workflow_version": { + "name": "workflow_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_path": { + "name": "webhook_path", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "parsing_script": { + "name": "parsing_script", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expected_inputs": { + "name": "expected_inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_configurations_webhook_path_unique": { + "name": "webhook_configurations_webhook_path_unique", + "nullsNotDistinct": false, + "columns": ["webhook_path"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "webhook_id": { + "name": "webhook_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_run_id": { + "name": "workflow_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'processing'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "parsed_data": { + "name": "parsed_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_deliveries_webhook_id_webhook_configurations_id_fk": { + "name": "webhook_deliveries_webhook_id_webhook_configurations_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_configurations", + "columnsFrom": ["webhook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_terminal_records": { + "name": "workflow_terminal_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_version_id": { + "name": "workflow_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "node_ref": { + "name": "node_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream": { + "name": "stream", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_chunk_index": { + "name": "first_chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_chunk_index": { + "name": "last_chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_group_servers": { + "name": "mcp_group_servers", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recommended": { + "name": "recommended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_selected": { + "name": "default_selected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_group_servers_group_idx": { + "name": "mcp_group_servers_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_group_servers_server_idx": { + "name": "mcp_group_servers_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_group_servers_group_id_mcp_groups_id_fk": { + "name": "mcp_group_servers_group_id_mcp_groups_id_fk", + "tableFrom": "mcp_group_servers", + "tableTo": "mcp_groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_group_servers_server_id_mcp_servers_id_fk": { + "name": "mcp_group_servers_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_group_servers", + "tableTo": "mcp_servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_group_servers_group_id_server_id_pk": { + "name": "mcp_group_servers_group_id_server_id_pk", + "columns": ["group_id", "server_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_groups": { + "name": "mcp_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_contract_name": { + "name": "credential_contract_name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "credential_mapping": { + "name": "credential_mapping", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "default_docker_image": { + "name": "default_docker_image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "template_hash": { + "name": "template_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_groups_slug_idx": { + "name": "mcp_groups_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_groups_enabled_idx": { + "name": "mcp_groups_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_groups_slug_unique": { + "name": "mcp_groups_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_tools": { + "name": "mcp_server_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "discovered_at": { + "name": "discovered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_tools_server_idx": { + "name": "mcp_server_tools_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_tools_server_tool_uidx": { + "name": "mcp_server_tools_server_tool_uidx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_server_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_tools", + "tableTo": "mcp_servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport_type": { + "name": "transport_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "health_check_url": { + "name": "health_check_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health_check": { + "name": "last_health_check", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_health_status": { + "name": "last_health_status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "varchar(191)", "primaryKey": false, - "notNull": true, - "default": 0 + "notNull": false }, "created_at": { "name": "created_at", @@ -107,16 +4903,93 @@ "default": "now()" } }, - "indexes": {}, - "foreignKeys": {}, + "indexes": { + "mcp_servers_org_idx": { + "name": "mcp_servers_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_enabled_idx": { + "name": "mcp_servers_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_group_idx": { + "name": "mcp_servers_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_name_org_uidx": { + "name": "mcp_servers_name_org_uidx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_group_id_mcp_groups_id_fk": { + "name": "mcp_servers_group_id_mcp_groups_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "mcp_groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, - "public.workflow_traces": { - "name": "workflow_traces", + "public.node_io": { + "name": "node_io", "schema": "", "columns": { "id": { @@ -131,53 +5004,112 @@ "primaryKey": false, "notNull": true }, + "node_ref": { + "name": "node_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, "workflow_id": { "name": "workflow_id", "type": "text", "primaryKey": false, "notNull": false }, - "type": { - "name": "type", - "type": "text", + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", "primaryKey": false, - "notNull": true + "notNull": false }, - "node_ref": { - "name": "node_ref", + "component_id": { + "name": "component_id", "type": "text", "primaryKey": false, "notNull": true }, - "timestamp": { - "name": "timestamp", - "type": "timestamp with time zone", + "inputs": { + "name": "inputs", + "type": "jsonb", "primaryKey": false, - "notNull": true + "notNull": false }, - "message": { - "name": "message", + "inputs_size": { + "name": "inputs_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "inputs_spilled": { + "name": "inputs_spilled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inputs_storage_ref": { + "name": "inputs_storage_ref", "type": "text", "primaryKey": false, "notNull": false }, - "error": { - "name": "error", + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "outputs_size": { + "name": "outputs_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "outputs_spilled": { + "name": "outputs_spilled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "outputs_storage_ref": { + "name": "outputs_storage_ref", "type": "text", "primaryKey": false, "notNull": false }, - "output_summary": { - "name": "output_summary", - "type": "jsonb", + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, - "sequence": { - "name": "sequence", + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", "type": "integer", "primaryKey": false, - "notNull": true + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false }, "created_at": { "name": "created_at", @@ -185,11 +5117,18 @@ "primaryKey": false, "notNull": true, "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" } }, "indexes": { - "workflow_traces_run_idx": { - "name": "workflow_traces_run_idx", + "node_io_run_node_idx": { + "name": "node_io_run_node_idx", "columns": [ { "expression": "run_id", @@ -198,7 +5137,37 @@ "nulls": "last" }, { - "expression": "sequence", + "expression": "node_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "node_io_run_idx": { + "name": "node_io_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "node_io_workflow_idx": { + "name": "node_io_workflow_idx", + "columns": [ + { + "expression": "workflow_id", "isExpression": false, "asc": true, "nulls": "last" @@ -217,8 +5186,48 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.workflows": { - "name": "workflows", + "public.organization_settings": { + "name": "organization_settings", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": true, + "notNull": true + }, + "analytics_retention_days": { + "name": "analytics_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_templates": { + "name": "workflow_templates", "schema": "", "columns": { "id": { @@ -234,31 +5243,49 @@ "primaryKey": false, "notNull": true }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, "description": { "name": "description", "type": "text", "primaryKey": false, "notNull": false }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, "graph": { "name": "graph", "type": "jsonb", "primaryKey": false, "notNull": true }, - "compiled_definition": { - "name": "compiled_definition", - "type": "jsonb", + "icon": { + "name": "icon", + "type": "varchar(50)", "primaryKey": false, - "notNull": false, - "default": "'null'::jsonb" + "notNull": false }, - "last_run": { - "name": "last_run", - "type": "timestamp with time zone", + "screenshot_url": { + "name": "screenshot_url", + "type": "varchar(255)", "primaryKey": false, "notNull": false }, + "is_featured": { + "name": "is_featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, "run_count": { "name": "run_count", "type": "integer", @@ -266,6 +5293,58 @@ "notNull": true, "default": 0 }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workflow_templates_slug_unique": { + "name": "workflow_templates_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_settings": { + "name": "user_settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(191)", + "primaryKey": true, + "notNull": true + }, + "aws_external_id": { + "name": "aws_external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, "created_at": { "name": "created_at", "type": "timestamp with time zone", @@ -290,7 +5369,18 @@ "isRLSEnabled": false } }, - "enums": {}, + "enums": { + "public.human_input_status": { + "name": "human_input_status", + "schema": "public", + "values": ["pending", "resolved", "expired", "cancelled"] + }, + "public.human_input_type": { + "name": "human_input_type", + "schema": "public", + "values": ["approval", "form", "selection", "review", "acknowledge"] + } + }, "schemas": {}, "sequences": {}, "roles": {}, diff --git a/backend/drizzle/meta/0004_snapshot.json b/backend/drizzle/meta/0004_snapshot.json deleted file mode 100644 index f7ca8d979..000000000 --- a/backend/drizzle/meta/0004_snapshot.json +++ /dev/null @@ -1,317 +0,0 @@ -{ - "id": "f0e7b6bc-23f6-46e9-a13d-8f4fef1223a2", - "prevId": "f83d9a8e-7d9c-4f1a-8f55-0c2ef1d9b6bb", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.files": { - "name": "files", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "file_name": { - "name": "file_name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "mime_type": { - "name": "mime_type", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "size": { - "name": "size", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "storage_key": { - "name": "storage_key", - "type": "varchar(500)", - "primaryKey": false, - "notNull": true - }, - "uploaded_at": { - "name": "uploaded_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "files_storage_key_unique": { - "name": "files_storage_key_unique", - "nullsNotDistinct": false, - "columns": [ - "storage_key" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workflow_runs": { - "name": "workflow_runs", - "schema": "", - "columns": { - "run_id": { - "name": "run_id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "workflow_id": { - "name": "workflow_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "temporal_run_id": { - "name": "temporal_run_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "total_actions": { - "name": "total_actions", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workflow_traces": { - "name": "workflow_traces", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "run_id": { - "name": "run_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "workflow_id": { - "name": "workflow_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "node_ref": { - "name": "node_ref", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "timestamp": { - "name": "timestamp", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "message": { - "name": "message", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "output_summary": { - "name": "output_summary", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "sequence": { - "name": "sequence", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "level": { - "name": "level", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'info'::text" - }, - "data": { - "name": "data", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "workflow_traces_run_idx": { - "name": "workflow_traces_run_idx", - "columns": [ - { - "expression": "run_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "sequence", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workflows": { - "name": "workflows", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "graph": { - "name": "graph", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "compiled_definition": { - "name": "compiled_definition", - "type": "jsonb", - "primaryKey": false, - "notNull": false, - "default": "'null'::jsonb" - }, - "last_run": { - "name": "last_run", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "run_count": { - "name": "run_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/backend/drizzle/meta/0005_snapshot.json b/backend/drizzle/meta/0005_snapshot.json deleted file mode 100644 index 15e62a29e..000000000 --- a/backend/drizzle/meta/0005_snapshot.json +++ /dev/null @@ -1,591 +0,0 @@ -{ - "id": "6d6f6a72-5cbe-4c2c-8c52-5d1c8c9ab123", - "prevId": "f0e7b6bc-23f6-46e9-a13d-8f4fef1223a2", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.files": { - "name": "files", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "file_name": { - "name": "file_name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "mime_type": { - "name": "mime_type", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "size": { - "name": "size", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "storage_key": { - "name": "storage_key", - "type": "varchar(500)", - "primaryKey": false, - "notNull": true - }, - "uploaded_at": { - "name": "uploaded_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "files_storage_key_unique": { - "name": "files_storage_key_unique", - "nullsNotDistinct": false, - "columns": [ - "storage_key" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workflow_runs": { - "name": "workflow_runs", - "schema": "", - "columns": { - "run_id": { - "name": "run_id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "workflow_id": { - "name": "workflow_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "temporal_run_id": { - "name": "temporal_run_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "total_actions": { - "name": "total_actions", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workflow_traces": { - "name": "workflow_traces", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "run_id": { - "name": "run_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "workflow_id": { - "name": "workflow_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "node_ref": { - "name": "node_ref", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "timestamp": { - "name": "timestamp", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "message": { - "name": "message", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "output_summary": { - "name": "output_summary", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "sequence": { - "name": "sequence", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "level": { - "name": "level", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'info'::text" - }, - "data": { - "name": "data", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "workflow_traces_run_idx": { - "name": "workflow_traces_run_idx", - "columns": [ - { - "expression": "run_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "sequence", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.secrets": { - "name": "secrets", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "varchar(191)", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "tags": { - "name": "tags", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "secrets_name_unique": { - "name": "secrets_name_unique", - "nullsNotDistinct": false, - "columns": [ - "name" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.secret_versions": { - "name": "secret_versions", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "secret_id": { - "name": "secret_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "version": { - "name": "version", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "encrypted_value": { - "name": "encrypted_value", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "iv": { - "name": "iv", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "auth_tag": { - "name": "auth_tag", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "encryption_key_id": { - "name": "encryption_key_id", - "type": "varchar(128)", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "created_by": { - "name": "created_by", - "type": "varchar(191)", - "primaryKey": false, - "notNull": false - }, - "is_active": { - "name": "is_active", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - } - }, - "indexes": { - "secret_versions_secret_id_idx": { - "name": "secret_versions_secret_id_idx", - "columns": [ - "secret_id" - ], - "isUnique": false, - "where": null - } - }, - "foreignKeys": { - "secret_versions_secret_id_secrets_id_fk": { - "name": "secret_versions_secret_id_secrets_id_fk", - "tableFrom": "secret_versions", - "columnsFrom": [ - "secret_id" - ], - "tableTo": "secrets", - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action", - "matchType": "simple" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "secret_versions_secret_id_version_idx": { - "name": "secret_versions_secret_id_version_idx", - "nullsNotDistinct": false, - "columns": [ - "secret_id", - "version" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workflow_log_streams": { - "name": "workflow_log_streams", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "run_id": { - "name": "run_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "node_ref": { - "name": "node_ref", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "stream": { - "name": "stream", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "labels": { - "name": "labels", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "first_timestamp": { - "name": "first_timestamp", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "last_timestamp": { - "name": "last_timestamp", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "line_count": { - "name": "line_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "workflow_log_streams_run_node_stream_idx": { - "name": "workflow_log_streams_run_node_stream_idx", - "columns": [ - { - "expression": "run_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "node_ref", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "stream", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workflows": { - "name": "workflows", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "graph": { - "name": "graph", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "compiled_definition": { - "name": "compiled_definition", - "type": "jsonb", - "primaryKey": false, - "notNull": false, - "default": "'null'::jsonb" - }, - "last_run": { - "name": "last_run", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "run_count": { - "name": "run_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - }, - "domains": {} -} diff --git a/backend/drizzle/meta/0006_snapshot.json b/backend/drizzle/meta/0006_snapshot.json deleted file mode 100644 index cb095c4be..000000000 --- a/backend/drizzle/meta/0006_snapshot.json +++ /dev/null @@ -1,618 +0,0 @@ -{ - "id": "48923d12-3f5a-4e87-beb3-cee68dda8ebe", - "prevId": "6d6f6a72-5cbe-4c2c-8c52-5d1c8c9ab123", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.files": { - "name": "files", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "file_name": { - "name": "file_name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "mime_type": { - "name": "mime_type", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "size": { - "name": "size", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "storage_key": { - "name": "storage_key", - "type": "varchar(500)", - "primaryKey": false, - "notNull": true - }, - "uploaded_at": { - "name": "uploaded_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "files_storage_key_unique": { - "name": "files_storage_key_unique", - "nullsNotDistinct": false, - "columns": [ - "storage_key" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workflow_runs": { - "name": "workflow_runs", - "schema": "", - "columns": { - "run_id": { - "name": "run_id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "workflow_id": { - "name": "workflow_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "temporal_run_id": { - "name": "temporal_run_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "total_actions": { - "name": "total_actions", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workflow_traces": { - "name": "workflow_traces", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "run_id": { - "name": "run_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "workflow_id": { - "name": "workflow_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "node_ref": { - "name": "node_ref", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "timestamp": { - "name": "timestamp", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "message": { - "name": "message", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "output_summary": { - "name": "output_summary", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "sequence": { - "name": "sequence", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "level": { - "name": "level", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'info'::text" - }, - "data": { - "name": "data", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "workflow_traces_run_idx": { - "name": "workflow_traces_run_idx", - "columns": [ - { - "expression": "run_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "sequence", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.secrets": { - "name": "secrets", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "varchar(191)", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "tags": { - "name": "tags", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "secrets_name_unique": { - "name": "secrets_name_unique", - "nullsNotDistinct": false, - "columns": [ - "name" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.secret_versions": { - "name": "secret_versions", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "secret_id": { - "name": "secret_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "version": { - "name": "version", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "encrypted_value": { - "name": "encrypted_value", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "iv": { - "name": "iv", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "auth_tag": { - "name": "auth_tag", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "encryption_key_id": { - "name": "encryption_key_id", - "type": "varchar(128)", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "created_by": { - "name": "created_by", - "type": "varchar(191)", - "primaryKey": false, - "notNull": false - }, - "is_active": { - "name": "is_active", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - } - }, - "indexes": { - "secret_versions_secret_id_idx": { - "name": "secret_versions_secret_id_idx", - "columns": [ - "secret_id" - ], - "isUnique": false, - "where": null - } - }, - "foreignKeys": { - "secret_versions_secret_id_secrets_id_fk": { - "name": "secret_versions_secret_id_secrets_id_fk", - "tableFrom": "secret_versions", - "columnsFrom": [ - "secret_id" - ], - "tableTo": "secrets", - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action", - "matchType": "simple" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "secret_versions_secret_id_version_idx": { - "name": "secret_versions_secret_id_version_idx", - "nullsNotDistinct": false, - "columns": [ - "secret_id", - "version" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workflow_log_streams": { - "name": "workflow_log_streams", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "run_id": { - "name": "run_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "node_ref": { - "name": "node_ref", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "stream": { - "name": "stream", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "labels": { - "name": "labels", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "first_timestamp": { - "name": "first_timestamp", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "last_timestamp": { - "name": "last_timestamp", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "line_count": { - "name": "line_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "workflow_log_streams_run_node_stream_idx": { - "name": "workflow_log_streams_run_node_stream_idx", - "columns": [ - { - "expression": "run_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "node_ref", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "stream", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "workflow_log_streams_run_node_stream_uidx": { - "name": "workflow_log_streams_run_node_stream_uidx", - "columns": [ - { - "expression": "run_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "node_ref", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "stream", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workflows": { - "name": "workflows", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "graph": { - "name": "graph", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "compiled_definition": { - "name": "compiled_definition", - "type": "jsonb", - "primaryKey": false, - "notNull": false, - "default": "'null'::jsonb" - }, - "last_run": { - "name": "last_run", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "run_count": { - "name": "run_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - }, - "domains": {} -} diff --git a/backend/drizzle/meta/0007_snapshot.json b/backend/drizzle/meta/0007_snapshot.json deleted file mode 100644 index 1be8d94d2..000000000 --- a/backend/drizzle/meta/0007_snapshot.json +++ /dev/null @@ -1,703 +0,0 @@ -{ - "id": "b7bdf851-9899-4f79-9c02-8f44ea98d104", - "prevId": "48923d12-3f5a-4e87-beb3-cee68dda8ebe", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.files": { - "name": "files", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "file_name": { - "name": "file_name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "mime_type": { - "name": "mime_type", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "size": { - "name": "size", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "storage_key": { - "name": "storage_key", - "type": "varchar(500)", - "primaryKey": false, - "notNull": true - }, - "uploaded_at": { - "name": "uploaded_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "files_storage_key_unique": { - "name": "files_storage_key_unique", - "nullsNotDistinct": false, - "columns": [ - "storage_key" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workflow_runs": { - "name": "workflow_runs", - "schema": "", - "columns": { - "run_id": { - "name": "run_id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "workflow_id": { - "name": "workflow_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "workflow_version_id": { - "name": "workflow_version_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "workflow_version": { - "name": "workflow_version", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "temporal_run_id": { - "name": "temporal_run_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "total_actions": { - "name": "total_actions", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workflow_versions": { - "name": "workflow_versions", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "workflow_id": { - "name": "workflow_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "version": { - "name": "version", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "graph": { - "name": "graph", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "compiled_definition": { - "name": "compiled_definition", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "workflow_versions_workflow_version_uidx": { - "name": "workflow_versions_workflow_version_uidx", - "columns": [ - { - "expression": "workflow_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "version", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workflow_traces": { - "name": "workflow_traces", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "run_id": { - "name": "run_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "workflow_id": { - "name": "workflow_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "node_ref": { - "name": "node_ref", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "timestamp": { - "name": "timestamp", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "message": { - "name": "message", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "output_summary": { - "name": "output_summary", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "sequence": { - "name": "sequence", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "level": { - "name": "level", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'info'::text" - }, - "data": { - "name": "data", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "workflow_traces_run_idx": { - "name": "workflow_traces_run_idx", - "columns": [ - { - "expression": "run_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "sequence", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.secrets": { - "name": "secrets", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "varchar(191)", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "tags": { - "name": "tags", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "secrets_name_unique": { - "name": "secrets_name_unique", - "nullsNotDistinct": false, - "columns": [ - "name" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.secret_versions": { - "name": "secret_versions", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "secret_id": { - "name": "secret_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "version": { - "name": "version", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "encrypted_value": { - "name": "encrypted_value", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "iv": { - "name": "iv", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "auth_tag": { - "name": "auth_tag", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "encryption_key_id": { - "name": "encryption_key_id", - "type": "varchar(128)", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "created_by": { - "name": "created_by", - "type": "varchar(191)", - "primaryKey": false, - "notNull": false - }, - "is_active": { - "name": "is_active", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - } - }, - "indexes": { - "secret_versions_secret_id_idx": { - "name": "secret_versions_secret_id_idx", - "columns": [ - "secret_id" - ], - "isUnique": false, - "where": null - } - }, - "foreignKeys": { - "secret_versions_secret_id_secrets_id_fk": { - "name": "secret_versions_secret_id_secrets_id_fk", - "tableFrom": "secret_versions", - "columnsFrom": [ - "secret_id" - ], - "tableTo": "secrets", - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action", - "matchType": "simple" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "secret_versions_secret_id_version_idx": { - "name": "secret_versions_secret_id_version_idx", - "nullsNotDistinct": false, - "columns": [ - "secret_id", - "version" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workflow_log_streams": { - "name": "workflow_log_streams", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "run_id": { - "name": "run_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "node_ref": { - "name": "node_ref", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "stream": { - "name": "stream", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "labels": { - "name": "labels", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "first_timestamp": { - "name": "first_timestamp", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "last_timestamp": { - "name": "last_timestamp", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "line_count": { - "name": "line_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "workflow_log_streams_run_node_stream_idx": { - "name": "workflow_log_streams_run_node_stream_idx", - "columns": [ - { - "expression": "run_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "node_ref", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "stream", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "workflow_log_streams_run_node_stream_uidx": { - "name": "workflow_log_streams_run_node_stream_uidx", - "columns": [ - { - "expression": "run_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "node_ref", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "stream", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workflows": { - "name": "workflows", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "graph": { - "name": "graph", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "compiled_definition": { - "name": "compiled_definition", - "type": "jsonb", - "primaryKey": false, - "notNull": false, - "default": "'null'::jsonb" - }, - "last_run": { - "name": "last_run", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "run_count": { - "name": "run_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - }, - "domains": {} -} diff --git a/backend/drizzle/meta/0030_snapshot.json b/backend/drizzle/meta/0030_snapshot.json new file mode 100644 index 000000000..edb20e844 --- /dev/null +++ b/backend/drizzle/meta/0030_snapshot.json @@ -0,0 +1,5394 @@ +{ + "id": "440b7bb9-9d42-4c9a-bffc-e2af5adec10e", + "prevId": "017d6c60-1533-4793-a22c-95d36aa578ee", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.agent_trace_events": { + "name": "agent_trace_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_run_id": { + "name": "workflow_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_ref": { + "name": "node_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "part_type": { + "name": "part_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_trace_events_run_idx": { + "name": "agent_trace_events_run_idx", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "agent_trace_events_workflow_idx": { + "name": "agent_trace_events_workflow_idx", + "columns": [ + { + "expression": "workflow_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "key_hint": { + "name": "key_hint", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rate_limit": { + "name": "rate_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_keys_org_idx": { + "name": "api_keys_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "api_keys_active_idx": { + "name": "api_keys_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "api_keys_created_by_idx": { + "name": "api_keys_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "api_keys_hash_idx": { + "name": "api_keys_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": ["key_hash"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifacts": { + "name": "artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_version_id": { + "name": "workflow_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "component_id": { + "name": "component_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "component_ref": { + "name": "component_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "destinations": { + "name": "destinations", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"run\"]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "artifacts_run_idx": { + "name": "artifacts_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "artifacts_file_id_files_id_fk": { + "name": "artifacts_file_id_files_id_fk", + "tableFrom": "artifacts", + "columnsFrom": ["file_id"], + "tableTo": "files", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asm_assets": { + "name": "asm_assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "domain_id": { + "name": "domain_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "ip_addresses": { + "name": "ip_addresses", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tech_stack": { + "name": "tech_stack", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "ports": { + "name": "ports", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "risk_score": { + "name": "risk_score", + "type": "numeric(4, 1)", + "primaryKey": false, + "notNull": false + }, + "dns_records": { + "name": "dns_records", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tls_info": { + "name": "tls_info", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "first_seen": { + "name": "first_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "asm_assets_domain_id_asm_domains_id_fk": { + "name": "asm_assets_domain_id_asm_domains_id_fk", + "tableFrom": "asm_assets", + "columnsFrom": ["domain_id"], + "tableTo": "asm_domains", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "asm_assets_domain_id_hostname_unique": { + "name": "asm_assets_domain_id_hostname_unique", + "columns": ["domain_id", "hostname"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asm_domains": { + "name": "asm_domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_include": { + "name": "scope_include", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scope_exclude": { + "name": "scope_exclude", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "monitoring_enabled": { + "name": "monitoring_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "frequency": { + "name": "frequency", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "last_scan_at": { + "name": "last_scan_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asm_domains_org_domain_unique": { + "name": "asm_domains_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "deleted_at IS NULL", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset_scan_status": { + "name": "asset_scan_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scan_type": { + "name": "scan_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "last_scanned_at": { + "name": "last_scanned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_run_id": { + "name": "last_run_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "finding_count": { + "name": "finding_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "critical_count": { + "name": "critical_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "high_count": { + "name": "high_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "default": "'unscanned'" + } + }, + "indexes": { + "asset_scan_status_asset_scan_type_uidx": { + "name": "asset_scan_status_asset_scan_type_uidx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scan_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "asset_scan_status_asset_id_assets_id_fk": { + "name": "asset_scan_status_asset_id_assets_id_fk", + "tableFrom": "asset_scan_status", + "columnsFrom": ["asset_id"], + "tableTo": "assets", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset_sync_run_scopes": { + "name": "asset_sync_run_scopes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "sync_run_id": { + "name": "sync_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_type": { + "name": "asset_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "region": { + "name": "region", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "asset_sync_run_scopes_run_type_region_uidx": { + "name": "asset_sync_run_scopes_run_type_region_uidx", + "columns": [ + { + "expression": "sync_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "asset_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "region", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "asset_sync_run_scopes_sync_run_idx": { + "name": "asset_sync_run_scopes_sync_run_idx", + "columns": [ + { + "expression": "sync_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "asset_sync_run_scopes_sync_run_id_asset_sync_runs_id_fk": { + "name": "asset_sync_run_scopes_sync_run_id_asset_sync_runs_id_fk", + "tableFrom": "asset_sync_run_scopes", + "columnsFrom": ["sync_run_id"], + "tableTo": "asset_sync_runs", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset_sync_runs": { + "name": "asset_sync_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "integration_id": { + "name": "integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_identifier": { + "name": "account_identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "temporal_workflow_id": { + "name": "temporal_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "temporal_run_id": { + "name": "temporal_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "covered_regions": { + "name": "covered_regions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "assets_discovered": { + "name": "assets_discovered", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "assets_created": { + "name": "assets_created", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "assets_updated": { + "name": "assets_updated", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "assets_stale": { + "name": "assets_stale", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asset_sync_runs_org_integration_created_idx": { + "name": "asset_sync_runs_org_integration_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "asset_sync_runs_org_account_created_idx": { + "name": "asset_sync_runs_org_account_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "integration_id": { + "name": "integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "account_identifier": { + "name": "account_identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "asset_type": { + "name": "asset_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "first_discovered_at": { + "name": "first_discovered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_org_provider_type_external_uidx": { + "name": "assets_org_provider_type_external_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "asset_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "assets_org_asset_type_idx": { + "name": "assets_org_asset_type_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "asset_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "assets_org_integration_idx": { + "name": "assets_org_integration_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "assets_status_last_seen_idx": { + "name": "assets_status_last_seen_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "actor_type": { + "name": "actor_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "actor_display": { + "name": "actor_display", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "ip": { + "name": "ip", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_org_created_at_idx": { + "name": "audit_logs_org_created_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "audit_logs_org_resource_idx": { + "name": "audit_logs_org_resource_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "audit_logs_org_action_created_at_idx": { + "name": "audit_logs_org_action_created_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "audit_logs_org_actor_created_at_idx": { + "name": "audit_logs_org_actor_created_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_messages": { + "name": "chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parts": { + "name": "parts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_messages_session_created_idx": { + "name": "chat_messages_session_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "chat_messages_session_id_chat_sessions_id_fk": { + "name": "chat_messages_session_id_chat_sessions_id_fk", + "tableFrom": "chat_messages", + "columnsFrom": ["session_id"], + "tableTo": "chat_sessions", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_partial_responses": { + "name": "chat_partial_responses", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "parts": { + "name": "parts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_partial_responses_session_id_chat_sessions_id_fk": { + "name": "chat_partial_responses_session_id_chat_sessions_id_fk", + "tableFrom": "chat_partial_responses", + "columnsFrom": ["session_id"], + "tableTo": "chat_sessions", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_sessions": { + "name": "chat_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New Conversation'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "active_run_id": { + "name": "active_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "next_event_seq": { + "name": "next_event_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "temporal_workflow_id": { + "name": "temporal_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_sessions_user_org_status_idx": { + "name": "chat_sessions_user_org_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_stream_events": { + "name": "chat_stream_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_stream_events_session_run_seq_idx": { + "name": "chat_stream_events_session_run_seq_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "chat_stream_events_session_id_chat_sessions_id_fk": { + "name": "chat_stream_events_session_id_chat_sessions_id_fk", + "tableFrom": "chat_stream_events", + "columnsFrom": ["session_id"], + "tableTo": "chat_sessions", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "file_name": { + "name": "file_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "files_storage_key_unique": { + "name": "files_storage_key_unique", + "columns": ["storage_key"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_app_installations": { + "name": "github_app_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_id": { + "name": "installation_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "account_avatar_url": { + "name": "account_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "repository_selection": { + "name": "repository_selection", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "default": "'selected'" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "installed_by": { + "name": "installed_by", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_installations_installation_id_uidx": { + "name": "github_installations_installation_id_uidx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "github_installations_org_idx": { + "name": "github_installations_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "github_installations_account_login_idx": { + "name": "github_installations_account_login_idx", + "columns": [ + { + "expression": "account_login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_repositories": { + "name": "github_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repo_id": { + "name": "repo_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false, + "default": "'main'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "clone_url": { + "name": "clone_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stars_count": { + "name": "stars_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "forks_count": { + "name": "forks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "scans_enabled": { + "name": "scans_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_repos_repo_id_uidx": { + "name": "github_repos_repo_id_uidx", + "columns": [ + { + "expression": "repo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "github_repos_installation_idx": { + "name": "github_repos_installation_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "github_repos_org_idx": { + "name": "github_repos_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "github_repos_full_name_idx": { + "name": "github_repos_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "github_repositories_installation_id_github_app_installations_id_fk": { + "name": "github_repositories_installation_id_github_app_installations_id_fk", + "tableFrom": "github_repositories", + "columnsFrom": ["installation_id"], + "tableTo": "github_app_installations", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_scan_results": { + "name": "github_scan_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_run_id": { + "name": "workflow_run_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "source_type": { + "name": "source_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "branch": { + "name": "branch", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "summary": { + "name": "summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"critical\":0,\"high\":0,\"medium\":0,\"low\":0,\"info\":0}'::jsonb" + }, + "findings_count": { + "name": "findings_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "findings": { + "name": "findings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "check_run_id": { + "name": "check_run_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "pr_comment_id": { + "name": "pr_comment_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "pr_review_id": { + "name": "pr_review_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "results_url": { + "name": "results_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_rule_id": { + "name": "trigger_rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_scan_results_repo_idx": { + "name": "github_scan_results_repo_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "github_scan_results_org_idx": { + "name": "github_scan_results_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "github_scan_results_pr_idx": { + "name": "github_scan_results_pr_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "github_scan_results_status_idx": { + "name": "github_scan_results_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "github_scan_results_workflow_run_idx": { + "name": "github_scan_results_workflow_run_idx", + "columns": [ + { + "expression": "workflow_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "github_scan_results_trigger_rule_idx": { + "name": "github_scan_results_trigger_rule_idx", + "columns": [ + { + "expression": "trigger_rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "github_scan_results_repository_id_github_repositories_id_fk": { + "name": "github_scan_results_repository_id_github_repositories_id_fk", + "tableFrom": "github_scan_results", + "columnsFrom": ["repository_id"], + "tableTo": "github_repositories", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "github_scan_results_trigger_rule_id_github_trigger_rules_id_fk": { + "name": "github_scan_results_trigger_rule_id_github_trigger_rules_id_fk", + "tableFrom": "github_scan_results", + "columnsFrom": ["trigger_rule_id"], + "tableTo": "github_trigger_rules", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_trigger_rules": { + "name": "github_trigger_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_pattern": { + "name": "repository_pattern", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "branches": { + "name": "branches", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "post_pr_comment": { + "name": "post_pr_comment", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "create_check_run": { + "name": "create_check_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "post_pr_review": { + "name": "post_pr_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fail_on": { + "name": "fail_on", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'high'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_trigger_rules_org_idx": { + "name": "github_trigger_rules_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "github_trigger_rules_event_idx": { + "name": "github_trigger_rules_event_idx", + "columns": [ + { + "expression": "event", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "github_trigger_rules_enabled_idx": { + "name": "github_trigger_rules_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.human_input_requests": { + "name": "human_input_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "node_ref": { + "name": "node_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "human_input_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "input_type": { + "name": "input_type", + "type": "human_input_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'approval'" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "resolve_token": { + "name": "resolve_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timeout_at": { + "name": "timeout_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "response_data": { + "name": "response_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "responded_at": { + "name": "responded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "responded_by": { + "name": "responded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "human_input_requests_resolve_token_unique": { + "name": "human_input_requests_resolve_token_unique", + "columns": ["resolve_token"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflows": { + "name": "workflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "graph": { + "name": "graph", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "compiled_definition": { + "name": "compiled_definition", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_color": { + "name": "icon_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run": { + "name": "last_run", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_versions": { + "name": "workflow_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "graph": { + "name": "graph", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "compiled_definition": { + "name": "compiled_definition", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_versions_workflow_version_uidx": { + "name": "workflow_versions_workflow_version_uidx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_traces": { + "name": "workflow_traces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_ref": { + "name": "node_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "output_summary": { + "name": "output_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_traces_run_idx": { + "name": "workflow_traces_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_runs": { + "name": "workflow_runs", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_version_id": { + "name": "workflow_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workflow_version": { + "name": "workflow_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "temporal_run_id": { + "name": "temporal_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_node_ref": { + "name": "parent_node_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_actions": { + "name": "total_actions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "inputs": { + "name": "inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "trigger_source": { + "name": "trigger_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_label": { + "name": "trigger_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Manual run'" + }, + "input_preview": { + "name": "input_preview", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"runtimeInputs\":{},\"nodeOverrides\":{}}'::jsonb" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "close_time": { + "name": "close_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_workflow_runs_workflow_created": { + "name": "idx_workflow_runs_workflow_created", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_workflow_runs_org_created": { + "name": "idx_workflow_runs_org_created", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_log_streams": { + "name": "workflow_log_streams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_ref": { + "name": "node_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream": { + "name": "stream", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "first_timestamp": { + "name": "first_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_timestamp": { + "name": "last_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_log_streams_run_node_stream_idx": { + "name": "workflow_log_streams_run_node_stream_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "node_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_log_streams_run_node_stream_uidx": { + "name": "workflow_log_streams_run_node_stream_uidx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "node_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_versions": { + "name": "secret_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret_id": { + "name": "secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_tag": { + "name": "auth_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encryption_key_id": { + "name": "encryption_key_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "secret_versions_secret_id_secrets_id_fk": { + "name": "secret_versions_secret_id_secrets_id_fk", + "tableFrom": "secret_versions", + "columnsFrom": ["secret_id"], + "tableTo": "secrets", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secrets": { + "name": "secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "secrets_name_unique": { + "name": "secrets_name_unique", + "columns": ["name"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_workflow_links": { + "name": "platform_workflow_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform_agent_id": { + "name": "platform_agent_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "platform_workflow_links_agent_idx": { + "name": "platform_workflow_links_agent_idx", + "columns": [ + { + "expression": "platform_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "platform_workflow_links_org_idx": { + "name": "platform_workflow_links_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "platform_workflow_links_id_pk": { + "name": "platform_workflow_links_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_roles": { + "name": "workflow_roles", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_roles_org_idx": { + "name": "workflow_roles_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_roles_user_idx": { + "name": "workflow_roles_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workflow_roles_workflow_id_workflows_id_fk": { + "name": "workflow_roles_workflow_id_workflows_id_fk", + "tableFrom": "workflow_roles", + "columnsFrom": ["workflow_id"], + "tableTo": "workflows", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "workflow_roles_workflow_id_user_id_pk": { + "name": "workflow_roles_workflow_id_user_id_pk", + "columns": ["workflow_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_oauth_states": { + "name": "integration_oauth_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_oauth_states_state_uidx": { + "name": "integration_oauth_states_state_uidx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_provider_configs": { + "name": "integration_provider_configs", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_tokens": { + "name": "integration_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "credential_type": { + "name": "credential_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'oauth'" + }, + "display_name": { + "name": "display_name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "access_token": { + "name": "access_token", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "token_type": { + "name": "token_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "default": "'Bearer'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_validated_at": { + "name": "last_validated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_validation_status": { + "name": "last_validation_status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "last_validation_error": { + "name": "last_validation_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_tokens_user_idx": { + "name": "integration_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "integration_tokens_org_idx": { + "name": "integration_tokens_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "integration_tokens_org_provider_type_name_uidx": { + "name": "integration_tokens_org_provider_type_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedules": { + "name": "workflow_schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_version_id": { + "name": "workflow_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workflow_version": { + "name": "workflow_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "human_label": { + "name": "human_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "overlap_policy": { + "name": "overlap_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'skip'" + }, + "catchup_window_seconds": { + "name": "catchup_window_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "input_payload": { + "name": "input_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"runtimeInputs\":{},\"nodeOverrides\":{}}'::jsonb" + }, + "temporal_schedule_id": { + "name": "temporal_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "temporal_snapshot": { + "name": "temporal_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_configurations": { + "name": "webhook_configurations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_version_id": { + "name": "workflow_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workflow_version": { + "name": "workflow_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_path": { + "name": "webhook_path", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "parsing_script": { + "name": "parsing_script", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expected_inputs": { + "name": "expected_inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_configurations_webhook_path_unique": { + "name": "webhook_configurations_webhook_path_unique", + "columns": ["webhook_path"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "webhook_id": { + "name": "webhook_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_run_id": { + "name": "workflow_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'processing'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "parsed_data": { + "name": "parsed_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_deliveries_webhook_id_webhook_configurations_id_fk": { + "name": "webhook_deliveries_webhook_id_webhook_configurations_id_fk", + "tableFrom": "webhook_deliveries", + "columnsFrom": ["webhook_id"], + "tableTo": "webhook_configurations", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_terminal_records": { + "name": "workflow_terminal_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_version_id": { + "name": "workflow_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "node_ref": { + "name": "node_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream": { + "name": "stream", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_chunk_index": { + "name": "first_chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_chunk_index": { + "name": "last_chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_group_servers": { + "name": "mcp_group_servers", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recommended": { + "name": "recommended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_selected": { + "name": "default_selected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_group_servers_group_idx": { + "name": "mcp_group_servers_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "mcp_group_servers_server_idx": { + "name": "mcp_group_servers_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "mcp_group_servers_group_id_mcp_groups_id_fk": { + "name": "mcp_group_servers_group_id_mcp_groups_id_fk", + "tableFrom": "mcp_group_servers", + "columnsFrom": ["group_id"], + "tableTo": "mcp_groups", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "mcp_group_servers_server_id_mcp_servers_id_fk": { + "name": "mcp_group_servers_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_group_servers", + "columnsFrom": ["server_id"], + "tableTo": "mcp_servers", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "mcp_group_servers_group_id_server_id_pk": { + "name": "mcp_group_servers_group_id_server_id_pk", + "columns": ["group_id", "server_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_groups": { + "name": "mcp_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_contract_name": { + "name": "credential_contract_name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "credential_mapping": { + "name": "credential_mapping", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "default_docker_image": { + "name": "default_docker_image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "template_hash": { + "name": "template_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_groups_slug_idx": { + "name": "mcp_groups_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "mcp_groups_enabled_idx": { + "name": "mcp_groups_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_groups_slug_unique": { + "name": "mcp_groups_slug_unique", + "columns": ["slug"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_tools": { + "name": "mcp_server_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "discovered_at": { + "name": "discovered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_tools_server_idx": { + "name": "mcp_server_tools_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "mcp_server_tools_server_tool_uidx": { + "name": "mcp_server_tools_server_tool_uidx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "mcp_server_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_server_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_tools", + "columnsFrom": ["server_id"], + "tableTo": "mcp_servers", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(191)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport_type": { + "name": "transport_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "health_check_url": { + "name": "health_check_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health_check": { + "name": "last_health_check", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_health_status": { + "name": "last_health_status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_org_idx": { + "name": "mcp_servers_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "mcp_servers_enabled_idx": { + "name": "mcp_servers_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "mcp_servers_group_idx": { + "name": "mcp_servers_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "mcp_servers_name_org_uidx": { + "name": "mcp_servers_name_org_uidx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "mcp_servers_group_id_mcp_groups_id_fk": { + "name": "mcp_servers_group_id_mcp_groups_id_fk", + "tableFrom": "mcp_servers", + "columnsFrom": ["group_id"], + "tableTo": "mcp_groups", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.node_io": { + "name": "node_io", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_ref": { + "name": "node_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": false, + "notNull": false + }, + "component_id": { + "name": "component_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inputs": { + "name": "inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "inputs_size": { + "name": "inputs_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "inputs_spilled": { + "name": "inputs_spilled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inputs_storage_ref": { + "name": "inputs_storage_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "outputs_size": { + "name": "outputs_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "outputs_spilled": { + "name": "outputs_spilled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "outputs_storage_ref": { + "name": "outputs_storage_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "node_io_run_node_idx": { + "name": "node_io_run_node_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "node_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "node_io_run_idx": { + "name": "node_io_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "node_io_workflow_idx": { + "name": "node_io_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_settings": { + "name": "organization_settings", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "varchar(191)", + "primaryKey": true, + "notNull": true + }, + "analytics_retention_days": { + "name": "analytics_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_templates": { + "name": "workflow_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "graph": { + "name": "graph", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "screenshot_url": { + "name": "screenshot_url", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "is_featured": { + "name": "is_featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workflow_templates_slug_unique": { + "name": "workflow_templates_slug_unique", + "columns": ["slug"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_settings": { + "name": "user_settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(191)", + "primaryKey": true, + "notNull": true + }, + "aws_external_id": { + "name": "aws_external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.human_input_status": { + "name": "human_input_status", + "schema": "public", + "values": ["pending", "resolved", "expired", "cancelled"] + }, + "public.human_input_type": { + "name": "human_input_type", + "schema": "public", + "values": ["approval", "form", "selection", "review", "acknowledge"] + } + }, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index 761f469da..3439f3c9f 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -5,99 +5,120 @@ { "idx": 0, "version": "7", - "when": 1759969737888, - "tag": "0000_tranquil_vertigo", + "when": 1772018698483, + "tag": "0000_initial_schema", "breakpoints": true }, { "idx": 1, "version": "7", - "when": 1760297403037, - "tag": "0001_complete_doctor_octopus", + "when": 1772018816636, + "tag": "0001_reconcile_existing_schema", "breakpoints": true }, { "idx": 2, "version": "7", - "when": 1760372669493, - "tag": "0002_add-workflow-traces", + "when": 1772150400000, + "tag": "0002_reconcile_workflows_columns", "breakpoints": true }, { "idx": 3, "version": "7", - "when": 1760700000000, - "tag": "0003_create-workflow-runs", + "when": 1772095528985, + "tag": "0003_rare_mimic", "breakpoints": true }, { "idx": 4, "version": "7", - "when": 1760703600000, - "tag": "0004_update-workflow-traces", + "when": 1772582400000, + "tag": "0004_chat_v2_turn_timeline", "breakpoints": true }, { "idx": 5, "version": "7", - "when": 1760900000000, - "tag": "0005_create-secret-store", + "when": 1772812800000, + "tag": "0005_chat_vnext_session_control_plane", "breakpoints": true }, { - "idx": 6, + "idx": 20, "version": "7", - "when": 1761069793200, - "tag": "0006_add-workflow-log-streams-unique-index", + "when": 1772874000000, + "tag": "0020_add-billing-usage-indexes", "breakpoints": true }, { - "idx": 7, + "idx": 23, "version": "7", - "when": 1761400000000, - "tag": "0007_create-integration-tokens", + "when": 1772882400000, + "tag": "0023_add_onboarding_preferences", "breakpoints": true }, { - "idx": 8, + "idx": 24, "version": "7", - "when": 1761500000000, - "tag": "0008_add-workflow-versions", + "when": 1743382800000, + "tag": "0024_add_slack_thread_mappings", "breakpoints": true }, { - "idx": 9, + "idx": 25, "version": "7", - "when": 1736352000000, - "tag": "0013_create-artifacts", + "when": 1775188200000, + "tag": "0025_add_slack_turn_posts", "breakpoints": true }, { - "idx": 10, + "idx": 26, "version": "7", - "when": 1737500000000, - "tag": "0014_create-terminal-records", + "when": 1775199600000, + "tag": "0026_add_mcp_server_templates", "breakpoints": true }, { - "idx": 11, + "idx": 27, "version": "7", - "when": 1738200000000, - "tag": "0015_create-workflow-schedules", + "when": 1775468400000, + "tag": "0027_add_github_installation_id_to_mcp_servers", "breakpoints": true }, { - "idx": 12, + "idx": 28, "version": "7", - "when": 1738454400000, - "tag": "0019_migrate-error-to-jsonb", + "when": 1775908800000, + "tag": "0028_add_report_templates", "breakpoints": true }, { - "idx": 13, + "idx": 29, "version": "7", - "when": 1762992000000, - "tag": "0026_add-run-status-cache", + "when": 1776484200000, + "tag": "0029_add_integration_natural_key", + "breakpoints": true + }, + { + "idx": 30, + "version": "7", + "when": 1777484161014, + "tag": "0030_add_security_ingestion_surfaces", + "breakpoints": true + }, + { + "idx": 31, + "version": "7", + "when": 1777484161015, + "tag": "0031_add_security_finding_links", + "breakpoints": true + }, + { + "idx": 32, + "version": "7", + "when": 1777484161016, + "tag": "0032_add_security_threat_stories", "breakpoints": true } ] diff --git a/backend/package.json b/backend/package.json index 1956e2874..d2f617ea0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,39 +4,50 @@ "type": "module", "packageManager": "bun@1.1.20", "scripts": { - "dev": "bun run migration:push --force && bun --watch src/main.ts", - "start": "bun run migration:push && bun run src/main.ts", + "dev": "bun --watch src/main.ts", + "start": "bun run src/main.ts", "start:prod": "bun run build/main.js", "build": "bun build src/main.ts --outdir build --target bun", "test": "bun test", "lint": "eslint .", "typecheck": "tsc --noEmit", "generate:openapi": "bun scripts/generate-openapi.ts", + "migration:generate": "bun x drizzle-kit generate", + "migration:migrate": "bun scripts/migrate-deploy.ts", + "migration:deploy": "bun scripts/migrate-deploy.ts", "migration:push": "bun x drizzle-kit push", + "migration:verify": "bun scripts/migration-verify.ts", + "stamp-baseline": "bun scripts/stamp-baseline.ts", "migration:smoke": "bun scripts/migration-smoke.ts", "delete:runs": "bun scripts/delete-all-workflow-runs.ts", - "setup:opensearch": "bun scripts/setup-opensearch.ts" + "seed:aws-workflow": "bun scripts/seed-aws-cspm-workflow.ts" }, "dependencies": { - "@clerk/backend": "^2.29.5", - "@clerk/types": "^4.101.13", + "@ai-sdk/openai": "^3.0.28", + "@aws-sdk/client-organizations": "^3.992.0", + "@aws-sdk/client-sts": "^3.992.0", + "@clickhouse/client": "^1.18.2", "@grpc/grpc-js": "^1.14.3", + "google-auth-library": "^10.5.0", + "@shipsec/hotplug-sdk": "^0.1.1", "@nest-lab/throttler-storage-redis": "^1.1.0", "@nestjs/common": "^10.4.22", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.4.22", + "@nestjs/event-emitter": "^3.0.1", "@nestjs/microservices": "^11.1.13", "@nestjs/platform-express": "^10.4.22", "@nestjs/swagger": "^11.2.5", "@nestjs/throttler": "^6.5.0", - "@opensearch-project/opensearch": "^3.5.1", "@shipsec/backend-client": "workspace:*", "@shipsec/component-sdk": "workspace:*", + "@shipsec/contracts": "workspace:*", "@shipsec/shared": "workspace:*", "@shipsec/studio-worker": "workspace:*", - "@temporalio/client": "^1.14.1", - "@temporalio/worker": "^1.14.1", - "@temporalio/workflow": "^1.14.1", + "@slack/web-api": "^7.15.0", + "@temporalio/client": "^1.16.0", + "@temporalio/worker": "^1.16.0", + "@temporalio/workflow": "^1.16.0", "@types/express": "^5.0.6", "@types/minio": "^7.1.1", "ai": "^6.0.49", @@ -52,10 +63,13 @@ "express": "^5.2.1", "ioredis": "^5.9.2", "kafkajs": "^2.2.4", + "kafkajs-snappy": "^1.1.0", + "liquidjs": "^10.19.1", "long": "^5.3.2", "minio": "^8.0.6", "mqtt": "^5.15.0", "multer": "^2.0.2", + "neo4j-driver": "^6.0.1", "nestjs-zod": "^5.1.1", "pg": "^8.17.2", "posthog-node": "^5.24.2", @@ -66,6 +80,7 @@ "devDependencies": { "@eslint/js": "^9.39.2", "@nestjs/testing": "^10.4.22", + "@testcontainers/clickhouse": "^11.12.0", "@types/bcryptjs": "^3.0.0", "@types/cookie-parser": "^1.4.10", "@types/express-serve-static-core": "^4.19.8", @@ -85,6 +100,7 @@ "globals": "^17.1.0", "prettier": "^3.8.1", "supertest": "^7.2.2", + "testcontainers": "^11.12.0", "typescript": "^5.9.3", "typescript-eslint": "^8.53.1" } diff --git a/backend/scripts/generate-openapi.ts b/backend/scripts/generate-openapi.ts index fd39d0c55..acce47d5f 100644 --- a/backend/scripts/generate-openapi.ts +++ b/backend/scripts/generate-openapi.ts @@ -11,6 +11,9 @@ async function generateOpenApi() { // Skip ingest services that require external connections during OpenAPI generation process.env.SKIP_INGEST_SERVICES = 'true'; process.env.SHIPSEC_SKIP_MIGRATION_CHECK = 'true'; + process.env.SHIPSEC_ALLOW_FILE_ENV_FALLBACK = 'false'; + process.env.SHIPSEC_CLOUD_BACKEND_MODULE = ''; + process.env.SHIPSEC_PROVIDER_OVERRIDES_MODULE = ''; // Ensure encryption services can bootstrap during schema generation. // This key is only used to construct the Nest application for OpenAPI output. process.env.SECRET_STORE_MASTER_KEY = @@ -45,7 +48,10 @@ async function generateOpenApi() { console.log('Script started'); generateOpenApi() - .then(() => console.log('Script finished successfully')) + .then(() => { + console.log('Script finished successfully'); + process.exit(0); + }) .catch((error) => { console.error('Failed to generate OpenAPI spec', error); process.exit(1); diff --git a/backend/scripts/lib/__tests__/migrations.test.ts b/backend/scripts/lib/__tests__/migrations.test.ts new file mode 100644 index 000000000..5f7445aa2 --- /dev/null +++ b/backend/scripts/lib/__tests__/migrations.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join, resolve } from 'path'; + +import { getConfiguredDrizzleDirs, loadAllMigrations, type JournalEntry } from '../migrations'; + +function writeDrizzleDir( + rootDir: string, + relativeDir: string, + entries: (JournalEntry & { sql: string })[], +): string { + const drizzleDir = join(rootDir, relativeDir); + mkdirSync(join(drizzleDir, 'meta'), { recursive: true }); + + writeFileSync( + join(drizzleDir, 'meta', '_journal.json'), + JSON.stringify( + { + version: '7', + dialect: 'postgresql', + entries: entries.map(({ sql: _sql, ...entry }) => ({ + ...entry, + version: '7', + breakpoints: true, + })), + }, + null, + 2, + ) + '\n', + ); + + for (const entry of entries) { + writeFileSync(join(drizzleDir, `${entry.tag}.sql`), entry.sql); + } + + return drizzleDir; +} + +describe('migration loader', () => { + let tempRoot: string; + let previousExtraDirs: string | undefined; + + beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), 'shipsec-migrations-')); + previousExtraDirs = process.env.SHIPSEC_EXTRA_DRIZZLE_DIRS; + delete process.env.SHIPSEC_EXTRA_DRIZZLE_DIRS; + }); + + afterEach(() => { + if (previousExtraDirs === undefined) { + delete process.env.SHIPSEC_EXTRA_DRIZZLE_DIRS; + } else { + process.env.SHIPSEC_EXTRA_DRIZZLE_DIRS = previousExtraDirs; + } + rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('includes the base drizzle dir and any existing overlay drizzle dirs', () => { + const baseDir = writeDrizzleDir(tempRoot, 'backend/drizzle', [ + { + idx: 0, + when: 100, + tag: '0000_public', + sql: '-- public migration\nselect 1;\n', + }, + ]); + const overlayDir = writeDrizzleDir(tempRoot, 'cloud/drizzle', [ + { + idx: 0, + when: 200, + tag: '0000_cloud', + sql: '-- cloud migration\nselect 2;\n', + }, + ]); + + process.env.SHIPSEC_EXTRA_DRIZZLE_DIRS = [ + overlayDir, + join(tempRoot, 'missing', 'drizzle'), + overlayDir, + ].join(','); + + const resolved = getConfiguredDrizzleDirs(baseDir); + + expect(resolved).toEqual([resolve(baseDir), resolve(overlayDir)]); + expect(loadAllMigrations(baseDir).map((migration) => migration.tag)).toEqual([ + '0000_public', + '0000_cloud', + ]); + }); + + it('keeps the public journal order first and appends overlay journals in configured order', () => { + const baseDir = writeDrizzleDir(tempRoot, 'backend/drizzle', [ + { + idx: 1, + when: 300, + tag: '0001_public_b', + sql: '-- public second\nselect 2;\n', + }, + { + idx: 0, + when: 100, + tag: '0000_public_a', + sql: '-- public first\nselect 1;\n', + }, + ]); + + const overlayDir = writeDrizzleDir(tempRoot, 'cloud/drizzle', [ + { + idx: 0, + when: 50, + tag: '0000_cloud_first', + sql: '-- cloud same time\nselect 3;\n', + }, + { + idx: 1, + when: 25, + tag: '0001_cloud_second', + sql: '-- cloud second\nselect 4;\n', + }, + ]); + + process.env.SHIPSEC_EXTRA_DRIZZLE_DIRS = overlayDir; + + const ordered = loadAllMigrations(baseDir).map((migration) => ({ + tag: migration.tag, + when: migration.when, + file: migration.file, + dir: migration.dir, + })); + + expect(ordered.map((migration) => migration.tag)).toEqual([ + '0000_public_a', + '0001_public_b', + '0000_cloud_first', + '0001_cloud_second', + ]); + }); +}); diff --git a/backend/scripts/lib/migrations.ts b/backend/scripts/lib/migrations.ts new file mode 100644 index 000000000..ee48a6395 --- /dev/null +++ b/backend/scripts/lib/migrations.ts @@ -0,0 +1,63 @@ +import { createHash } from 'crypto'; +import { existsSync, readFileSync } from 'fs'; +import { resolve } from 'path'; + +export interface JournalEntry { + idx: number; + when: number; + tag: string; +} + +interface Journal { + entries?: JournalEntry[]; +} + +export interface LoadedMigration extends JournalEntry { + dir: string; + file: string; + sql: string; + hash: string; +} + +export function getConfiguredDrizzleDirs(baseDir: string): string[] { + const extraDirs = (process.env.SHIPSEC_EXTRA_DRIZZLE_DIRS ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => resolve(process.cwd(), entry)); + + const ordered = [resolve(baseDir), ...extraDirs]; + return [...new Set(ordered)].filter((dir) => existsSync(resolve(dir, 'meta/_journal.json'))); +} + +export function readJournalEntries(drizzleDir: string): JournalEntry[] { + const journalPath = resolve(drizzleDir, 'meta/_journal.json'); + const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as Journal; + return [...(journal.entries ?? [])].sort((a, b) => a.idx - b.idx); +} + +export function loadMigrations(drizzleDir: string): LoadedMigration[] { + const entries = readJournalEntries(drizzleDir); + + if (entries.length === 0) { + throw new Error(`No migration entries found in ${resolve(drizzleDir, 'meta/_journal.json')}`); + } + + return entries.map((entry) => { + const file = `${entry.tag}.sql`; + const sql = readFileSync(resolve(drizzleDir, file), 'utf8'); + const hash = createHash('sha256').update(sql).digest('hex'); + + return { + ...entry, + dir: drizzleDir, + file, + sql, + hash, + }; + }); +} + +export function loadAllMigrations(baseDir: string): LoadedMigration[] { + return getConfiguredDrizzleDirs(baseDir).flatMap((dir) => loadMigrations(dir)); +} diff --git a/backend/scripts/migrate-deploy.ts b/backend/scripts/migrate-deploy.ts new file mode 100644 index 000000000..2c09cdf2f --- /dev/null +++ b/backend/scripts/migrate-deploy.ts @@ -0,0 +1,123 @@ +import { resolve } from 'path'; +import { Pool, type PoolClient } from 'pg'; +import { loadAllMigrations, type LoadedMigration } from './lib/migrations'; + +const DEFAULT_DATABASE_URL = 'postgresql://shipsec:shipsec@localhost:5433/shipsec'; + +async function ensureMigrationsTable(client: PoolClient): Promise { + await client.query('CREATE SCHEMA IF NOT EXISTS drizzle'); + await client.query(` + CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations ( + id SERIAL PRIMARY KEY, + hash text NOT NULL, + created_at bigint + ) + `); +} + +async function getKnownHashes(client: PoolClient): Promise> { + const { rows } = await client.query<{ hash: string }>( + 'SELECT hash FROM drizzle.__drizzle_migrations', + ); + return new Set(rows.map((row) => row.hash)); +} + +async function hasPublicTables(client: PoolClient): Promise { + const { rows } = await client.query<{ exists: boolean }>(` + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + ) AS exists + `); + + return rows[0]?.exists ?? false; +} + +async function stampBaselineIfNeeded( + client: PoolClient, + baseline: LoadedMigration, + knownHashes: Set, +): Promise { + if (knownHashes.has(baseline.hash)) { + return; + } + + const publicTablesExist = await hasPublicTables(client); + if (!publicTablesExist) { + return; + } + + await client.query( + `INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ($1, $2)`, + [baseline.hash, baseline.when], + ); + knownHashes.add(baseline.hash); + + console.log(`✅ Baseline stamped (${baseline.file}).`); +} + +async function applyMigration( + client: PoolClient, + migration: LoadedMigration, + knownHashes: Set, +): Promise { + if (knownHashes.has(migration.hash)) { + console.log(`↷ Skipping already-applied migration ${migration.file}`); + return; + } + + console.log(`→ Applying ${migration.file}`); + + await client.query('BEGIN'); + try { + await client.query(migration.sql); + await client.query( + `INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ($1, $2)`, + [migration.hash, migration.when], + ); + await client.query('COMMIT'); + knownHashes.add(migration.hash); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } +} + +async function main() { + const connectionString = process.env.DATABASE_URL || DEFAULT_DATABASE_URL; + const drizzleDir = resolve(__dirname, '../drizzle'); + const migrations = loadAllMigrations(drizzleDir); + + const pool = new Pool({ connectionString }); + const client = await pool.connect(); + + try { + await ensureMigrationsTable(client); + + const knownHashes = await getKnownHashes(client); + + const [baseline] = migrations; + if (!baseline) { + throw new Error('No baseline migration found in journal entries.'); + } + + await stampBaselineIfNeeded(client, baseline, knownHashes); + + for (const migration of migrations) { + await applyMigration(client, migration, knownHashes); + } + + console.log(`✅ Migration deploy complete (${migrations.length} migration(s) tracked).`); + } finally { + client.release(); + await pool.end(); + } +} + +main().catch((error) => { + console.error('❌ migration:deploy failed'); + console.error(error); + process.exit(1); +}); diff --git a/backend/scripts/migration-smoke.ts b/backend/scripts/migration-smoke.ts index c9ba2174b..83eccc445 100644 --- a/backend/scripts/migration-smoke.ts +++ b/backend/scripts/migration-smoke.ts @@ -1,6 +1,6 @@ -import { readFileSync, readdirSync } from 'fs'; -import { join, resolve } from 'path'; +import { resolve } from 'path'; import { Pool } from 'pg'; +import { loadAllMigrations } from './lib/migrations'; async function main() { const connectionString = @@ -9,18 +9,15 @@ async function main() { const pool = new Pool({ connectionString }); const client = await pool.connect(); const migrationsDir = resolve(__dirname, '../drizzle'); - const files = readdirSync(migrationsDir) - .filter((file) => /^\d+_.*\.sql$/i.test(file)) - .sort(); + const migrations = loadAllMigrations(migrationsDir); - console.log(`🧪 Migration smoke test starting (found ${files.length} files)`); + console.log(`🧪 Migration smoke test starting (found ${migrations.length} files)`); try { await client.query('BEGIN'); - for (const file of files) { - const sql = readFileSync(join(migrationsDir, file), 'utf8'); - console.log(`→ Applying ${file}`); - await client.query(sql); + for (const migration of migrations) { + console.log(`→ Applying ${migration.file}`); + await client.query(migration.sql); } await client.query('ROLLBACK'); console.log('✅ Migration smoke test passed (changes rolled back)'); diff --git a/backend/scripts/migration-verify.ts b/backend/scripts/migration-verify.ts new file mode 100644 index 000000000..7d28420dc --- /dev/null +++ b/backend/scripts/migration-verify.ts @@ -0,0 +1,44 @@ +import { resolve } from 'path'; +import { Pool } from 'pg'; +import { loadAllMigrations } from './lib/migrations'; + +const DEFAULT_DATABASE_URL = 'postgresql://shipsec:shipsec@localhost:5433/shipsec'; + +async function main() { + const connectionString = process.env.DATABASE_URL || DEFAULT_DATABASE_URL; + const drizzleDir = resolve(__dirname, '../drizzle'); + const expected = new Map( + loadAllMigrations(drizzleDir).map((migration) => [migration.tag, migration.hash]), + ); + + const pool = new Pool({ connectionString }); + + try { + const { rows } = await pool.query<{ hash: string }>( + 'SELECT hash FROM drizzle.__drizzle_migrations ORDER BY id ASC', + ); + + const applied = new Set(rows.map((row) => row.hash)); + const missing = [...expected.entries()] + .filter(([, hash]) => !applied.has(hash)) + .map(([tag]) => tag); + + if (missing.length > 0) { + throw new Error( + `Database is missing repo migrations: [${missing.join(', ')}]. Run \`bun run migration:deploy\` before rollout.`, + ); + } + + console.log( + `✅ Migration verify complete (${expected.size} repo migration(s) present in database).`, + ); + } finally { + await pool.end(); + } +} + +main().catch((error) => { + console.error('❌ migration:verify failed'); + console.error(error); + process.exit(1); +}); diff --git a/backend/scripts/seed-aws-cspm-workflow.ts b/backend/scripts/seed-aws-cspm-workflow.ts new file mode 100644 index 000000000..50cb952f4 --- /dev/null +++ b/backend/scripts/seed-aws-cspm-workflow.ts @@ -0,0 +1,61 @@ +/** + * Seed script: imports the AWS CSPM Org Discovery workflow via the backend API. + * + * Usage: + * bun backend/scripts/seed-aws-cspm-workflow.ts + * + * The backend must be running on BACKEND_URL (default http://localhost:3001). + * Set ADMIN_USERNAME / ADMIN_PASSWORD env vars if admin auth is required, + * or CLERK_SESSION_TOKEN for Clerk-based auth. + */ + +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:3001'; +const ADMIN_USER = process.env.ADMIN_USERNAME ?? 'admin'; +const ADMIN_PASS = process.env.ADMIN_PASSWORD ?? 'admin'; + +async function main() { + const workflowPath = resolve(import.meta.dir, '../../docs/sample/aws-cspm-org-discovery.json'); + + const workflowJson = JSON.parse(readFileSync(workflowPath, 'utf-8')); + console.log(`Importing workflow: ${workflowJson.name}`); + console.log(` Nodes: ${workflowJson.nodes.length}`); + console.log(` Edges: ${workflowJson.edges.length}`); + + // Build auth headers — try Clerk token first, then fall back to basic auth + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (process.env.CLERK_SESSION_TOKEN) { + headers['Authorization'] = `Bearer ${process.env.CLERK_SESSION_TOKEN}`; + } else { + headers['Authorization'] = `Basic ${btoa(`${ADMIN_USER}:${ADMIN_PASS}`)}`; + } + + const res = await fetch(`${BACKEND_URL}/api/v1/workflows`, { + method: 'POST', + headers, + body: JSON.stringify(workflowJson), + }); + + if (!res.ok) { + const body = await res.text(); + console.error(`Failed to create workflow (${res.status}): ${body}`); + process.exit(1); + } + + const created = await res.json(); + console.log(`\nWorkflow created successfully!`); + console.log(` ID: ${created.id}`); + console.log(` Name: ${created.name}`); + console.log(` Version: ${created.currentVersion}`); + console.log(`\nOpen in dashboard: http://localhost:5173/workflows/${created.id}`); +} + +main().catch((err) => { + console.error('Seed failed:', err); + process.exit(1); +}); diff --git a/backend/scripts/seed-private-stress-test.ts b/backend/scripts/seed-private-stress-test.ts new file mode 100644 index 000000000..2b35ea295 --- /dev/null +++ b/backend/scripts/seed-private-stress-test.ts @@ -0,0 +1,1127 @@ +import { createClient } from '@clickhouse/client'; +import { Pool } from 'pg'; +import { randomUUID } from 'crypto'; +import { config } from 'dotenv'; + +// Load environment variables +config(); + +// ─── Configuration ─────────────────────────────────────────────────────────── + +const ORG_ID = process.env.SEED_ORG_ID || 'stress-test'; + +interface TierConfig { + findings: number; +} + +const TIERS: Record = { + small: { findings: 500 }, + medium: { findings: 5_000 }, + large: { findings: 50_000 }, +}; + +// ─── Utilities ─────────────────────────────────────────────────────────────── + +function randInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +function pick(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +function pickN(arr: T[], n: number): T[] { + const shuffled = [...arr].sort(() => Math.random() - 0.5); + return shuffled.slice(0, Math.min(n, arr.length)); +} + +function pickWeighted(dist: [string, number][]): string { + const r = Math.random(); + let cumulative = 0; + for (const [value, weight] of dist) { + cumulative += weight; + if (r <= cumulative) return value; + } + return dist[dist.length - 1][0]; +} + +/** + * Generate a date within `daysBack` of now, weighted toward recent dates. + * Uses a quadratic bias: random^2 makes values cluster near 0 (= now). + */ +function recentWeightedDate(daysBack: number): Date { + const bias = Math.pow(Math.random(), 2); // clusters near 0 + const msBack = bias * daysBack * 24 * 60 * 60 * 1000; + return new Date(Date.now() - msBack); +} + +function shortUUID(): string { + return randomUUID().split('-')[0]; +} + +// ─── Scanner Definitions ───────────────────────────────────────────────────── + +// Weighted distribution (must sum to ~1.0) +const SCANNER_DIST: [string, number][] = [ + ['prowler', 0.3], + ['opengrep', 0.15], + ['dependency', 0.12], + ['trivy', 0.1], + ['trufflehog', 0.08], + ['trivy-container', 0.08], + ['grype', 0.07], + ['nuclei', 0.05], + ['supabase-scanner', 0.05], +]; + +const SEVERITY_DIST: [string, number][] = [ + ['critical', 0.08], + ['high', 0.22], + ['medium', 0.4], + ['low', 0.25], + ['info', 0.05], +]; + +const STATUS_DIST: [string, number][] = [ + ['FAIL', 0.65], + ['PASS', 0.2], + ['MUTED', 0.1], + ['MANUAL', 0.05], +]; + +// ─── Cloud Scanner Data ────────────────────────────────────────────────────── + +const AWS_REGIONS = ['us-east-1', 'us-west-2', 'eu-west-1', 'eu-central-1', 'ap-southeast-1']; + +const AWS_ACCOUNTS = [ + { id: '123456789012', name: 'production' }, + { id: '234567890123', name: 'staging' }, + { id: '345678901234', name: 'development' }, +]; + +const AWS_SERVICES = ['s3', 'ec2', 'iam', 'rds', 'lambda', 'cloudfront', 'kms', 'sns', 'sqs']; + +// Prowler checks organized by service for logical consistency +const PROWLER_CHECKS: Record = { + iam: [ + { + check: 'iam_root_mfa_enabled', + title: 'Root account does not have MFA enabled', + remediation: 'Enable MFA for the root account using a hardware or virtual MFA device.', + }, + { + check: 'iam_password_policy_uppercase', + title: 'IAM password policy does not require uppercase letters', + remediation: 'Update the IAM password policy to require at least one uppercase letter.', + }, + { + check: 'iam_user_console_access_unused', + title: 'IAM user has console access but has not logged in for 90+ days', + remediation: 'Disable console access for inactive IAM users or remove the user.', + }, + { + check: 'iam_policy_allows_full_admin', + title: 'IAM policy grants full administrative access (*:*)', + remediation: + 'Replace wildcard policies with least-privilege permissions specific to the workload.', + }, + { + check: 'iam_access_key_rotation', + title: 'IAM access key has not been rotated in over 90 days', + remediation: 'Rotate the access key and update applications to use the new key.', + }, + ], + s3: [ + { + check: 's3_bucket_public_access', + title: 'S3 bucket has public access enabled', + remediation: 'Enable S3 Block Public Access settings on the bucket.', + }, + { + check: 's3_bucket_versioning_enabled', + title: 'S3 bucket does not have versioning enabled', + remediation: 'Enable versioning on the S3 bucket to protect against accidental deletion.', + }, + { + check: 's3_bucket_encryption_at_rest', + title: 'S3 bucket does not have server-side encryption enabled', + remediation: 'Enable default server-side encryption (SSE-S3 or SSE-KMS) on the bucket.', + }, + { + check: 's3_bucket_logging_enabled', + title: 'S3 bucket access logging is not enabled', + remediation: 'Enable server access logging to track requests for security auditing.', + }, + ], + ec2: [ + { + check: 'ec2_security_group_open_to_world', + title: 'Security group allows unrestricted ingress (0.0.0.0/0)', + remediation: + 'Restrict security group rules to specific IP ranges required by your application.', + }, + { + check: 'ec2_instance_imdsv2_required', + title: 'EC2 instance does not require IMDSv2', + remediation: 'Configure the instance to require IMDSv2 to prevent SSRF token theft.', + }, + { + check: 'ec2_ebs_volume_encryption', + title: 'EBS volume is not encrypted', + remediation: 'Enable encryption on EBS volumes using AWS-managed or customer-managed keys.', + }, + ], + rds: [ + { + check: 'rds_instance_public_access', + title: 'RDS instance is publicly accessible', + remediation: 'Modify the RDS instance to disable public accessibility.', + }, + { + check: 'rds_instance_encryption_enabled', + title: 'RDS instance storage is not encrypted', + remediation: 'Create an encrypted snapshot and restore to a new encrypted instance.', + }, + { + check: 'rds_instance_backup_enabled', + title: 'RDS instance does not have automated backups enabled', + remediation: 'Enable automated backups with an appropriate retention period (7+ days).', + }, + ], + lambda: [ + { + check: 'lambda_function_public_access', + title: 'Lambda function has a resource-based policy allowing public invoke', + remediation: 'Update the resource-based policy to restrict access to specific principals.', + }, + { + check: 'lambda_function_runtime_deprecated', + title: 'Lambda function uses a deprecated runtime', + remediation: 'Update the function to use a supported runtime version.', + }, + ], + cloudfront: [ + { + check: 'cloudfront_distribution_https_only', + title: 'CloudFront distribution does not enforce HTTPS', + remediation: 'Update the distribution to redirect HTTP to HTTPS or use HTTPS-only.', + }, + ], + kms: [ + { + check: 'kms_key_rotation_enabled', + title: 'KMS key does not have automatic rotation enabled', + remediation: 'Enable automatic key rotation for symmetric KMS keys.', + }, + { + check: 'kms_key_not_scheduled_for_deletion', + title: 'KMS key is scheduled for deletion', + remediation: 'Cancel the key deletion and review if the key is still needed.', + }, + ], + sns: [ + { + check: 'sns_topic_encryption_enabled', + title: 'SNS topic does not have server-side encryption enabled', + remediation: 'Enable SSE for the SNS topic using an AWS KMS key.', + }, + ], + sqs: [ + { + check: 'sqs_queue_encryption_enabled', + title: 'SQS queue does not have server-side encryption enabled', + remediation: 'Enable SSE for the SQS queue using an AWS KMS key.', + }, + { + check: 'sqs_queue_public_access', + title: 'SQS queue policy allows public access', + remediation: 'Restrict the SQS queue policy to specific AWS accounts or IAM principals.', + }, + ], +}; + +const COMPLIANCE_FRAMEWORKS = ['CIS', 'PCI-DSS', 'SOC2', 'HIPAA', 'NIST-800-53', 'ISO-27001']; + +const FINDING_TYPES = [ + 'Software and Configuration Checks', + 'TTPs', + 'Sensitive Data Identifications', + 'Effects', + 'Unusual Behaviors', +]; + +const RESOURCE_TAGS_POOL = { + Environment: ['production', 'staging', 'development', 'sandbox'], + Team: ['platform', 'backend', 'frontend', 'security', 'data', 'devops', 'sre'], + Project: ['core-api', 'auth-service', 'data-pipeline', 'web-app', 'mobile-backend', 'analytics'], + CostCenter: ['eng-100', 'eng-200', 'eng-300', 'ops-100', 'ops-200'], + Owner: ['alice@company.com', 'bob@company.com', 'carol@company.com', 'dave@company.com'], +}; + +// ─── Code Scanner Data ─────────────────────────────────────────────────────── + +const CODE_FILES = [ + 'src/api/auth.ts', + 'src/api/users.ts', + 'src/api/payments.ts', + 'src/middleware/cors.ts', + 'src/middleware/rate-limit.ts', + 'src/services/database.ts', + 'src/services/cache.ts', + 'src/services/email.ts', + 'src/utils/crypto.ts', + 'src/utils/validators.ts', + 'config/database.yml', + 'config/redis.yml', + 'config/nginx.conf', + 'docker-compose.yml', + 'Dockerfile', + 'scripts/deploy.sh', + 'scripts/backup.sh', + 'terraform/main.tf', + 'terraform/variables.tf', + 'terraform/iam.tf', + 'k8s/deployment.yaml', + 'k8s/service.yaml', + 'k8s/ingress.yaml', + '.github/workflows/ci.yml', + '.github/workflows/deploy.yml', +]; + +const OPENGREP_RULES: { rule: string; title: string; description: string }[] = [ + { + rule: 'typescript.security.sql-injection', + title: 'Potential SQL injection via string concatenation', + description: + 'User-supplied data is concatenated into a SQL query string without parameterization, which could allow SQL injection.', + }, + { + rule: 'typescript.security.xss-reflected', + title: 'Reflected cross-site scripting (XSS) in response', + description: + 'User input is included directly in the HTTP response without escaping, enabling reflected XSS attacks.', + }, + { + rule: 'typescript.security.hardcoded-secret', + title: 'Hardcoded secret or API key detected in source code', + description: + 'A secret or API key appears to be hardcoded. Move it to environment variables or a secrets manager.', + }, + { + rule: 'typescript.security.insecure-random', + title: 'Use of Math.random() for security-sensitive operation', + description: + 'Math.random() is not cryptographically secure. Use crypto.randomBytes() or crypto.randomUUID() for tokens and secrets.', + }, + { + rule: 'typescript.security.path-traversal', + title: 'Potential path traversal via unsanitized user input', + description: + 'User input is used to construct a file path without sanitization, which could allow reading or writing arbitrary files.', + }, + { + rule: 'typescript.security.command-injection', + title: 'OS command injection via unsanitized input', + description: + 'User-supplied data is passed to a shell command without sanitization. Use parameterized exec or avoid shell execution.', + }, + { + rule: 'typescript.security.insecure-cookie', + title: 'Cookie set without Secure or HttpOnly flag', + description: + 'Session cookies should be set with Secure, HttpOnly, and SameSite attributes to prevent theft and CSRF.', + }, + { + rule: 'typescript.security.eval-usage', + title: 'Dynamic code execution via eval()', + description: + 'eval() executes arbitrary code and should be avoided. Use safer alternatives like JSON.parse() or a sandboxed interpreter.', + }, + { + rule: 'yaml.security.exposed-port', + title: 'Container exposes sensitive port to host network', + description: + 'A Docker container maps an internal port to the host, which could expose services unintentionally in production.', + }, + { + rule: 'terraform.security.iam-wildcard-action', + title: 'IAM policy uses wildcard (*) action', + description: + 'Using wildcard actions in IAM policies grants excessive permissions. Follow least-privilege principle.', + }, +]; + +const TRUFFLEHOG_SECRETS: { type: string; title: string; description: string }[] = [ + { + type: 'AWS', + title: 'AWS access key ID found in source code', + description: + 'An AWS access key ID (AKIA...) was detected. Rotate the key immediately and use IAM roles or environment variables.', + }, + { + type: 'GitHub', + title: 'GitHub personal access token detected', + description: + 'A GitHub PAT (ghp_...) was found. Revoke the token and use fine-grained tokens with minimal scopes.', + }, + { + type: 'Slack', + title: 'Slack webhook URL or bot token exposed', + description: + 'A Slack token (xoxb-...) or webhook URL was detected. Regenerate the token from Slack admin settings.', + }, + { + type: 'Stripe', + title: 'Stripe secret API key found', + description: + 'A Stripe secret key (sk_live_...) was detected. Roll the key in the Stripe dashboard immediately.', + }, + { + type: 'SendGrid', + title: 'SendGrid API key exposed in source', + description: + 'A SendGrid API key (SG....) was found. Revoke and regenerate the key from the SendGrid console.', + }, + { + type: 'PrivateKey', + title: 'RSA/EC private key detected in file', + description: + 'A private key was found in the source tree. Remove it from version control and use a secrets manager.', + }, + { + type: 'Generic', + title: 'High-entropy string resembling a secret or password', + description: + 'A high-entropy string that may be a credential was detected. Verify and move to a secrets manager if confirmed.', + }, + { + type: 'JWT', + title: 'JWT signing secret hardcoded in source', + description: + 'A JWT secret or HMAC key is hardcoded. Move it to environment variables or a vault.', + }, +]; + +// ─── Dependency Scanner Data ───────────────────────────────────────────────── + +const DEPENDENCY_PACKAGES: { + name: string; + version: string; + fixedVersion: string; + cve: string; + title: string; + description: string; +}[] = [ + { + name: 'lodash', + version: '4.17.20', + fixedVersion: '4.17.21', + cve: 'CVE-2021-23337', + title: 'Prototype Pollution in lodash', + description: + 'lodash before 4.17.21 is vulnerable to command injection via the template function.', + }, + { + name: 'express', + version: '4.17.1', + fixedVersion: '4.18.2', + cve: 'CVE-2022-24999', + title: 'Open redirect vulnerability in express', + description: + 'Express.js before 4.18.2 allows open redirects when using untrusted URLs in res.redirect().', + }, + { + name: 'jsonwebtoken', + version: '8.5.1', + fixedVersion: '9.0.0', + cve: 'CVE-2022-23529', + title: 'Insecure default algorithm in jsonwebtoken', + description: + 'jsonwebtoken before 9.0.0 allows algorithm confusion attacks when the secretOrPublicKey is not specified.', + }, + { + name: 'axios', + version: '0.21.1', + fixedVersion: '0.21.2', + cve: 'CVE-2021-3749', + title: 'Server-Side Request Forgery in axios', + description: + 'axios before 0.21.2 is vulnerable to SSRF when following redirects to internal network addresses.', + }, + { + name: 'minimatch', + version: '3.0.4', + fixedVersion: '3.0.5', + cve: 'CVE-2022-3517', + title: 'ReDoS vulnerability in minimatch', + description: + 'minimatch before 3.0.5 is vulnerable to Regular Expression Denial of Service via long brace patterns.', + }, + { + name: 'tar', + version: '6.1.10', + fixedVersion: '6.1.12', + cve: 'CVE-2022-33987', + title: 'Arbitrary file creation via symlink in tar', + description: + 'tar before 6.1.12 allows arbitrary file creation outside the extraction directory via symbolic links.', + }, + { + name: 'node-fetch', + version: '2.6.6', + fixedVersion: '2.6.7', + cve: 'CVE-2022-0235', + title: 'Exposure of sensitive information in node-fetch', + description: + 'node-fetch before 2.6.7 forwards authorization headers on cross-origin redirects.', + }, + { + name: 'glob-parent', + version: '5.1.1', + fixedVersion: '5.1.2', + cve: 'CVE-2020-28469', + title: 'ReDoS in glob-parent', + description: 'glob-parent before 5.1.2 is vulnerable to Regular Expression Denial of Service.', + }, + { + name: 'xml2js', + version: '0.4.23', + fixedVersion: '0.5.0', + cve: 'CVE-2023-0842', + title: 'Prototype pollution in xml2js', + description: + 'xml2js before 0.5.0 is vulnerable to prototype pollution when parsing specially crafted XML.', + }, + { + name: 'semver', + version: '7.3.7', + fixedVersion: '7.5.2', + cve: 'CVE-2022-25883', + title: 'ReDoS in semver range parsing', + description: + 'semver before 7.5.2 is vulnerable to Regular Expression Denial of Service in range parsing.', + }, + { + name: 'tough-cookie', + version: '4.1.2', + fixedVersion: '4.1.3', + cve: 'CVE-2023-26136', + title: 'Prototype pollution in tough-cookie', + description: + 'tough-cookie before 4.1.3 is vulnerable to prototype pollution via CookieJar.setCookie.', + }, + { + name: 'word-wrap', + version: '1.2.3', + fixedVersion: '1.2.4', + cve: 'CVE-2023-26115', + title: 'ReDoS in word-wrap', + description: 'word-wrap before 1.2.4 is vulnerable to Regular Expression Denial of Service.', + }, +]; + +// ─── Container Scanner Data ────────────────────────────────────────────────── + +const CONTAINER_IMAGES = [ + 'nginx:1.21', + 'nginx:1.23-alpine', + 'node:18-alpine', + 'node:16-bullseye', + 'python:3.11-slim', + 'python:3.9-alpine', + 'postgres:14-alpine', + 'postgres:15', + 'redis:7-alpine', + 'mongo:6', + 'golang:1.21-alpine', + 'ubuntu:22.04', + 'debian:bullseye-slim', + 'alpine:3.18', + 'openjdk:17-slim', +]; + +const CONTAINER_VULNS: { + pkg: string; + version: string; + fixedVersion: string; + cve: string; + title: string; +}[] = [ + { + pkg: 'openssl', + version: '3.0.8', + fixedVersion: '3.0.10', + cve: 'CVE-2023-2650', + title: 'Excessive processing time in OpenSSL ASN.1 object identifier verification', + }, + { + pkg: 'libcurl', + version: '7.88.0', + fixedVersion: '8.1.0', + cve: 'CVE-2023-27534', + title: 'SFTP path resolving vulnerability in libcurl', + }, + { + pkg: 'zlib', + version: '1.2.11', + fixedVersion: '1.2.13', + cve: 'CVE-2022-37434', + title: 'Heap-based buffer over-read in zlib inflate', + }, + { + pkg: 'libexpat', + version: '2.4.8', + fixedVersion: '2.5.0', + cve: 'CVE-2022-43680', + title: 'Use-after-free in libexpat XML parser', + }, + { + pkg: 'glibc', + version: '2.35', + fixedVersion: '2.36', + cve: 'CVE-2023-4911', + title: 'Buffer overflow in glibc ld.so (Looney Tunables)', + }, + { + pkg: 'krb5-libs', + version: '1.19.3', + fixedVersion: '1.20.1', + cve: 'CVE-2022-42898', + title: 'Integer overflow in krb5 PAC parsing', + }, + { + pkg: 'busybox', + version: '1.35.0', + fixedVersion: '1.36.0', + cve: 'CVE-2022-48174', + title: 'Stack overflow vulnerability in busybox ash', + }, + { + pkg: 'libxml2', + version: '2.9.14', + fixedVersion: '2.10.3', + cve: 'CVE-2023-28484', + title: 'NULL pointer dereference in libxml2 xmlSchemaValidate', + }, +]; + +// ─── Nuclei Scanner Data ───────────────────────────────────────────────────── + +const NUCLEI_TARGETS = [ + 'https://api.example.com', + 'https://app.example.com', + 'https://admin.example.com', + 'https://staging.example.com', + 'https://dev.example.com', + 'http://10.0.1.50:8080', + 'http://10.0.2.100:3000', + 'http://internal-api.local:8443', +]; + +const NUCLEI_TEMPLATES: { + templateId: string; + title: string; + description: string; + tags: string[]; +}[] = [ + { + templateId: 'http-missing-security-headers', + title: 'Missing security headers detected', + description: + 'The HTTP response is missing critical security headers (X-Frame-Options, X-Content-Type-Options, CSP).', + tags: ['misconfiguration', 'http', 'headers'], + }, + { + templateId: 'ssl-tls-version-detection', + title: 'Deprecated TLS version in use (TLS 1.0/1.1)', + description: + 'The server supports deprecated TLS versions that are vulnerable to known attacks (POODLE, BEAST).', + tags: ['ssl', 'tls', 'misconfiguration'], + }, + { + templateId: 'exposed-admin-panel', + title: 'Admin panel accessible without authentication', + description: + 'An administrative interface is exposed to the network without proper authentication controls.', + tags: ['exposure', 'admin', 'panel'], + }, + { + templateId: 'cors-misconfiguration', + title: 'Permissive CORS policy allows any origin', + description: + 'The server returns Access-Control-Allow-Origin: * which may allow unauthorized cross-origin data access.', + tags: ['misconfiguration', 'cors'], + }, + { + templateId: 'directory-listing-enabled', + title: 'Directory listing enabled on web server', + description: + 'Web server returns directory listings that may reveal sensitive files and internal structure.', + tags: ['exposure', 'directory', 'listing'], + }, + { + templateId: 'git-config-exposed', + title: '.git/config file accessible via web', + description: + 'The .git directory is accessible, potentially exposing source code, credentials, and commit history.', + tags: ['exposure', 'git', 'config'], + }, + { + templateId: 'open-redirect', + title: 'Open redirect vulnerability in URL parameter', + description: + 'The application redirects users to a URL specified in a parameter without validation, enabling phishing attacks.', + tags: ['vulnerability', 'redirect'], + }, + { + templateId: 'server-version-disclosure', + title: 'Server software version disclosed in headers', + description: + 'The Server header reveals specific software and version information that helps attackers target known vulnerabilities.', + tags: ['exposure', 'server', 'version'], + }, +]; + +// ─── Supabase Scanner Data ─────────────────────────────────────────────────── + +const SUPABASE_CHECKS: { + check: string; + title: string; + description: string; + service: string; +}[] = [ + { + check: 'supabase_rls_disabled', + title: 'Row Level Security (RLS) is disabled on a public table', + description: + 'A table exposed via the PostgREST API does not have Row Level Security enabled, allowing unrestricted data access.', + service: 'database', + }, + { + check: 'supabase_anon_key_exposed', + title: 'Supabase anon key used in client-side code with excessive permissions', + description: + 'The anon key is used client-side but policies grant more access than intended for anonymous users.', + service: 'auth', + }, + { + check: 'supabase_storage_public_bucket', + title: 'Storage bucket allows public file uploads without authentication', + description: + 'A Supabase storage bucket accepts file uploads from unauthenticated users, risking storage abuse.', + service: 'storage', + }, + { + check: 'supabase_function_no_auth', + title: 'Edge function invokable without authentication', + description: + 'A Supabase Edge Function does not verify the JWT token, allowing unauthenticated invocations.', + service: 'functions', + }, + { + check: 'supabase_mfa_not_enforced', + title: 'Multi-factor authentication not enforced for admin users', + description: 'Supabase project settings do not enforce MFA for users with elevated privileges.', + service: 'auth', + }, +]; + +// ─── Finding Generator ─────────────────────────────────────────────────────── + +interface WorkflowRef { + workflow_id: string; + workflow_name: string; + run_id: string; +} + +function generateResourceTags(): Record { + const tags: Record = {}; + // Always include Environment and Team + tags['Environment'] = pick(RESOURCE_TAGS_POOL.Environment); + tags['Team'] = pick(RESOURCE_TAGS_POOL.Team); + // 60% chance of Project tag + if (Math.random() < 0.6) tags['Project'] = pick(RESOURCE_TAGS_POOL.Project); + // 40% chance of CostCenter + if (Math.random() < 0.4) tags['CostCenter'] = pick(RESOURCE_TAGS_POOL.CostCenter); + // 30% chance of Owner + if (Math.random() < 0.3) tags['Owner'] = pick(RESOURCE_TAGS_POOL.Owner); + return tags; +} + +interface FindingRow { + organization_id: string; + run_id: string; + workflow_id: string; + workflow_name: string; + component_id: string; + node_ref: string; + scanner: string; + finding_hash: string; + severity: string; + status: string; + title: string; + description: string; + asset_key: string; + region: string; + account_id: string; + service_name: string; + check_name: string; + compliance: string; + resource_tags: string; + finding_types: string; + remediation_text: string; + recommendation_url: string; + timestamp: string; +} + +function generateFinding( + scanner: string, + wfRef: WorkflowRef, + findingHash: string, + timestamp: Date, +): FindingRow { + const severity = pickWeighted(SEVERITY_DIST); + const status = pickWeighted(STATUS_DIST); + + const row: FindingRow = { + organization_id: ORG_ID, + run_id: wfRef.run_id, + workflow_id: wfRef.workflow_id, + workflow_name: wfRef.workflow_name, + component_id: `security.${scanner}`, + node_ref: `node_${shortUUID()}`, + scanner, + finding_hash: findingHash, + severity, + status, + title: '', + description: '', + asset_key: '', + region: '', + account_id: '', + service_name: '', + check_name: '', + compliance: '', + resource_tags: '', + finding_types: '', + remediation_text: '', + recommendation_url: '', + timestamp: timestamp.toISOString().replace('T', ' ').replace('Z', ''), + }; + + switch (scanner) { + case 'prowler': + case 'supabase-scanner': { + const account = pick(AWS_ACCOUNTS); + const region = pick(AWS_REGIONS); + + if (scanner === 'prowler') { + const service = pick(AWS_SERVICES); + const checks = PROWLER_CHECKS[service] ?? PROWLER_CHECKS['iam']; + const checkDef = pick(checks); + + row.region = region; + row.account_id = account.id; + row.service_name = service; + row.check_name = checkDef.check; + row.title = checkDef.title; + row.description = `[${account.name}/${region}] ${checkDef.title}`; + row.compliance = pickN(COMPLIANCE_FRAMEWORKS, randInt(1, 3)).join(','); + row.resource_tags = JSON.stringify(generateResourceTags()); + row.finding_types = pickN(FINDING_TYPES, randInt(1, 2)).join(','); + row.remediation_text = checkDef.remediation; + row.recommendation_url = `https://docs.aws.amazon.com/securityhub/latest/userguide/${service}-checks.html`; + row.asset_key = `arn:aws:${service}:${region}:${account.id}:${shortUUID()}`; + } else { + const checkDef = pick(SUPABASE_CHECKS); + row.region = region; + row.account_id = account.id; + row.service_name = checkDef.service; + row.check_name = checkDef.check; + row.title = checkDef.title; + row.description = checkDef.description; + row.compliance = pickN(COMPLIANCE_FRAMEWORKS, randInt(1, 2)).join(','); + row.resource_tags = JSON.stringify(generateResourceTags()); + row.finding_types = 'Software and Configuration Checks'; + row.remediation_text = checkDef.description; + row.recommendation_url = 'https://supabase.com/docs/guides/platform/going-into-production'; + row.asset_key = `supabase://${checkDef.service}/${shortUUID()}`; + } + break; + } + + case 'opengrep': { + const rule = pick(OPENGREP_RULES); + const file = pick(CODE_FILES); + row.title = rule.title; + row.description = rule.description; + row.asset_key = file; + row.check_name = rule.rule; + row.remediation_text = `Fix the issue in ${file} by following the rule guidance.`; + row.recommendation_url = `https://semgrep.dev/r/${rule.rule}`; + break; + } + + case 'trufflehog': { + const secret = pick(TRUFFLEHOG_SECRETS); + const file = pick(CODE_FILES); + row.title = secret.title; + row.description = secret.description; + row.asset_key = file; + row.check_name = `trufflehog.${secret.type.toLowerCase()}`; + row.remediation_text = `Rotate the exposed ${secret.type} credential and remove it from source control.`; + row.recommendation_url = 'https://trufflesecurity.com/docs/remediation'; + break; + } + + case 'dependency': + case 'trivy': { + const dep = pick(DEPENDENCY_PACKAGES); + row.title = `${dep.cve}: ${dep.title}`; + row.description = dep.description; + row.asset_key = `${dep.name}@${dep.version}`; + row.check_name = dep.cve; + row.remediation_text = `Upgrade ${dep.name} from ${dep.version} to ${dep.fixedVersion} or later.`; + row.recommendation_url = `https://nvd.nist.gov/vuln/detail/${dep.cve}`; + break; + } + + case 'trivy-container': + case 'grype': { + const image = pick(CONTAINER_IMAGES); + const vuln = pick(CONTAINER_VULNS); + row.title = `${vuln.cve}: ${vuln.title}`; + row.description = `${vuln.title} (${vuln.pkg} ${vuln.version} in ${image})`; + row.asset_key = image; + row.check_name = vuln.cve; + row.remediation_text = `Update ${vuln.pkg} from ${vuln.version} to ${vuln.fixedVersion} in image ${image}.`; + row.recommendation_url = `https://nvd.nist.gov/vuln/detail/${vuln.cve}`; + break; + } + + case 'nuclei': { + const target = pick(NUCLEI_TARGETS); + const template = pick(NUCLEI_TEMPLATES); + row.title = template.title; + row.description = template.description; + row.asset_key = target; + row.check_name = template.templateId; + row.finding_types = template.tags.join(','); + row.remediation_text = template.description; + row.recommendation_url = `https://github.com/projectdiscovery/nuclei-templates/blob/main/${template.templateId}.yaml`; + break; + } + } + + return row; +} + +// ─── ClickHouse Bulk Insertion ──────────────────────────────────────────────── + +async function bulkInsert( + chClient: ReturnType, + rows: FindingRow[], +): Promise { + if (rows.length === 0) return 0; + + await chClient.insert({ + table: 'security_findings', + values: rows, + format: 'JSONEachRow', + }); + + return rows.length; +} + +// ─── Main Logic ────────────────────────────────────────────────────────────── + +async function loadWorkflowRefs(pgPool: Pool): Promise { + const client = await pgPool.connect(); + try { + const result = await client.query( + `SELECT w.id AS workflow_id, w.name AS workflow_name, r.run_id + FROM workflows w + INNER JOIN workflow_runs r ON r.workflow_id = w.id + WHERE w.organization_id = $1 + ORDER BY r.created_at DESC + LIMIT 5000`, + [ORG_ID], + ); + + if (result.rows.length === 0) { + return []; + } + + return result.rows.map((row) => ({ + workflow_id: row.workflow_id, + workflow_name: row.workflow_name, + run_id: row.run_id, + })); + } finally { + client.release(); + } +} + +async function cleanupFindings(chClient: ReturnType): Promise { + console.log(`\nDeleting existing findings for organization '${ORG_ID}'...`); + await chClient.command({ + query: `ALTER TABLE security_findings DELETE WHERE organization_id = {orgId:String}`, + query_params: { orgId: ORG_ID }, + }); + console.log(' Deleted successfully.'); +} + +async function seedFindings( + chClient: ReturnType, + wfRefs: WorkflowRef[], + totalFindings: number, +): Promise { + const BATCH_SIZE = 500; + + // Generate unique finding hashes — roughly 1/3 of total count + const uniqueHashCount = Math.max(50, Math.ceil(totalFindings / 3)); + const findingHashes: string[] = []; + for (let i = 0; i < uniqueHashCount; i++) { + findingHashes.push(randomUUID().replace(/-/g, '').slice(0, 16)); + } + + let totalIndexed = 0; + let batch: FindingRow[] = []; + + for (let i = 0; i < totalFindings; i++) { + const scanner = pickWeighted(SCANNER_DIST); + const wfRef = pick(wfRefs); + const hash = pick(findingHashes); + const timestamp = recentWeightedDate(90); + + const row = generateFinding(scanner, wfRef, hash, timestamp); + batch.push(row); + + if (batch.length >= BATCH_SIZE) { + const inserted = await bulkInsert(chClient, batch); + totalIndexed += inserted; + batch = []; + + if (totalIndexed % 2000 === 0 || totalIndexed === BATCH_SIZE) { + console.log(` ... inserted ${totalIndexed}/${totalFindings} findings`); + } + } + } + + // Flush remaining + if (batch.length > 0) { + totalIndexed += await bulkInsert(chClient, batch); + } + + console.log(` Inserted ${totalIndexed}/${totalFindings} findings total`); +} + +// ─── CLI & Main ────────────────────────────────────────────────────────────── + +function parseArgs(): { tier: string; clean: boolean } { + const args = process.argv.slice(2); + let tier = 'small'; + let clean = false; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--tier' && args[i + 1]) { + tier = args[i + 1]; + i++; + } + if (args[i] === '--clean') { + clean = true; + } + } + + if (!clean && !TIERS[tier]) { + console.error(`Invalid tier: ${tier}. Choose from: ${Object.keys(TIERS).join(', ')}`); + process.exit(1); + } + + return { tier, clean }; +} + +async function main() { + const { tier, clean } = parseArgs(); + + // ── ClickHouse connection (required) ── + const chUrl = process.env.CLICKHOUSE_URL; + if (!chUrl) { + console.error('❌ CLICKHOUSE_URL environment variable is required'); + console.error(' Example: CLICKHOUSE_URL=http://localhost:8123'); + process.exit(1); + } + + const chClient = createClient({ + url: chUrl, + username: process.env.CLICKHOUSE_USER ?? 'default', + password: process.env.CLICKHOUSE_PASSWORD ?? '', + }); + + // Test ClickHouse connection + try { + await chClient.ping(); + console.log('✅ Connected to ClickHouse'); + } catch (err) { + console.error('❌ Failed to connect to ClickHouse:', err); + process.exit(1); + } + + // ── Cleanup mode ── + if (clean) { + await cleanupFindings(chClient); + console.log('\nCleanup complete.'); + await chClient.close(); + return; + } + + // ── Seed mode ── + const tierConfig = TIERS[tier]; + + console.log(`\nSecurity Findings Seed - Tier: ${tier.toUpperCase()}`); + console.log(`Target: ${tierConfig.findings} findings`); + console.log('='.repeat(50)); + + // ── Postgres connection (to read parent seed data) ── + const pgUrl = process.env.DATABASE_URL || 'postgresql://shipsec:shipsec@localhost:5433/shipsec'; + const pgPool = new Pool({ connectionString: pgUrl }); + + try { + console.log('\nLoading workflow/run references from Postgres...'); + const wfRefs = await loadWorkflowRefs(pgPool); + + if (wfRefs.length === 0) { + console.error(`\n❌ No workflows/runs found for organization '${ORG_ID}' in Postgres.`); + console.error( + ' Run the parent seed script first: bun backend/scripts/seed-stress-test.ts --tier small', + ); + process.exit(1); + } + + console.log(` Found ${wfRefs.length} workflow/run references`); + + // Clean existing findings before re-seeding + await cleanupFindings(chClient); + + const startTime = Date.now(); + + console.log(`\nSeeding ${tierConfig.findings} security findings...`); + await seedFindings(chClient, wfRefs, tierConfig.findings); + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + + console.log('\n' + '='.repeat(50)); + console.log(`Seed complete in ${elapsed}s`); + console.log(`Tier: ${tier} | Organization: ${ORG_ID}`); + console.log('Run --clean to remove all seeded findings.'); + } finally { + await pgPool.end(); + await chClient.close(); + } +} + +main().catch((error) => { + console.error('Script encountered an unexpected error'); + console.error(error); + process.exit(1); +}); diff --git a/backend/scripts/seed-stress-test.ts b/backend/scripts/seed-stress-test.ts index 12ba5307d..73162bc91 100644 --- a/backend/scripts/seed-stress-test.ts +++ b/backend/scripts/seed-stress-test.ts @@ -134,9 +134,7 @@ const HUMAN_INPUT_TYPE_DIST: [string, number][] = [ const NODE_TYPES = [ 'core.workflow.entrypoint', 'core.http.request', - 'core.ai.agent', 'core.logic.script', - 'core.ai.generate-text', 'core.workflow.call', 'core.file.writer', 'core.artifact.writer', @@ -385,22 +383,14 @@ function generateLongName(): string { // ─── Graph Generators ──────────────────────────────────────────────────────── -type TemplateType = 'simple_http' | 'ai_agent' | 'complex_branching' | 'subflow' | 'large_pipeline'; +type TemplateType = 'simple_http' | 'complex_branching' | 'subflow' | 'large_pipeline'; -const TEMPLATES: TemplateType[] = [ - 'simple_http', - 'ai_agent', - 'complex_branching', - 'subflow', - 'large_pipeline', -]; +const TEMPLATES: TemplateType[] = ['simple_http', 'complex_branching', 'subflow', 'large_pipeline']; function nodeCountForTemplate(template: TemplateType, isLarge: boolean): number { switch (template) { case 'simple_http': return randInt(3, 5); - case 'ai_agent': - return randInt(5, 8); case 'complex_branching': return randInt(10, 20); case 'subflow': @@ -478,13 +468,6 @@ function generateWorkflowGraph( 'core.text.joiner', 'core.file.writer', ], - ai_agent: [ - 'core.ai.agent', - 'core.ai.generate-text', - 'core.provider.openai', - 'core.http.request', - 'core.text.joiner', - ], complex_branching: [ 'core.http.request', 'core.logic.script', @@ -1334,7 +1317,9 @@ async function seedRuns( wf.createdAt.getTime() + randInt(60000, Math.max(60001, Date.now() - wf.createdAt.getTime())), ); - const isAgentRun = wf.graph.nodes.some((n) => n.type === 'core.ai.agent'); + const isAgentRun = wf.graph.nodes.some( + (n) => n.type === 'core.ai.opencode' || n.type === 'core.ai.claude-code', + ); runs.push({ runId, diff --git a/backend/scripts/setup-opensearch.ts b/backend/scripts/setup-opensearch.ts deleted file mode 100644 index bb4646e03..000000000 --- a/backend/scripts/setup-opensearch.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Client } from '@opensearch-project/opensearch'; -import { config } from 'dotenv'; - -// Load environment variables -config(); - -async function main() { - const url = process.env.OPENSEARCH_URL; - const username = process.env.OPENSEARCH_USERNAME; - const password = process.env.OPENSEARCH_PASSWORD; - - if (!url) { - console.error('❌ OPENSEARCH_URL environment variable is required'); - process.exit(1); - } - - console.log('🔍 Connecting to OpenSearch...'); - - const client = new Client({ - node: url, - auth: username && password ? { username, password } : undefined, - ssl: { - rejectUnauthorized: process.env.NODE_ENV === 'production', - }, - }); - - try { - // Test connection - const healthCheck = await client.cluster.health(); - console.log(`✅ Connected to OpenSearch cluster (status: ${healthCheck.body.status})`); - - // Create index template for security-findings-* - const templateName = 'security-findings-template'; - console.log(`\n📋 Creating index template: ${templateName}`); - - await client.indices.putIndexTemplate({ - name: templateName, - body: { - index_patterns: ['security-findings-*'], - template: { - settings: { - number_of_shards: 1, - number_of_replicas: 1, - }, - mappings: { - properties: { - '@timestamp': { type: 'date' }, - // Root-level analytics fields - scanner: { type: 'keyword' }, - severity: { type: 'keyword' }, - finding_hash: { type: 'keyword' }, - asset_key: { type: 'keyword' }, - // Workflow context under shipsec namespace - shipsec: { - type: 'object', - dynamic: true, - properties: { - organization_id: { type: 'keyword' }, - run_id: { type: 'keyword' }, - workflow_id: { type: 'keyword' }, - workflow_name: { type: 'keyword' }, - component_id: { type: 'keyword' }, - node_ref: { type: 'keyword' }, - asset_key: { type: 'keyword' }, - }, - }, - }, - }, - }, - }, - }); - - console.log(`✅ Index template '${templateName}' created successfully`); - console.log('\n📊 Template configuration:'); - console.log(' - Index pattern: security-findings-*'); - console.log(' - Shards: 1, Replicas: 1'); - console.log(' - Mappings: @timestamp (date)'); - console.log(' root: scanner, severity, finding_hash, asset_key (keyword)'); - console.log(' shipsec.*: organization_id, run_id, workflow_id, workflow_name,'); - console.log(' component_id, node_ref, asset_key (keyword)'); - console.log('\n🎉 OpenSearch setup completed successfully!'); - } catch (error) { - console.error('❌ OpenSearch setup failed'); - console.error(error); - process.exit(1); - } -} - -main().catch((error) => { - console.error('❌ Unexpected error during OpenSearch setup'); - console.error(error); - process.exit(1); -}); diff --git a/backend/scripts/stamp-baseline.ts b/backend/scripts/stamp-baseline.ts new file mode 100644 index 000000000..d1b7b0af5 --- /dev/null +++ b/backend/scripts/stamp-baseline.ts @@ -0,0 +1,174 @@ +import { createHash } from 'crypto'; +import { readFileSync, readdirSync } from 'fs'; +import { basename, resolve } from 'path'; +import { Pool, type PoolClient } from 'pg'; + +interface JournalEntry { + idx: number; + when: number; + tag: string; +} + +interface Journal { + entries?: JournalEntry[]; +} + +function getMigrationFiles(drizzleDir: string): string[] { + return readdirSync(drizzleDir) + .filter((file) => /^\d+_.*\.sql$/i.test(file)) + .sort(); +} + +function getBaselineMigrationFile(drizzleDir: string): string { + const preferredTag = process.env.DRIZZLE_BASELINE_TAG?.trim(); + const files = getMigrationFiles(drizzleDir); + + if (files.length === 0) { + throw new Error(`No migration SQL files found in ${drizzleDir}`); + } + + if (preferredTag) { + const preferredFile = `${preferredTag}.sql`; + if (files.includes(preferredFile)) { + return preferredFile; + } + throw new Error( + `DRIZZLE_BASELINE_TAG=${preferredTag} does not match any migration file in ${drizzleDir}`, + ); + } + + // Baseline is always the first migration in lexical order. + return files[0]; +} + +function getCreatedAtFromJournal(drizzleDir: string, sqlFile: string): number { + const journalPath = resolve(drizzleDir, 'meta/_journal.json'); + const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as Journal; + const entries = journal.entries ?? []; + const tag = basename(sqlFile, '.sql'); + const entry = entries.find((item) => item.tag === tag); + + if (entry?.when) { + return entry.when; + } + + const firstEntryWithWhen = entries.find((item) => Boolean(item.when)); + if (firstEntryWithWhen?.when) { + return firstEntryWithWhen.when; + } + + throw new Error(`No usable "when" value found in ${journalPath}`); +} + +async function hasPublicTables(client: PoolClient): Promise { + const { rows } = await client.query<{ exists: boolean }>( + ` + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + ) AS exists + `, + ); + return rows[0]?.exists ?? false; +} + +async function hasBaselineHash(client: PoolClient, hash: string): Promise { + const { rows } = await client.query<{ exists: boolean }>( + ` + SELECT EXISTS ( + SELECT 1 + FROM drizzle.__drizzle_migrations + WHERE hash = $1 + ) AS exists + `, + [hash], + ); + + return rows[0]?.exists ?? false; +} + +async function insertBaselineHash( + client: PoolClient, + hash: string, + createdAt: number, +): Promise { + await client.query( + ` + INSERT INTO drizzle.__drizzle_migrations (hash, created_at) + VALUES ($1, $2) + `, + [hash, createdAt], + ); +} + +async function ensureMigrationsTable(client: PoolClient): Promise { + await client.query('CREATE SCHEMA IF NOT EXISTS drizzle'); + await client.query(` + CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations ( + id SERIAL PRIMARY KEY, + hash text NOT NULL, + created_at bigint + ) + `); +} + +function getShortHash(hash: string): string { + return hash.slice(0, 12); +} + +async function main() { + const force = process.argv.includes('--force'); + const connectionString = + process.env.DATABASE_URL || 'postgresql://shipsec:shipsec@localhost:5433/shipsec'; + + const drizzleDir = resolve(__dirname, '../drizzle'); + const baselineFile = getBaselineMigrationFile(drizzleDir); + const sqlContent = readFileSync(resolve(drizzleDir, baselineFile), 'utf8'); + const hash = createHash('sha256').update(sqlContent).digest('hex'); + const createdAt = getCreatedAtFromJournal(drizzleDir, baselineFile); + + const pool = new Pool({ connectionString }); + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + await ensureMigrationsTable(client); + + const publicTablesExist = await hasPublicTables(client); + const baselineAlreadyStamped = await hasBaselineHash(client, hash); + + if (baselineAlreadyStamped) { + await client.query('COMMIT'); + console.log( + `ℹ️ Baseline already stamped (${baselineFile}, hash=${getShortHash(hash)}). Skipping.`, + ); + return; + } + + if (!publicTablesExist && !force) { + await client.query('COMMIT'); + console.log(`ℹ️ Fresh database detected. Skipping baseline stamp for ${baselineFile}.`); + return; + } + + await insertBaselineHash(client, hash, createdAt); + await client.query('COMMIT'); + console.log(`✅ Baseline stamped (${baselineFile}, hash=${getShortHash(hash)}).`); + } catch (error) { + await client.query('ROLLBACK'); + console.error('❌ Failed to stamp baseline migration'); + console.error(error); + process.exit(1); + } finally { + client.release(); + await pool.end(); + } +} + +main().catch((error) => { + console.error('❌ Unexpected error while stamping baseline migration'); + console.error(error); + process.exit(1); +}); diff --git a/backend/scripts/validate-env.ts b/backend/scripts/validate-env.ts new file mode 100644 index 000000000..7e9ecb8a3 --- /dev/null +++ b/backend/scripts/validate-env.ts @@ -0,0 +1,18 @@ +/** + * Pre-flight environment validation script. + * Called by `just dev` before starting PM2 to surface config errors early. + * Exits 1 with a clear error if validation fails. + */ +import { formatEnvErrors } from '@shipsec/shared'; +import { backendEnvSchema } from '../src/config/env.schema'; + +const result = backendEnvSchema.safeParse(process.env); + +if (!result.success) { + console.error('\n❌ Backend environment validation failed:\n'); + console.error(formatEnvErrors(result.error)); + console.error('\nSee backend/.env.example for reference.\n'); + process.exit(1); +} + +console.log('✅ Backend .env OK'); diff --git a/backend/scripts/version-check-summary.ts b/backend/scripts/version-check-summary.ts index bcabdd496..b59449c7f 100644 --- a/backend/scripts/version-check-summary.ts +++ b/backend/scripts/version-check-summary.ts @@ -36,7 +36,7 @@ function printSection(lines: string[], accentColor: string) { async function main() { if (isVersionCheckDisabled(process.env)) { printSection( - [`${colors.dim}Version check skipped (disabled via env)${colors.reset}`], + [`${colors.dim}Version check skipped (disabled or no URL configured)${colors.reset}`], colors.dim, ); return; diff --git a/backend/src/__tests__/backend-integration.test.ts b/backend/src/__tests__/backend-integration.test.ts index 0f6413c39..2a35000c6 100644 --- a/backend/src/__tests__/backend-integration.test.ts +++ b/backend/src/__tests__/backend-integration.test.ts @@ -2,7 +2,8 @@ import 'reflect-metadata'; import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; -import { AppModule } from '../app.module'; +// Dynamic import to avoid triggering ConfigModule validation when tests are skipped +let AppModule: any; import { Pool } from 'pg'; import { drizzle } from 'drizzle-orm/node-postgres'; import { sql } from 'drizzle-orm'; @@ -140,6 +141,10 @@ interface Component { beforeAll(async () => { console.log('🚀 Starting backend integration test setup...'); + // Dynamic import to avoid triggering env validation when tests are skipped + const appModule = await import('../app.module'); + AppModule = appModule.AppModule; + // Create NestJS test application const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], @@ -185,11 +190,13 @@ interface Component { }); describe('Health Check', () => { - it('should list workflows (basic connectivity test)', async () => { - const response = await fetch(api('/workflows')); + it('returns health without authentication', async () => { + const response = await fetch(api('/health')); expect(response.ok).toBe(true); - const data = await readJson(response); - expect(Array.isArray(data)).toBe(true); + const data = await readJson<{ status: string; service: string; timestamp: string }>(response); + expect(data.status).toBe('ok'); + expect(data.service).toBe('shipsec-backend'); + expect(typeof data.timestamp).toBe('string'); }); }); diff --git a/backend/src/__tests__/version-check.test.ts b/backend/src/__tests__/version-check.test.ts new file mode 100644 index 000000000..68e0daab6 --- /dev/null +++ b/backend/src/__tests__/version-check.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { getVersionCheckBaseUrl, isVersionCheckDisabled } from '../version-check'; + +const ORIGINAL_ENV = { ...process.env }; + +describe('versionCheck', () => { + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it('treats the version check as disabled when no URL is configured', () => { + delete process.env.SHIPSEC_VERSION_CHECK_URL; + delete process.env.SHIPSEC_VERSION_CHECK_DISABLED; + + expect(getVersionCheckBaseUrl()).toBeNull(); + expect(isVersionCheckDisabled()).toBe(true); + }); + + it('uses the configured version-check URL when present', () => { + process.env.SHIPSEC_VERSION_CHECK_URL = 'https://updates.example.com'; + delete process.env.SHIPSEC_VERSION_CHECK_DISABLED; + + expect(getVersionCheckBaseUrl()).toBe('https://updates.example.com'); + expect(isVersionCheckDisabled()).toBe(false); + }); + + it('still honors the explicit disable flag', () => { + process.env.SHIPSEC_VERSION_CHECK_URL = 'https://updates.example.com'; + process.env.SHIPSEC_VERSION_CHECK_DISABLED = 'true'; + + expect(isVersionCheckDisabled()).toBe(true); + }); +}); diff --git a/backend/src/agent-skills/agent-skills.controller.ts b/backend/src/agent-skills/agent-skills.controller.ts new file mode 100644 index 000000000..4ed319c22 --- /dev/null +++ b/backend/src/agent-skills/agent-skills.controller.ts @@ -0,0 +1,110 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + ParseUUIDPipe, +} from '@nestjs/common'; +import { + ApiCreatedResponse, + ApiNoContentResponse, + ApiOkResponse, + ApiTags, + ApiOperation, +} from '@nestjs/swagger'; + +import { AgentSkillsService } from './agent-skills.service'; +import { + CreateAgentSkillDto, + UpdateAgentSkillDto, + AgentSkillResponse, +} from './dto/agent-skills.dto'; +import { CurrentAuth } from '../auth/auth-context.decorator'; +import type { AuthContext } from '../auth/types'; + +@ApiTags('agent-skills') +@Controller('agent-skills') +export class AgentSkillsController { + constructor(private readonly agentSkillsService: AgentSkillsService) {} + + @Get() + @ApiOperation({ summary: 'List all agent skills' }) + @ApiOkResponse({ type: [AgentSkillResponse] }) + async listSkills(@CurrentAuth() auth: AuthContext | null): Promise { + return this.agentSkillsService.listSkills(auth); + } + + @Get('enabled') + @ApiOperation({ summary: 'List enabled agent skills only' }) + @ApiOkResponse({ type: [AgentSkillResponse] }) + async listEnabledSkills(@CurrentAuth() auth: AuthContext | null): Promise { + return this.agentSkillsService.listEnabledSkills(auth); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a specific agent skill' }) + @ApiOkResponse({ type: AgentSkillResponse }) + async getSkill( + @CurrentAuth() auth: AuthContext | null, + @Param('id', new ParseUUIDPipe()) id: string, + ): Promise { + return this.agentSkillsService.getSkill(auth, id); + } + + @Post() + @ApiOperation({ summary: 'Create a new agent skill' }) + @ApiCreatedResponse({ type: AgentSkillResponse }) + async createSkill( + @CurrentAuth() auth: AuthContext | null, + @Body() body: CreateAgentSkillDto, + ): Promise { + return this.agentSkillsService.createSkill(auth, body); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update an agent skill' }) + @ApiOkResponse({ type: AgentSkillResponse }) + async updateSkill( + @CurrentAuth() auth: AuthContext | null, + @Param('id', new ParseUUIDPipe()) id: string, + @Body() body: UpdateAgentSkillDto, + ): Promise { + return this.agentSkillsService.updateSkill(auth, id, body); + } + + @Post(':id/toggle') + @ApiOperation({ summary: 'Toggle agent skill enabled/disabled status' }) + @ApiOkResponse({ type: AgentSkillResponse }) + async toggleSkill( + @CurrentAuth() auth: AuthContext | null, + @Param('id', new ParseUUIDPipe()) id: string, + ): Promise { + return this.agentSkillsService.toggleSkill(auth, id); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Delete an agent skill' }) + @ApiNoContentResponse() + async deleteSkill( + @CurrentAuth() auth: AuthContext | null, + @Param('id', new ParseUUIDPipe()) id: string, + ): Promise { + await this.agentSkillsService.deleteSkill(auth, id); + } + + @Get(':id/preview') + @ApiOperation({ summary: 'Preview the SKILL.md content for an agent skill' }) + @ApiOkResponse({ schema: { type: 'object', properties: { content: { type: 'string' } } } }) + async previewSkillMd( + @CurrentAuth() auth: AuthContext | null, + @Param('id', new ParseUUIDPipe()) id: string, + ): Promise<{ content: string }> { + return this.agentSkillsService.getSkillPreview(auth, id); + } +} diff --git a/backend/src/agent-skills/agent-skills.module.ts b/backend/src/agent-skills/agent-skills.module.ts new file mode 100644 index 000000000..1c62944a1 --- /dev/null +++ b/backend/src/agent-skills/agent-skills.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from '../database/database.module'; +import { AgentSkillsController } from './agent-skills.controller'; +import { AgentSkillsRepository } from './agent-skills.repository'; +import { AgentSkillsService } from './agent-skills.service'; + +@Module({ + imports: [DatabaseModule], + controllers: [AgentSkillsController], + providers: [AgentSkillsService, AgentSkillsRepository], + exports: [AgentSkillsService], +}) +export class AgentSkillsModule {} diff --git a/backend/src/agent-skills/agent-skills.repository.ts b/backend/src/agent-skills/agent-skills.repository.ts new file mode 100644 index 000000000..5d6ef780d --- /dev/null +++ b/backend/src/agent-skills/agent-skills.repository.ts @@ -0,0 +1,183 @@ +import { Inject, Injectable, ConflictException, NotFoundException } from '@nestjs/common'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { and, eq, sql, type SQL, or, isNull } from 'drizzle-orm'; + +import { DRIZZLE_TOKEN } from '../database/database.module'; +import { agentSkills, type AgentSkillRecord, type NewAgentSkillRecord } from '../database/schema'; +import { DEFAULT_ORGANIZATION_ID } from '../auth/constants'; + +export interface AgentSkillQueryOptions { + organizationId?: string | null; +} + +export interface AgentSkillUpdateData { + name?: string; + description?: string; + content?: string; + files?: { path: string; content: string; executable?: boolean }[]; + license?: string | null; + compatibility?: string | null; + metadata?: Record | null; + allowedTools?: string | null; + isEnabled?: boolean; +} + +@Injectable() +export class AgentSkillsRepository { + constructor( + @Inject(DRIZZLE_TOKEN) + private readonly db: NodePgDatabase, + ) {} + + async list(options: AgentSkillQueryOptions = {}): Promise { + const conditions: (SQL | undefined)[] = []; + if (options.organizationId) { + conditions.push( + or( + eq(agentSkills.organizationId, options.organizationId), + isNull(agentSkills.organizationId), + ), + ); + } + + const whereClause = + conditions.length === 0 + ? undefined + : conditions.length === 1 + ? conditions[0] + : and(...conditions.filter((c): c is SQL => c !== undefined)); + + const rows = await ( + whereClause + ? this.db.select().from(agentSkills).where(whereClause) + : this.db.select().from(agentSkills) + ).orderBy(agentSkills.name); + + return rows; + } + + async listEnabled(options: AgentSkillQueryOptions = {}): Promise { + const conditions: (SQL | undefined)[] = [eq(agentSkills.isEnabled, true)]; + if (options.organizationId) { + conditions.push( + or( + eq(agentSkills.organizationId, options.organizationId), + isNull(agentSkills.organizationId), + ), + ); + } + + const rows = await this.db + .select() + .from(agentSkills) + .where(and(...conditions.filter((c): c is SQL => c !== undefined))) + .orderBy(agentSkills.name); + + return rows; + } + + async findById(id: string, options: AgentSkillQueryOptions = {}): Promise { + const conditions: (SQL | undefined)[] = [eq(agentSkills.id, id)]; + if (options.organizationId) { + conditions.push( + or( + eq(agentSkills.organizationId, options.organizationId), + isNull(agentSkills.organizationId), + ), + ); + } + + const rows = await this.db + .select() + .from(agentSkills) + .where(and(...conditions.filter((c): c is SQL => c !== undefined))) + .limit(1); + + const row = rows[0]; + if (!row) { + throw new NotFoundException(`Agent skill ${id} not found`); + } + + return row; + } + + async create( + data: Omit, + ): Promise { + try { + const [skill] = await this.db + .insert(agentSkills) + .values({ + ...data, + organizationId: data.organizationId ?? DEFAULT_ORGANIZATION_ID, + }) + .returning(); + + return skill; + } catch (error: any) { + if (error?.code === '23505') { + throw new ConflictException(`Agent skill name '${data.name}' already exists`); + } + throw error; + } + } + + async update( + id: string, + data: AgentSkillUpdateData, + options: AgentSkillQueryOptions = {}, + ): Promise { + const conditions: (SQL | undefined)[] = [eq(agentSkills.id, id)]; + if (options.organizationId) { + conditions.push( + or( + eq(agentSkills.organizationId, options.organizationId), + isNull(agentSkills.organizationId), + ), + ); + } + + try { + const [updated] = await this.db + .update(agentSkills) + .set({ + ...data, + updatedAt: sql`now()`, + }) + .where(and(...conditions.filter((c): c is SQL => c !== undefined))) + .returning(); + + if (!updated) { + throw new NotFoundException(`Agent skill ${id} not found`); + } + + return updated; + } catch (error: any) { + if (error?.code === '23505' && data.name) { + throw new ConflictException(`Agent skill name '${data.name}' already exists`); + } + throw error; + } + } + + async delete(id: string, options: AgentSkillQueryOptions = {}): Promise { + const conditions: (SQL | undefined)[] = [eq(agentSkills.id, id)]; + if (options.organizationId) { + conditions.push( + or( + eq(agentSkills.organizationId, options.organizationId), + isNull(agentSkills.organizationId), + ), + ); + } + + const deleted = await this.db + .delete(agentSkills) + .where(and(...conditions.filter((c): c is SQL => c !== undefined))) + .returning({ id: agentSkills.id }); + + if (deleted.length === 0) { + throw new NotFoundException(`Agent skill ${id} not found`); + } + } +} diff --git a/backend/src/agent-skills/agent-skills.service.ts b/backend/src/agent-skills/agent-skills.service.ts new file mode 100644 index 000000000..526931038 --- /dev/null +++ b/backend/src/agent-skills/agent-skills.service.ts @@ -0,0 +1,269 @@ +import { Injectable, BadRequestException, Logger } from '@nestjs/common'; +import { AgentSkillsRepository } from './agent-skills.repository'; +import type { AuthContext } from '../auth/types'; +import { AuditLogService } from '../audit/audit-log.service'; +import { DEFAULT_ORGANIZATION_ID } from '../auth/constants'; +import type { + CreateAgentSkillDto, + UpdateAgentSkillDto, + AgentSkillResponse, +} from './dto/agent-skills.dto'; +import type { AgentSkillRecord } from '../database/schema'; + +@Injectable() +export class AgentSkillsService { + private readonly logger = new Logger(AgentSkillsService.name); + + constructor( + private readonly repository: AgentSkillsRepository, + private readonly auditLogService: AuditLogService, + ) {} + + private resolveOrganizationId(auth: AuthContext | null): string { + return auth?.organizationId ?? DEFAULT_ORGANIZATION_ID; + } + + private assertOrganizationId(auth: AuthContext | null): string { + const organizationId = this.resolveOrganizationId(auth); + if (!organizationId) { + throw new BadRequestException('Organization context is required'); + } + return organizationId; + } + + private mapToResponse(record: AgentSkillRecord): AgentSkillResponse { + return { + id: record.id, + name: record.name, + description: record.description, + content: record.content, + files: record.files ?? [], + license: record.license ?? null, + compatibility: record.compatibility ?? null, + metadata: record.metadata ?? null, + allowedTools: record.allowedTools ?? null, + isEnabled: record.isEnabled, + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + }; + } + + async listSkills(auth: AuthContext | null): Promise { + const organizationId = this.assertOrganizationId(auth); + const skills = await this.repository.list({ organizationId }); + return skills.map((s) => this.mapToResponse(s)); + } + + async listEnabledSkills(auth: AuthContext | null): Promise { + const organizationId = this.assertOrganizationId(auth); + const skills = await this.repository.listEnabled({ organizationId }); + return skills.map((s) => this.mapToResponse(s)); + } + + async getSkill(auth: AuthContext | null, id: string): Promise { + const organizationId = this.assertOrganizationId(auth); + const skill = await this.repository.findById(id, { organizationId }); + return this.mapToResponse(skill); + } + + async createSkill( + auth: AuthContext | null, + input: CreateAgentSkillDto, + ): Promise { + const organizationId = this.assertOrganizationId(auth); + + // Check for duplicate name within the organization + const existing = await this.repository.list({ organizationId }); + const duplicate = existing.find((s) => s.name === input.name.trim()); + if (duplicate) { + throw new BadRequestException( + `An agent skill with the name "${input.name.trim()}" already exists. Please use a different name or delete the existing skill first.`, + ); + } + + const skill = await this.repository.create({ + name: input.name.trim(), + description: input.description.trim(), + content: input.content, + files: sanitizeSkillFiles(input.files ?? []), + license: input.license ?? null, + compatibility: input.compatibility ?? null, + metadata: input.metadata ?? null, + allowedTools: input.allowedTools ?? null, + isEnabled: input.isEnabled ?? true, + organizationId, + }); + + this.auditLogService.record(auth, { + action: 'agent_skill.create', + resourceType: 'agent_skill', + resourceId: skill.id, + resourceName: skill.name, + metadata: {}, + }); + + return this.mapToResponse(skill); + } + + async updateSkill( + auth: AuthContext | null, + id: string, + input: UpdateAgentSkillDto, + ): Promise { + const organizationId = this.assertOrganizationId(auth); + + // Verify the skill exists and belongs to the organization + const current = await this.repository.findById(id, { organizationId }); + + const updates: Partial<{ + name: string; + description: string; + content: string; + files: { path: string; content: string; executable?: boolean }[]; + license: string | null; + compatibility: string | null; + metadata: Record | null; + allowedTools: string | null; + isEnabled: boolean; + }> = {}; + + if (input.name !== undefined) { + const trimmed = input.name.trim(); + if (trimmed.length === 0) { + throw new BadRequestException('Skill name cannot be empty'); + } + updates.name = trimmed; + } + + if (input.description !== undefined) { + updates.description = input.description.trim(); + } + + if (input.content !== undefined) { + updates.content = input.content; + } + + if (input.files !== undefined) { + updates.files = sanitizeSkillFiles(input.files); + } + + if (input.license !== undefined) { + updates.license = input.license ?? null; + } + + if (input.compatibility !== undefined) { + updates.compatibility = input.compatibility ?? null; + } + + if (input.metadata !== undefined) { + updates.metadata = input.metadata ?? null; + } + + if (input.allowedTools !== undefined) { + updates.allowedTools = input.allowedTools ?? null; + } + + if (input.isEnabled !== undefined) { + updates.isEnabled = input.isEnabled; + } + + if (Object.keys(updates).length === 0) { + return this.mapToResponse(current); + } + + const skill = await this.repository.update(id, updates, { organizationId }); + + this.auditLogService.record(auth, { + action: 'agent_skill.update', + resourceType: 'agent_skill', + resourceId: skill.id, + resourceName: skill.name, + metadata: {}, + }); + + return this.mapToResponse(skill); + } + + async toggleSkill(auth: AuthContext | null, id: string): Promise { + const organizationId = this.assertOrganizationId(auth); + const current = await this.repository.findById(id, { organizationId }); + const skill = await this.repository.update( + id, + { isEnabled: !current.isEnabled }, + { organizationId }, + ); + + this.auditLogService.record(auth, { + action: 'agent_skill.toggle', + resourceType: 'agent_skill', + resourceId: skill.id, + resourceName: skill.name, + metadata: { isEnabled: skill.isEnabled }, + }); + + return this.mapToResponse(skill); + } + + async deleteSkill(auth: AuthContext | null, id: string): Promise { + const organizationId = this.assertOrganizationId(auth); + const skill = await this.repository.findById(id, { organizationId }); + await this.repository.delete(id, { organizationId }); + + this.auditLogService.record(auth, { + action: 'agent_skill.delete', + resourceType: 'agent_skill', + resourceId: skill.id, + resourceName: skill.name, + metadata: {}, + }); + } + + /** + * Generate the full SKILL.md content from stored data. + * Format: + * --- + * name: + * description: + * [allowed-tools: ] + * --- + * + * + */ + previewSkillMd(record: AgentSkillRecord): string { + const frontmatterLines: string[] = [ + '---', + `name: ${record.name}`, + `description: ${record.description}`, + ]; + + if (record.allowedTools) { + frontmatterLines.push(`allowed-tools: ${record.allowedTools}`); + } + + frontmatterLines.push('---'); + + return [...frontmatterLines, '', record.content].join('\n'); + } + + async getSkillPreview(auth: AuthContext | null, id: string): Promise<{ content: string }> { + const organizationId = this.assertOrganizationId(auth); + const skill = await this.repository.findById(id, { organizationId }); + return { content: this.previewSkillMd(skill) }; + } +} + +function sanitizeSkillFiles( + files: { path: string; content: string; executable?: boolean }[], +): { path: string; content: string; executable?: boolean }[] { + return files.map((file) => { + const path = file.path.trim().replace(/^\/+/, ''); + if (!path || path === 'SKILL.md' || path.includes('..')) { + throw new BadRequestException(`Invalid skill bundle file path: ${file.path}`); + } + return { + path, + content: file.content, + ...(file.executable ? { executable: true } : {}), + }; + }); +} diff --git a/backend/src/agent-skills/dto/agent-skills.dto.ts b/backend/src/agent-skills/dto/agent-skills.dto.ts new file mode 100644 index 000000000..f214be8dd --- /dev/null +++ b/backend/src/agent-skills/dto/agent-skills.dto.ts @@ -0,0 +1,68 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +const AgentSkillFileSchema = z.object({ + path: z.string().min(1).max(240), + content: z.string(), + executable: z.boolean().optional(), +}); + +export const CreateAgentSkillSchema = z.object({ + name: z + .string() + .min(1) + .max(64) + .regex(/^[a-z0-9-]+$/, 'Name must contain only lowercase letters, numbers, and hyphens'), + description: z.string().min(1).max(1024), + content: z.string().min(1), + files: z.array(AgentSkillFileSchema).optional(), + license: z.string().optional(), + compatibility: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + allowedTools: z.string().optional(), + isEnabled: z.boolean().optional(), +}); + +export class CreateAgentSkillDto extends createZodDto(CreateAgentSkillSchema) {} + +export const UpdateAgentSkillSchema = z.object({ + name: z + .string() + .min(1) + .max(64) + .regex(/^[a-z0-9-]+$/, 'Name must contain only lowercase letters, numbers, and hyphens') + .optional(), + description: z.string().min(1).max(1024).optional(), + content: z.string().min(1).optional(), + files: z.array(AgentSkillFileSchema).optional(), + license: z.string().nullable().optional(), + compatibility: z.string().nullable().optional(), + metadata: z.record(z.string(), z.unknown()).nullable().optional(), + allowedTools: z.string().nullable().optional(), + isEnabled: z.boolean().optional(), +}); + +export class UpdateAgentSkillDto extends createZodDto(UpdateAgentSkillSchema) {} + +export const AgentSkillResponseSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string(), + content: z.string(), + files: z.array(AgentSkillFileSchema), + license: z.string().nullable(), + compatibility: z.string().nullable(), + metadata: z.record(z.string(), z.unknown()).nullable(), + allowedTools: z.string().nullable(), + isEnabled: z.boolean(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); + +export class AgentSkillResponseDto extends createZodDto(AgentSkillResponseSchema) {} + +// Export classes with both names for backward compatibility (as values) +export const AgentSkillResponse = AgentSkillResponseDto; + +// Type alias for use in type annotations +export type AgentSkillResponse = AgentSkillResponseDto; diff --git a/backend/src/agent-trace/agent-trace-ingest.service.ts b/backend/src/agent-trace/agent-trace-ingest.service.ts index 290aca41d..84382c531 100644 --- a/backend/src/agent-trace/agent-trace-ingest.service.ts +++ b/backend/src/agent-trace/agent-trace-ingest.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; -import { Consumer, Kafka } from 'kafkajs'; +import { Consumer } from 'kafkajs'; import { getTopicResolver } from '../common/kafka-topic-resolver'; +import { createKafkaClient } from '../common/kafka-client'; import { AgentTraceRepository, type AgentTraceEventInput } from './agent-trace.repository'; @@ -51,7 +52,7 @@ export class AgentTraceIngestService implements OnModuleInit, OnModuleDestroy { private async connectToKafka(): Promise { try { - const kafka = new Kafka({ + const kafka = createKafkaClient({ clientId: this.kafkaClientId, brokers: this.kafkaBrokers, requestTimeout: 30000, diff --git a/backend/src/agents/agents.controller.ts b/backend/src/agents/agents.controller.ts index 402772f63..3db72426b 100644 --- a/backend/src/agents/agents.controller.ts +++ b/backend/src/agents/agents.controller.ts @@ -15,9 +15,10 @@ import type { Response, Request } from 'express'; import { ZodValidationPipe } from 'nestjs-zod'; import { createUIMessageStream, pipeUIMessageStreamToResponse, type UIMessageChunk } from 'ai'; import { AgentStreamQuerySchema } from './dto/agent-stream-query.dto'; -import type { AgentStreamQueryDto } from './dto/agent-stream-query.dto'; +import { AgentStreamQueryDto } from './dto/agent-stream-query.dto'; import { AgentChatRequestSchema } from './dto/agent-chat-request.dto'; -import type { AgentChatRequestDto } from './dto/agent-chat-request.dto'; +import { AgentChatRequestDto } from './dto/agent-chat-request.dto'; +import { AgentPartsResponseDto } from './dto/agent-trace.dto'; import { WorkflowsService } from '../workflows/workflows.service'; import { AgentTraceService } from '../agent-trace/agent-trace.service'; import type { AgentTracePartEntry } from '../agent-trace/agent-trace.service'; @@ -35,7 +36,7 @@ export class AgentsController { ) {} @Get('/:agentRunId/parts') - @ApiOkResponse({ description: 'Returns stored agent trace parts' }) + @ApiOkResponse({ type: AgentPartsResponseDto, description: 'Returns stored agent trace parts' }) async parts( @Param('agentRunId') agentRunId: string, @Query(new ZodValidationPipe(AgentStreamQuerySchema)) query: AgentStreamQueryDto, diff --git a/backend/src/agents/dto/agent-chat-request.dto.ts b/backend/src/agents/dto/agent-chat-request.dto.ts index f1208423f..0fea59d53 100644 --- a/backend/src/agents/dto/agent-chat-request.dto.ts +++ b/backend/src/agents/dto/agent-chat-request.dto.ts @@ -1,7 +1,8 @@ +import { createZodDto } from 'nestjs-zod'; import { z } from 'zod'; export const AgentChatRequestSchema = z.object({ cursor: z.number().int().nonnegative().optional(), }); -export type AgentChatRequestDto = z.infer; +export class AgentChatRequestDto extends createZodDto(AgentChatRequestSchema) {} diff --git a/backend/src/agents/dto/agent-stream-query.dto.ts b/backend/src/agents/dto/agent-stream-query.dto.ts index 0b1e98b4e..0e6f4b501 100644 --- a/backend/src/agents/dto/agent-stream-query.dto.ts +++ b/backend/src/agents/dto/agent-stream-query.dto.ts @@ -1,7 +1,8 @@ +import { createZodDto } from 'nestjs-zod'; import { z } from 'zod'; export const AgentStreamQuerySchema = z.object({ cursor: z.string().optional(), }); -export type AgentStreamQueryDto = z.infer; +export class AgentStreamQueryDto extends createZodDto(AgentStreamQuerySchema) {} diff --git a/backend/src/agents/dto/agent-trace.dto.ts b/backend/src/agents/dto/agent-trace.dto.ts new file mode 100644 index 000000000..f662152fb --- /dev/null +++ b/backend/src/agents/dto/agent-trace.dto.ts @@ -0,0 +1,20 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +const AgentTraceChunkSchema = z.record(z.string(), z.unknown()); + +export const AgentTracePartSchema = z.object({ + sequence: z.number().int().nonnegative(), + timestamp: z.string().datetime(), + chunk: AgentTraceChunkSchema, +}); + +export const AgentPartsResponseSchema = z.object({ + agentRunId: z.string(), + workflowRunId: z.string(), + nodeRef: z.string(), + cursor: z.number().int().nonnegative(), + parts: z.array(AgentTracePartSchema), +}); + +export class AgentPartsResponseDto extends createZodDto(AgentPartsResponseSchema) {} diff --git a/backend/src/analytics/__tests__/analytics.controller.spec.ts b/backend/src/analytics/__tests__/analytics.controller.spec.ts new file mode 100644 index 000000000..7ef08e718 --- /dev/null +++ b/backend/src/analytics/__tests__/analytics.controller.spec.ts @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, it, vi } from 'bun:test'; + +import { AnalyticsController } from '../analytics.controller'; +import type { AuditLogService } from '../../audit/audit-log.service'; +import type { AuthContext } from '../../auth/types'; +import type { SecurityAnalyticsService } from '../security-analytics.service'; +import type { OrganizationSettingsService } from '../organization-settings.service'; +import type { BillingProvider, BillingUsageSummary } from '@shipsec/shared'; + +const TEST_ORG_ID = 'org-123'; +const CREATED_AT = new Date('2026-01-01T00:00:00.000Z'); +const UPDATED_AT = new Date('2026-01-15T00:00:00.000Z'); + +const adminAuth: AuthContext = { + userId: 'user-1', + organizationId: TEST_ORG_ID, + roles: ['ADMIN'], + isAuthenticated: true, + provider: 'test', +}; + +const baseSettings = { + organizationId: TEST_ORG_ID, + analyticsRetentionDays: 30, + createdAt: CREATED_AT, + updatedAt: UPDATED_AT, +}; + +function createUsageSummary(overrides: Partial = {}): BillingUsageSummary { + return { + organizationId: TEST_ORG_ID, + subscriptionTier: 'team', + plan: { + id: 'team', + displayName: 'Team', + priceUsdCents: 9900, + }, + period: { + start: '2026-01-01T00:00:00.000Z', + end: '2026-02-01T00:00:00.000Z', + }, + workflowRuns: { + used: 10, + limit: 100, + remaining: 90, + }, + ai: { + spentUsd: 1, + budgetUsd: 100, + remainingUsd: 99, + }, + limits: { + maxConcurrentRuns: 5, + maxWorkflows: 20, + analyticsRetentionDays: 180, + }, + ...overrides, + }; +} + +describe('AnalyticsController analytics settings', () => { + let organizationSettingsService: { + getOrganizationSettings: ReturnType; + updateOrganizationSettings: ReturnType; + validateRetentionPeriod: ReturnType; + getMaxRetentionDays: ReturnType; + }; + let billingProvider: { + isConfigured: ReturnType; + getUsageSummary: ReturnType; + listPlans: ReturnType; + }; + let controller: AnalyticsController; + + beforeEach(() => { + organizationSettingsService = { + getOrganizationSettings: vi.fn().mockResolvedValue(baseSettings), + updateOrganizationSettings: vi.fn().mockResolvedValue({ + ...baseSettings, + analyticsRetentionDays: 60, + updatedAt: new Date('2026-01-20T00:00:00.000Z'), + }), + validateRetentionPeriod: vi.fn().mockReturnValue(true), + getMaxRetentionDays: vi.fn().mockReturnValue(365), + }; + + billingProvider = { + isConfigured: vi.fn().mockReturnValue(false), + getUsageSummary: vi.fn(), + listPlans: vi.fn().mockResolvedValue([]), + }; + + controller = new AnalyticsController( + {} as SecurityAnalyticsService, + organizationSettingsService as unknown as OrganizationSettingsService, + { record: vi.fn() } as unknown as AuditLogService, + billingProvider as unknown as BillingProvider, + ); + }); + + it('returns single-tenant defaults in the public build when billing is not configured', async () => { + const result = await controller.getAnalyticsSettings(adminAuth); + + expect(organizationSettingsService.getOrganizationSettings).toHaveBeenCalledWith(TEST_ORG_ID); + expect(organizationSettingsService.getMaxRetentionDays).toHaveBeenCalledTimes(1); + expect(billingProvider.getUsageSummary).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + organizationId: TEST_ORG_ID, + analyticsRetentionDays: 30, + maxRetentionDays: 365, + planId: null, + planName: null, + subscriptionTier: null, + limitSource: 'single_tenant', + createdAt: CREATED_AT.toISOString(), + updatedAt: UPDATED_AT.toISOString(), + }); + }); + + it('surfaces plan-derived retention data when the cloud billing provider is configured', async () => { + billingProvider.isConfigured.mockReturnValue(true); + billingProvider.getUsageSummary.mockResolvedValue( + createUsageSummary({ + subscriptionTier: 'enterprise', + plan: { + id: 'enterprise', + displayName: 'Enterprise', + priceUsdCents: 49900, + }, + limits: { + maxConcurrentRuns: 20, + maxWorkflows: 200, + analyticsRetentionDays: 365, + }, + }), + ); + + const result = await controller.getAnalyticsSettings(adminAuth); + + expect(billingProvider.getUsageSummary).toHaveBeenCalledWith({ organizationId: TEST_ORG_ID }); + expect(result).toMatchObject({ + maxRetentionDays: 365, + planId: 'enterprise', + planName: 'Enterprise', + subscriptionTier: 'enterprise', + limitSource: 'plan', + }); + }); + + it('enforces the plan-derived retention cap when updating analytics settings', async () => { + billingProvider.isConfigured.mockReturnValue(true); + billingProvider.getUsageSummary.mockResolvedValue( + createUsageSummary({ + limits: { + maxConcurrentRuns: 5, + maxWorkflows: 20, + analyticsRetentionDays: 90, + }, + }), + ); + + await expect( + controller.updateAnalyticsSettings(adminAuth, { analyticsRetentionDays: 120 }), + ).rejects.toThrow('Retention period of 120 days exceeds the allowed maximum (90 days)'); + + expect(organizationSettingsService.updateOrganizationSettings).not.toHaveBeenCalled(); + }); + + it('returns plan metadata after a successful update so all consumers keep the same contract', async () => { + billingProvider.isConfigured.mockReturnValue(true); + billingProvider.getUsageSummary.mockResolvedValue(createUsageSummary()); + + const result = await controller.updateAnalyticsSettings(adminAuth, { + analyticsRetentionDays: 60, + }); + + expect(organizationSettingsService.validateRetentionPeriod).toHaveBeenCalledWith(60); + expect(organizationSettingsService.updateOrganizationSettings).toHaveBeenCalledWith( + TEST_ORG_ID, + { analyticsRetentionDays: 60 }, + ); + expect(result).toMatchObject({ + analyticsRetentionDays: 60, + maxRetentionDays: 180, + planId: 'team', + planName: 'Team', + subscriptionTier: 'team', + limitSource: 'plan', + }); + }); +}); diff --git a/backend/src/analytics/__tests__/security-analytics.integration.test.ts b/backend/src/analytics/__tests__/security-analytics.integration.test.ts new file mode 100644 index 000000000..088dffedf --- /dev/null +++ b/backend/src/analytics/__tests__/security-analytics.integration.test.ts @@ -0,0 +1,538 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { resolve } from 'path'; +import { SecurityAnalyticsService } from '../security-analytics.service'; + +// Skip by default — requires Docker. Run with: INTEGRATION=1 bun test __tests__/security-analytics +describe.skipIf(!process.env.INTEGRATION)('SecurityAnalyticsService integration', () => { + let container: any; + let client: any; + let service: SecurityAnalyticsService; + + const DEFAULT_META = { + workflowId: 'wf-test', + workflowName: 'integration-test', + runId: 'run-default', + nodeRef: 'node1', + componentId: 'comp1', + }; + + beforeAll(async () => { + // Dynamic imports to avoid WASM load when tests are skipped + const { ClickHouseContainer } = await import('@testcontainers/clickhouse'); + const { createClient } = await import('@clickhouse/client'); + + // 1. Start ClickHouse container + container = await new ClickHouseContainer('clickhouse/clickhouse-server:latest-alpine') + .withDatabase('test_shipsec') + .withCopyFilesToContainer([ + { + source: resolve(__dirname, '../schema/security-findings.init.sql'), + target: '/docker-entrypoint-initdb.d/01-init-schema.sql', + }, + ]) + .start(); + + // 2. Connect with @clickhouse/client + client = createClient({ + ...container.getClientOptions(), + }); + + // 3. Create service with mock ClickHouseClientService + const mockClickHouseClientService = { + getClient: () => client, + isClientEnabled: () => true, + } as any; + + service = new SecurityAnalyticsService(mockClickHouseClientService); + }, 60_000); + + afterAll(async () => { + await client?.close(); + await container?.stop(); + }); + + const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + + it('indexDocument and query — basic CRUD', async () => { + const orgId = 'org-crud'; + + await service.bulkIndex( + orgId, + [ + { + finding_hash: 'hash-crud-1', + '@timestamp': new Date().toISOString(), + scanner: 'prowler', + severity: 'high', + title: 'Test finding', + status: 'FAIL', + }, + ], + { ...DEFAULT_META, runId: 'run-crud' }, + ); + + await delay(2000); + + const result = await service.query(orgId, { + filters: [{ field: 'scanner', op: 'eq', value: 'prowler' }], + size: 10, + useLatest: false, + }); + + expect(result.total).toBeGreaterThanOrEqual(1); + expect(result.results[0].scanner).toBe('prowler'); + }, 30_000); + + it('query with severity filter', async () => { + const orgId = 'org-severity'; + + await service.bulkIndex( + orgId, + [ + { + finding_hash: 'hash-sev-1', + '@timestamp': new Date().toISOString(), + scanner: 'trivy', + severity: 'critical', + title: 'Critical vuln', + status: 'FAIL', + }, + { + finding_hash: 'hash-sev-2', + '@timestamp': new Date().toISOString(), + scanner: 'trivy', + severity: 'high', + title: 'High vuln', + status: 'FAIL', + }, + { + finding_hash: 'hash-sev-3', + '@timestamp': new Date().toISOString(), + scanner: 'trivy', + severity: 'medium', + title: 'Medium vuln', + status: 'FAIL', + }, + ], + { ...DEFAULT_META, runId: 'run-severity' }, + ); + + await delay(2000); + + const result = await service.query(orgId, { + filters: [{ field: 'severity', op: 'eq', value: 'critical' }], + size: 10, + useLatest: false, + }); + + expect(result.total).toBe(1); + expect(result.results.every((r) => r.severity === 'critical')).toBe(true); + }, 30_000); + + it('query with search text', async () => { + const orgId = 'org-search'; + + await service.bulkIndex( + orgId, + [ + { + finding_hash: 'hash-search-1', + '@timestamp': new Date().toISOString(), + scanner: 'semgrep', + severity: 'high', + title: 'SQL Injection vulnerability', + status: 'FAIL', + }, + ], + { ...DEFAULT_META, runId: 'run-search' }, + ); + + await delay(2000); + + const result = await service.query(orgId, { + search: 'SQL Injection', + size: 10, + useLatest: false, + }); + + expect(result.total).toBeGreaterThanOrEqual(1); + expect(result.results.some((r) => r.title.includes('SQL Injection'))).toBe(true); + }, 30_000); + + it('aggregations', async () => { + const orgId = 'org-aggs'; + + await service.bulkIndex( + orgId, + [ + { + finding_hash: 'hash-agg-1', + '@timestamp': new Date().toISOString(), + scanner: 'prowler', + severity: 'high', + title: 'Agg test 1', + status: 'FAIL', + }, + { + finding_hash: 'hash-agg-2', + '@timestamp': new Date().toISOString(), + scanner: 'trivy', + severity: 'critical', + title: 'Agg test 2', + status: 'FAIL', + }, + { + finding_hash: 'hash-agg-3', + '@timestamp': new Date().toISOString(), + scanner: 'prowler', + severity: 'medium', + title: 'Agg test 3', + status: 'FAIL', + }, + { + finding_hash: 'hash-agg-4', + '@timestamp': new Date().toISOString(), + scanner: 'semgrep', + severity: 'high', + title: 'Agg test 4', + status: 'FAIL', + }, + ], + { ...DEFAULT_META, runId: 'run-aggs' }, + ); + + await delay(2000); + + const result = await service.query(orgId, { + aggs: [ + { name: 'by_scanner', field: 'scanner' }, + { name: 'by_severity', field: 'severity' }, + ], + size: 0, + useLatest: false, + }); + + expect(result.aggregations).toBeDefined(); + expect(result.aggregations!.by_scanner.length).toBeGreaterThanOrEqual(2); + expect(result.aggregations!.by_severity.length).toBeGreaterThanOrEqual(2); + + const scannerKeys = result.aggregations!.by_scanner.map((b) => b.key); + expect(scannerKeys).toContain('prowler'); + expect(scannerKeys).toContain('trivy'); + }, 30_000); + + it('timeSeries', async () => { + const orgId = 'org-timeseries'; + const now = Date.now(); + + await service.bulkIndex( + orgId, + [ + { + finding_hash: 'hash-ts-1', + '@timestamp': new Date(now - 1 * 86_400_000).toISOString(), + scanner: 'prowler', + severity: 'high', + title: 'TS 1', + status: 'FAIL', + }, + { + finding_hash: 'hash-ts-2', + '@timestamp': new Date(now - 3 * 86_400_000).toISOString(), + scanner: 'prowler', + severity: 'medium', + title: 'TS 2', + status: 'FAIL', + }, + { + finding_hash: 'hash-ts-3', + '@timestamp': new Date(now - 7 * 86_400_000).toISOString(), + scanner: 'prowler', + severity: 'critical', + title: 'TS 3', + status: 'FAIL', + }, + ], + { ...DEFAULT_META, runId: 'run-ts' }, + ); + + await delay(2000); + + const result = await service.timeSeries(orgId, { days: 30, interval: 'day' }); + + expect(result.buckets.length).toBeGreaterThanOrEqual(1); + expect(result.buckets[0]).toHaveProperty('date'); + expect(result.buckets[0]).toHaveProperty('severity'); + expect(result.buckets[0]).toHaveProperty('count'); + }, 30_000); + + it('lifecycle', async () => { + const orgId = 'org-lifecycle'; + const hash = 'hash-lifecycle'; + const now = Date.now(); + + await service.bulkIndex( + orgId, + [ + { + finding_hash: hash, + '@timestamp': new Date(now - 5 * 86_400_000).toISOString(), + scanner: 'prowler', + severity: 'high', + title: 'LC finding', + status: 'FAIL', + }, + { + finding_hash: hash, + '@timestamp': new Date(now - 2 * 86_400_000).toISOString(), + scanner: 'prowler', + severity: 'high', + title: 'LC finding', + status: 'FAIL', + }, + { + finding_hash: hash, + '@timestamp': new Date(now).toISOString(), + scanner: 'prowler', + severity: 'high', + title: 'LC finding', + status: 'FAIL', + }, + ], + { ...DEFAULT_META, runId: 'run-lifecycle' }, + ); + + await delay(2000); + + const result = await service.lifecycle(orgId, [hash]); + + expect(result[hash]).toBeDefined(); + expect(result[hash].occurrences).toBeGreaterThanOrEqual(2); + expect(new Date(result[hash].firstSeen).getTime()).toBeLessThan( + new Date(result[hash].lastSeen).getTime(), + ); + }, 30_000); + + it('batchRunSeverityCounts', async () => { + const orgId = 'org-batch'; + + await service.bulkIndex( + orgId, + [ + { + finding_hash: 'hash-ba-1', + '@timestamp': new Date().toISOString(), + scanner: 'prowler', + severity: 'critical', + title: 'Batch A', + status: 'FAIL', + }, + { + finding_hash: 'hash-ba-2', + '@timestamp': new Date().toISOString(), + scanner: 'prowler', + severity: 'high', + title: 'Batch A', + status: 'FAIL', + }, + ], + { ...DEFAULT_META, runId: 'run-a' }, + ); + + await service.bulkIndex( + orgId, + [ + { + finding_hash: 'hash-bb-1', + '@timestamp': new Date().toISOString(), + scanner: 'trivy', + severity: 'medium', + title: 'Batch B', + status: 'FAIL', + }, + { + finding_hash: 'hash-bb-2', + '@timestamp': new Date().toISOString(), + scanner: 'trivy', + severity: 'low', + title: 'Batch B', + status: 'FAIL', + }, + { + finding_hash: 'hash-bb-3', + '@timestamp': new Date().toISOString(), + scanner: 'trivy', + severity: 'low', + title: 'Batch B', + status: 'FAIL', + }, + ], + { ...DEFAULT_META, runId: 'run-b' }, + ); + + await delay(2000); + + const result = await service.batchRunSeverityCounts(orgId, ['run-a', 'run-b']); + + expect(result['run-a'].critical).toBe(1); + expect(result['run-a'].high).toBe(1); + expect(result['run-b'].medium).toBe(1); + expect(result['run-b'].low).toBe(2); + }, 30_000); + + it('purgeOrgData', async () => { + const orgId = 'purge-test-org'; + + await service.bulkIndex( + orgId, + [ + { + finding_hash: 'hash-purge-1', + '@timestamp': new Date().toISOString(), + scanner: 'prowler', + severity: 'high', + title: 'Purge test', + status: 'FAIL', + }, + { + finding_hash: 'hash-purge-2', + '@timestamp': new Date().toISOString(), + scanner: 'prowler', + severity: 'medium', + title: 'Purge test 2', + status: 'FAIL', + }, + ], + { ...DEFAULT_META, runId: 'run-purge' }, + ); + + await delay(2000); + + // Verify data exists + const before = await service.query(orgId, { size: 10, useLatest: false }); + expect(before.total).toBeGreaterThanOrEqual(1); + + // Purge + await service.purgeOrgData(orgId); + + // ClickHouse lightweight DELETE is async — wait for mutations to process + await delay(3000); + + const after = await service.query(orgId, { size: 10, useLatest: false }); + expect(after.total).toBe(0); + }, 45_000); + + it('distinct query with LIMIT 1 BY', async () => { + const orgId = 'org-distinct'; + + await service.bulkIndex( + orgId, + [ + { + finding_hash: 'hash-d-1', + '@timestamp': new Date().toISOString(), + scanner: 'prowler', + severity: 'high', + title: 'Distinct 1', + status: 'FAIL', + }, + { + finding_hash: 'hash-d-2', + '@timestamp': new Date().toISOString(), + scanner: 'prowler', + severity: 'medium', + title: 'Distinct 2', + status: 'FAIL', + }, + { + finding_hash: 'hash-d-3', + '@timestamp': new Date().toISOString(), + scanner: 'trivy', + severity: 'high', + title: 'Distinct 3', + status: 'FAIL', + }, + { + finding_hash: 'hash-d-4', + '@timestamp': new Date().toISOString(), + scanner: 'trivy', + severity: 'low', + title: 'Distinct 4', + status: 'FAIL', + }, + ], + { ...DEFAULT_META, runId: 'run-distinct' }, + ); + + await delay(2000); + + const result = await service.query(orgId, { + distinct: 'scanner', + size: 10, + useLatest: false, + }); + + // Should have at most one result per unique scanner value + const scanners = result.results.map((r) => r.scanner); + const uniqueScanners = new Set(scanners); + expect(scanners.length).toBe(uniqueScanners.size); + expect(uniqueScanners.size).toBe(2); // prowler and trivy + }, 30_000); + + it('countDistinct', async () => { + const orgId = 'org-countdistinct'; + + await service.bulkIndex( + orgId, + [ + { + finding_hash: 'hash-cd-1', + '@timestamp': new Date().toISOString(), + scanner: 'prowler', + severity: 'high', + title: 'CD 1', + status: 'FAIL', + asset_key: 'asset-a', + }, + { + finding_hash: 'hash-cd-2', + '@timestamp': new Date().toISOString(), + scanner: 'prowler', + severity: 'high', + title: 'CD 2', + status: 'FAIL', + asset_key: 'asset-b', + }, + { + finding_hash: 'hash-cd-3', + '@timestamp': new Date().toISOString(), + scanner: 'prowler', + severity: 'high', + title: 'CD 3', + status: 'FAIL', + asset_key: 'asset-a', + }, + { + finding_hash: 'hash-cd-4', + '@timestamp': new Date().toISOString(), + scanner: 'prowler', + severity: 'high', + title: 'CD 4', + status: 'FAIL', + asset_key: 'asset-c', + }, + ], + { ...DEFAULT_META, runId: 'run-cd' }, + ); + + await delay(2000); + + const result = await service.query(orgId, { + countDistinct: 'asset_key', + size: 0, + useLatest: false, + }); + + expect(result.distinctCount).toBe(3); // asset-a, asset-b, asset-c + }, 30_000); +}); diff --git a/backend/src/analytics/analytics.controller.ts b/backend/src/analytics/analytics.controller.ts index e4df18e38..8c1ccecc3 100644 --- a/backend/src/analytics/analytics.controller.ts +++ b/backend/src/analytics/analytics.controller.ts @@ -2,30 +2,51 @@ import { BadRequestException, Body, Controller, + Delete, ForbiddenException, Get, - Headers, + Header, + HttpCode, Post, Put, UnauthorizedException, } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { ApiOkResponse, ApiTags, ApiHeader } from '@nestjs/swagger'; import { Throttle, SkipThrottle } from '@nestjs/throttler'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { SecurityAnalyticsService } from './security-analytics.service'; import { OrganizationSettingsService } from './organization-settings.service'; -import { OpenSearchTenantService } from './opensearch-tenant.service'; -import { AnalyticsQueryRequestDto, AnalyticsQueryResponseDto } from './dto/analytics-query.dto'; +import { + AnalyticsQueryRequestDto, + AnalyticsQueryResponseDto, + ScannerSeverityCountsRequestDto, + ScannerSeverityCountsResponseDto, + ProwlerChecksMetadataResponseDto, + ProwlerChecksMetadataResponseSchema, + StorageUsageResponseDto, + PurgeDataResponseDto, + TimeSeriesRequestDto, + TimeSeriesResponseDto, + LifecycleRequestDto, + LifecycleResponseDto, + type ProwlerChecksMetadataResponse, +} from './dto/analytics-query.dto'; import { AnalyticsSettingsResponseDto, UpdateAnalyticsSettingsDto, - TIER_LIMITS, } from './dto/analytics-settings.dto'; +import { + BatchRunSummariesRequestDto, + BatchRunSummariesResponseDto, +} from './dto/analytics-internal.dto'; import { AuditLogService } from '../audit/audit-log.service'; import { CurrentAuth } from '../auth/auth-context.decorator'; -import { Public } from '../auth/public.decorator'; import type { AuthContext } from '../auth/types'; +import type { BillingProvider } from '@shipsec/shared'; +import { BILLING_PROVIDER } from '../providers/tokens'; +import { Inject } from '@nestjs/common'; const MAX_QUERY_SIZE = 1000; const MAX_QUERY_FROM = 10000; @@ -37,20 +58,58 @@ function isValidNonNegativeInt(value: unknown): value is number { @ApiTags('analytics') @Controller('analytics') export class AnalyticsController { - private readonly internalServiceToken: string; + private _prowlerChecksMetadata: ProwlerChecksMetadataResponse | null = null; + + private get prowlerChecksMetadata(): ProwlerChecksMetadataResponse { + if (!this._prowlerChecksMetadata) { + try { + const raw = readFileSync(join(__dirname, '..', 'assets', 'prowler-checks.json'), 'utf-8'); + const parsed = ProwlerChecksMetadataResponseSchema.safeParse(JSON.parse(raw)); + this._prowlerChecksMetadata = parsed.success ? parsed.data : {}; + } catch { + this._prowlerChecksMetadata = {}; + } + } + return this._prowlerChecksMetadata!; + } constructor( private readonly securityAnalyticsService: SecurityAnalyticsService, private readonly organizationSettingsService: OrganizationSettingsService, - private readonly openSearchTenantService: OpenSearchTenantService, - private readonly configService: ConfigService, private readonly auditLogService: AuditLogService, - ) { - this.internalServiceToken = this.configService.get('INTERNAL_SERVICE_TOKEN') || ''; + @Inject(BILLING_PROVIDER) private readonly billingProvider: BillingProvider, + ) {} + + private async resolveAnalyticsRetentionPolicy(organizationId: string): Promise<{ + maxRetentionDays: number; + planId?: string | null; + planName?: string | null; + subscriptionTier?: 'free' | 'pro' | 'team' | 'enterprise' | null; + limitSource: 'single_tenant' | 'plan'; + }> { + if (!this.billingProvider.isConfigured()) { + return { + maxRetentionDays: this.organizationSettingsService.getMaxRetentionDays(), + planId: null, + planName: null, + subscriptionTier: null, + limitSource: 'single_tenant', + }; + } + + const usage = await this.billingProvider.getUsageSummary({ organizationId }); + return { + maxRetentionDays: usage.limits.analyticsRetentionDays, + planId: usage.plan.id, + planName: usage.plan.displayName, + subscriptionTier: usage.subscriptionTier, + limitSource: 'plan', + }; } @Post('query') - @Throttle({ default: { limit: 100, ttl: 60000 } }) // 100 requests per minute per user + @HttpCode(200) + @Throttle({ default: { limit: 100, ttl: 60000 } }) @ApiOkResponse({ description: 'Query analytics data for the authenticated organization', type: AnalyticsQueryResponseDto, @@ -69,41 +128,25 @@ export class AnalyticsController { @CurrentAuth() auth: AuthContext | null, @Body() queryDto: AnalyticsQueryRequestDto, ): Promise { - // Require authentication if (!auth || !auth.isAuthenticated) { throw new UnauthorizedException('Authentication required'); } - - // Require organization context if (!auth.organizationId) { throw new UnauthorizedException('Organization context required'); } - // Validate query syntax - if (queryDto.query && typeof queryDto.query !== 'object') { - throw new BadRequestException('Invalid query syntax: query must be an object'); - } - - if (queryDto.aggs && typeof queryDto.aggs !== 'object') { - throw new BadRequestException('Invalid query syntax: aggs must be an object'); - } - - // Set defaults const size = queryDto.size ?? 10; const from = queryDto.from ?? 0; if (!isValidNonNegativeInt(size)) { throw new BadRequestException('Invalid size: must be a non-negative integer'); } - if (!isValidNonNegativeInt(from)) { throw new BadRequestException('Invalid from: must be a non-negative integer'); } - if (size > MAX_QUERY_SIZE) { throw new BadRequestException(`Invalid size: maximum is ${MAX_QUERY_SIZE}`); } - if (from > MAX_QUERY_FROM) { throw new BadRequestException(`Invalid from: maximum is ${MAX_QUERY_FROM}`); } @@ -116,20 +159,107 @@ export class AnalyticsController { metadata: { size, from, - hasQuery: Boolean(queryDto.query), - hasAggs: Boolean(queryDto.aggs), + hasFilters: Boolean(queryDto.filters?.length), + hasAggs: Boolean(queryDto.aggs?.length), }, }); - // Call the service to execute the query return this.securityAnalyticsService.query(auth.organizationId, { - query: queryDto.query, + filters: queryDto.filters, + search: queryDto.search, + sort: queryDto.sort, + aggs: queryDto.aggs, + distinct: queryDto.distinct, + countDistinct: queryDto.countDistinct, size, from, - aggs: queryDto.aggs, + useLatest: queryDto.useLatest, }); } + @Post('scanner-severity-counts') + @HttpCode(200) + @Throttle({ default: { limit: 100, ttl: 60000 } }) + @ApiOkResponse({ + description: 'Get severity counts grouped by scanner for security findings', + type: ScannerSeverityCountsResponseDto, + }) + async scannerSeverityCounts( + @CurrentAuth() auth: AuthContext | null, + @Body() body: ScannerSeverityCountsRequestDto, + ): Promise { + if (!auth || !auth.isAuthenticated) { + throw new UnauthorizedException('Authentication required'); + } + if (!auth.organizationId) { + throw new UnauthorizedException('Organization context required'); + } + + return this.securityAnalyticsService.scannerSeverityCounts(auth.organizationId, { + filters: body.filters, + useLatest: body.useLatest, + }); + } + + @Post('time-series') + @HttpCode(200) + @Throttle({ default: { limit: 100, ttl: 60000 } }) + @ApiOkResponse({ + description: 'Get time-series data for security findings', + type: TimeSeriesResponseDto, + }) + async timeSeries( + @CurrentAuth() auth: AuthContext | null, + @Body() body: TimeSeriesRequestDto, + ): Promise { + if (!auth || !auth.isAuthenticated) { + throw new UnauthorizedException('Authentication required'); + } + if (!auth.organizationId) { + throw new UnauthorizedException('Organization context required'); + } + + return this.securityAnalyticsService.timeSeries(auth.organizationId, { + filters: body.filters, + interval: body.interval, + days: body.days, + timezone: body.timezone, + }); + } + + @Post('lifecycle') + @HttpCode(200) + @Throttle({ default: { limit: 100, ttl: 60000 } }) + @ApiOkResponse({ + description: 'Get lifecycle data (first seen, last seen, occurrences) for findings', + type: LifecycleResponseDto, + }) + async lifecycle( + @CurrentAuth() auth: AuthContext | null, + @Body() body: LifecycleRequestDto, + ): Promise { + if (!auth || !auth.isAuthenticated) { + throw new UnauthorizedException('Authentication required'); + } + if (!auth.organizationId) { + throw new UnauthorizedException('Organization context required'); + } + + const hashes: string[] = []; + if (body.hash) hashes.push(body.hash); + if (body.hashes) hashes.push(...body.hashes); + + if (hashes.length === 0) { + throw new BadRequestException('At least one hash or hashes[] is required'); + } + + const lifecycles = await this.securityAnalyticsService.lifecycle(auth.organizationId, [ + ...new Set(hashes), + ]); + + return { lifecycles }; + } + @Get('settings') @ApiOkResponse({ description: 'Get analytics settings for the authenticated organization', @@ -138,33 +268,28 @@ export class AnalyticsController { async getAnalyticsSettings( @CurrentAuth() auth: AuthContext | null, ): Promise { - // Require authentication if (!auth || !auth.isAuthenticated) { throw new UnauthorizedException('Authentication required'); } - - // Require organization context if (!auth.organizationId) { throw new UnauthorizedException('Organization context required'); } - // Get or create organization settings const settings = await this.organizationSettingsService.getOrganizationSettings( auth.organizationId, ); - - // Get max retention days for tier - const maxRetentionDays = this.organizationSettingsService.getMaxRetentionDays( - settings.subscriptionTier, - ); + const retentionPolicy = await this.resolveAnalyticsRetentionPolicy(auth.organizationId); return { organizationId: settings.organizationId, - subscriptionTier: settings.subscriptionTier, analyticsRetentionDays: settings.analyticsRetentionDays, - maxRetentionDays, - createdAt: settings.createdAt, - updatedAt: settings.updatedAt, + maxRetentionDays: retentionPolicy.maxRetentionDays, + planId: retentionPolicy.planId ?? null, + planName: retentionPolicy.planName ?? null, + subscriptionTier: retentionPolicy.subscriptionTier ?? null, + limitSource: retentionPolicy.limitSource, + createdAt: settings.createdAt.toISOString(), + updatedAt: settings.updatedAt.toISOString(), }; } @@ -177,30 +302,18 @@ export class AnalyticsController { @CurrentAuth() auth: AuthContext | null, @Body() updateDto: UpdateAnalyticsSettingsDto, ): Promise { - // Require authentication if (!auth || !auth.isAuthenticated) { throw new UnauthorizedException('Authentication required'); } - - // Require organization context if (!auth.organizationId) { throw new UnauthorizedException('Organization context required'); } - - // Only org admins can update settings if (!auth.roles.includes('ADMIN')) { throw new ForbiddenException('Only organization admins can update analytics settings'); } - // Get current settings to validate against tier - const currentSettings = await this.organizationSettingsService.getOrganizationSettings( - auth.organizationId, - ); - - // Determine the tier to validate against (use new tier if provided, otherwise current) - const tierToValidate = updateDto.subscriptionTier ?? currentSettings.subscriptionTier; + const retentionPolicy = await this.resolveAnalyticsRetentionPolicy(auth.organizationId); - // Validate retention period is within tier limits if (updateDto.analyticsRetentionDays !== undefined) { if ( typeof updateDto.analyticsRetentionDays !== 'number' || @@ -210,103 +323,115 @@ export class AnalyticsController { } const isValid = this.organizationSettingsService.validateRetentionPeriod( - tierToValidate, updateDto.analyticsRetentionDays, ); - if (!isValid) { - const maxDays = TIER_LIMITS[tierToValidate].maxRetentionDays; + if (!isValid || updateDto.analyticsRetentionDays > retentionPolicy.maxRetentionDays) { throw new BadRequestException( - `Retention period of ${updateDto.analyticsRetentionDays} days exceeds the limit for ${TIER_LIMITS[tierToValidate].name} tier (${maxDays} days)`, + `Retention period of ${updateDto.analyticsRetentionDays} days exceeds the allowed maximum (${retentionPolicy.maxRetentionDays} days)`, ); } } - // Update settings const updated = await this.organizationSettingsService.updateOrganizationSettings( auth.organizationId, { analyticsRetentionDays: updateDto.analyticsRetentionDays, - subscriptionTier: updateDto.subscriptionTier, }, ); - // Get max retention days for updated tier - const maxRetentionDays = this.organizationSettingsService.getMaxRetentionDays( - updated.subscriptionTier, - ); - return { organizationId: updated.organizationId, - subscriptionTier: updated.subscriptionTier, analyticsRetentionDays: updated.analyticsRetentionDays, - maxRetentionDays, - createdAt: updated.createdAt, - updatedAt: updated.updatedAt, + maxRetentionDays: retentionPolicy.maxRetentionDays, + planId: retentionPolicy.planId ?? null, + planName: retentionPolicy.planName ?? null, + subscriptionTier: retentionPolicy.subscriptionTier ?? null, + limitSource: retentionPolicy.limitSource, + createdAt: updated.createdAt.toISOString(), + updatedAt: updated.updatedAt.toISOString(), }; } - /** - * Ensure tenant resources exist for an organization. - * Called by worker before indexing to ensure tenant isolation is set up. - * - * Requires X-Internal-Token header for authentication (internal service-to-service). - * This endpoint is idempotent - safe to call multiple times. - */ - @Public() - @SkipThrottle() - @Post('ensure-tenant') + @Post('batch-run-summaries') + @HttpCode(200) + @Throttle({ default: { limit: 100, ttl: 60000 } }) @ApiOkResponse({ - description: 'Ensure tenant resources exist for organization', - schema: { - type: 'object', - properties: { - success: { type: 'boolean' }, - securityEnabled: { type: 'boolean' }, - message: { type: 'string' }, - }, - }, + description: 'Get severity counts for multiple run IDs', + type: BatchRunSummariesResponseDto, }) - async ensureTenant( - @Headers('x-internal-token') internalToken: string | undefined, - @Body() body: { organizationId: string }, - ): Promise<{ success: boolean; securityEnabled: boolean; message: string }> { - // Validate internal service token - if (!this.internalServiceToken) { - // Token not configured - allow in dev mode but log warning - console.warn('[ensureTenant] INTERNAL_SERVICE_TOKEN not configured'); - } else if (internalToken !== this.internalServiceToken) { - throw new UnauthorizedException('Invalid internal service token'); + async batchRunSummaries( + @CurrentAuth() auth: AuthContext | null, + @Body() body: BatchRunSummariesRequestDto, + ): Promise { + if (!auth || !auth.isAuthenticated) { + throw new UnauthorizedException('Authentication required'); } - - // Validate request body - if (!body.organizationId || typeof body.organizationId !== 'string') { - throw new BadRequestException('organizationId is required'); + if (!auth.organizationId) { + throw new UnauthorizedException('Organization context required'); } - const orgId = body.organizationId.trim(); - if (!orgId) { - throw new BadRequestException('organizationId cannot be empty'); + if (!Array.isArray(body.runIds)) { + throw new BadRequestException('runIds must be an array'); } - // Check if security mode is enabled - if (!this.openSearchTenantService.isSecurityEnabled()) { - return { - success: true, - securityEnabled: false, - message: 'Security mode disabled, tenant provisioning skipped', - }; + const runIds = [...new Set(body.runIds)].slice(0, 100); + + const summaries = await this.securityAnalyticsService.batchRunSeverityCounts( + auth.organizationId, + runIds, + ); + + return { summaries }; + } + + @Get('storage-usage') + @ApiOkResponse({ + description: 'Get analytics storage usage for the authenticated organization', + type: StorageUsageResponseDto, + }) + async getStorageUsage(@CurrentAuth() auth: AuthContext | null): Promise { + if (!auth || !auth.isAuthenticated) { + throw new UnauthorizedException('Authentication required'); + } + if (!auth.organizationId) { + throw new UnauthorizedException('Organization context required'); } + return this.securityAnalyticsService.getStorageUsage(auth.organizationId); + } - // Provision tenant resources - const success = await this.openSearchTenantService.ensureTenantExists(orgId); + @Delete('data') + @HttpCode(200) + @ApiOkResponse({ + description: 'Purge all analytics data for the authenticated organization', + type: PurgeDataResponseDto, + }) + async purgeData(@CurrentAuth() auth: AuthContext | null): Promise<{ tablesCleared: number }> { + if (!auth || !auth.isAuthenticated) { + throw new UnauthorizedException('Authentication required'); + } + if (!auth.organizationId) { + throw new UnauthorizedException('Organization context required'); + } + if (!auth.roles.includes('ADMIN')) { + throw new ForbiddenException('Only organization admins can purge analytics data'); + } + return this.securityAnalyticsService.purgeOrgData(auth.organizationId); + } - return { - success, - securityEnabled: true, - message: success - ? `Tenant provisioned for ${orgId}` - : `Failed to provision tenant for ${orgId}`, - }; + @Get('prowler-checks-metadata') + @SkipThrottle() + @Header('Cache-Control', 'public, max-age=2592000') + @ApiOkResponse({ + description: 'Static Prowler check metadata keyed by CheckID', + type: ProwlerChecksMetadataResponseDto, + }) + async getProwlerChecksMetadata( + @CurrentAuth() auth: AuthContext | null, + ): Promise { + if (!auth || !auth.isAuthenticated) { + throw new UnauthorizedException('Authentication required'); + } + return this.prowlerChecksMetadata; } } diff --git a/backend/src/analytics/analytics.module.ts b/backend/src/analytics/analytics.module.ts index e77f2062e..adfaba540 100644 --- a/backend/src/analytics/analytics.module.ts +++ b/backend/src/analytics/analytics.module.ts @@ -3,8 +3,10 @@ import { ConfigModule } from '@nestjs/config'; import { AnalyticsService } from './analytics.service'; import { SecurityAnalyticsService } from './security-analytics.service'; import { OrganizationSettingsService } from './organization-settings.service'; -import { OpenSearchTenantService } from './opensearch-tenant.service'; +import { ClickHouseTenantService } from './clickhouse-tenant.service'; import { AnalyticsController } from './analytics.controller'; +import { BILLING_PROVIDER } from '../providers/tokens'; +import { createDefaultProviderRegistry, resolveProvider } from '../providers/provider-registry'; @Module({ imports: [ConfigModule], @@ -13,13 +15,17 @@ import { AnalyticsController } from './analytics.controller'; AnalyticsService, SecurityAnalyticsService, OrganizationSettingsService, - OpenSearchTenantService, + ClickHouseTenantService, + { + provide: BILLING_PROVIDER, + useFactory: () => resolveProvider('billing', createDefaultProviderRegistry().billing), + }, ], exports: [ AnalyticsService, SecurityAnalyticsService, OrganizationSettingsService, - OpenSearchTenantService, + ClickHouseTenantService, ], }) export class AnalyticsModule {} diff --git a/backend/src/analytics/clickhouse-tenant.service.ts b/backend/src/analytics/clickhouse-tenant.service.ts new file mode 100644 index 000000000..d02d1d4dd --- /dev/null +++ b/backend/src/analytics/clickhouse-tenant.service.ts @@ -0,0 +1,47 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ClickHouseClientService } from '../config/clickhouse.client'; + +/** + * ClickHouse Tenant Service + * + * Verifies that the ClickHouse schema exists. + * Table creation is handled by the ClickHouse initialization SQL asset + * used by local and test environments. + * on first container startup. + */ +@Injectable() +export class ClickHouseTenantService { + private readonly logger = new Logger(ClickHouseTenantService.name); + + constructor(private readonly clickhouseClient: ClickHouseClientService) {} + + async ensureSchemaExists(): Promise { + const client = this.clickhouseClient.getClient(); + if (!client) { + this.logger.warn('ClickHouse client not available, skipping schema check'); + return false; + } + + try { + const result = await client.query({ + query: `SELECT count() AS cnt FROM system.tables WHERE database = currentDatabase() AND name = 'security_findings'`, + format: 'JSONEachRow', + }); + const rows = await result.json<{ cnt: string }>(); + const exists = Number(rows[0]?.cnt ?? 0) > 0; + + if (!exists) { + this.logger.warn( + 'ClickHouse tables not found. Run docker compose to initialize schema via init-schema.sql.', + ); + return false; + } + + this.logger.log('ClickHouse schema verified'); + return true; + } catch (error: any) { + this.logger.error(`Failed to verify ClickHouse schema: ${error?.message || error}`); + return false; + } + } +} diff --git a/backend/src/analytics/dto/analytics-internal.dto.ts b/backend/src/analytics/dto/analytics-internal.dto.ts new file mode 100644 index 000000000..38d20e289 --- /dev/null +++ b/backend/src/analytics/dto/analytics-internal.dto.ts @@ -0,0 +1,36 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +export const SeverityCountsSchema = z.object({ + critical: z.number().int().nonnegative(), + high: z.number().int().nonnegative(), + medium: z.number().int().nonnegative(), + low: z.number().int().nonnegative(), + info: z.number().int().nonnegative(), +}); + +export const BatchRunSummariesRequestSchema = z.object({ + runIds: z.array(z.string().trim().min(1)).max(100), +}); + +export class BatchRunSummariesRequestDto extends createZodDto(BatchRunSummariesRequestSchema) {} + +export const BatchRunSummariesResponseSchema = z.object({ + summaries: z.record(z.string(), SeverityCountsSchema), +}); + +export class BatchRunSummariesResponseDto extends createZodDto(BatchRunSummariesResponseSchema) {} + +export const EnsureTenantRequestSchema = z.object({ + organizationId: z.string().trim().min(1), +}); + +export class EnsureTenantRequestDto extends createZodDto(EnsureTenantRequestSchema) {} + +export const EnsureTenantResponseSchema = z.object({ + success: z.boolean(), + securityEnabled: z.boolean(), + message: z.string(), +}); + +export class EnsureTenantResponseDto extends createZodDto(EnsureTenantResponseSchema) {} diff --git a/backend/src/analytics/dto/analytics-query.dto.ts b/backend/src/analytics/dto/analytics-query.dto.ts index 969939bd1..f150d8c7f 100644 --- a/backend/src/analytics/dto/analytics-query.dto.ts +++ b/backend/src/analytics/dto/analytics-query.dto.ts @@ -1,66 +1,147 @@ -import { ApiProperty } from '@nestjs/swagger'; - -export class AnalyticsQueryRequestDto { - @ApiProperty({ - description: 'OpenSearch DSL query object', - example: { match_all: {} }, - required: false, - }) - query?: Record; - - @ApiProperty({ - description: 'Number of results to return', - example: 10, - default: 10, - minimum: 0, - maximum: 1000, - required: false, - }) - size?: number; - - @ApiProperty({ - description: 'Offset for pagination', - example: 0, - default: 0, - minimum: 0, - maximum: 10000, - required: false, - }) - from?: number; - - @ApiProperty({ - description: 'OpenSearch aggregations object', - example: { - components: { - terms: { field: 'component_id' }, - }, - }, - required: false, - }) - aggs?: Record; -} - -export class AnalyticsQueryResponseDto { - @ApiProperty({ - description: 'Total number of matching documents', - example: 100, - }) - total!: number; - - @ApiProperty({ - description: 'Search hits', - type: 'array', - items: { type: 'object' }, - }) - hits!: { - _id: string; - _source: Record; - _score?: number; - }[]; - - @ApiProperty({ - description: 'Aggregation results', - required: false, - }) - aggregations?: Record; -} +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +export const ProwlerCheckMetadataSchema = z.object({ + checkTitle: z.string().nullable(), + recommendation: z.string().nullable(), + recommendationUrl: z.string().nullable(), + cli: z.string().nullable(), + nativeIaC: z.string().nullable(), + terraform: z.string().nullable(), + other: z.string().nullable(), +}); + +export class ProwlerCheckMetadataDto extends createZodDto(ProwlerCheckMetadataSchema) {} + +export const ProwlerChecksMetadataResponseSchema = z.record(z.string(), ProwlerCheckMetadataSchema); + +export class ProwlerChecksMetadataResponseDto extends createZodDto( + ProwlerChecksMetadataResponseSchema, +) {} +export type ProwlerChecksMetadataResponse = z.infer; + +export const FilterSchema = z.object({ + field: z.string(), + op: z.enum(['eq', 'in', 'gte', 'lte', 'like', 'prefix', 'not_eq', 'exists', 'not_exists']), + value: z.unknown().optional(), +}); + +export const AggSchema = z.object({ + name: z.string(), + field: z.string(), + size: z.number().optional(), +}); + +export const SortSchema = z.object({ + field: z.string(), + direction: z.enum(['asc', 'desc']), +}); + +export const AnalyticsQueryRequestSchema = z.object({ + filters: z.array(FilterSchema).optional().describe('Filter conditions'), + search: z.string().optional().describe('Free-text search across key fields'), + aggs: z.array(AggSchema).optional().describe('Named aggregations'), + sort: z.array(SortSchema).optional().describe('Sort specification'), + distinct: z.string().optional().describe('Field to deduplicate results on'), + countDistinct: z + .string() + .optional() + .describe('Field to count distinct values for (returns distinctCount in response)'), + size: z.number().optional().describe('Number of results to return'), + from: z.number().optional().describe('Offset for pagination'), + useLatest: z + .boolean() + .optional() + .describe('Query the deduplicated latest-state index instead of the event log'), +}); + +export class AnalyticsQueryRequestDto extends createZodDto(AnalyticsQueryRequestSchema) {} + +export const AnalyticsQueryResponseSchema = z.object({ + total: z.number().describe('Total number of matching documents'), + results: z.array(z.record(z.string(), z.unknown())), + aggregations: z + .record(z.string(), z.array(z.object({ key: z.string(), count: z.number() }))) + .optional() + .describe('Aggregation results'), + distinctCount: z.number().optional().describe('Count of distinct values for countDistinct field'), +}); + +export class AnalyticsQueryResponseDto extends createZodDto(AnalyticsQueryResponseSchema) {} + +export const ScannerSeverityCountsRequestSchema = z.object({ + filters: z.array(FilterSchema).optional(), + useLatest: z.boolean().optional(), +}); + +export class ScannerSeverityCountsRequestDto extends createZodDto( + ScannerSeverityCountsRequestSchema, +) {} + +export const ScannerSeverityCountsResponseSchema = z.object({ + buckets: z.array( + z.object({ + scanner: z.string(), + severity: z.string(), + count: z.number(), + }), + ), +}); + +export class ScannerSeverityCountsResponseDto extends createZodDto( + ScannerSeverityCountsResponseSchema, +) {} + +export const TimeSeriesRequestSchema = z.object({ + filters: z.array(FilterSchema).optional(), + interval: z.enum(['day', 'week', 'month']).optional().default('day'), + days: z.number().optional().default(30), + timezone: z.string().optional(), +}); + +export class TimeSeriesRequestDto extends createZodDto(TimeSeriesRequestSchema) {} + +export const TimeSeriesResponseSchema = z.object({ + buckets: z.array( + z.object({ + date: z.string(), + severity: z.string(), + count: z.number(), + }), + ), +}); + +export class TimeSeriesResponseDto extends createZodDto(TimeSeriesResponseSchema) {} + +export const LifecycleRequestSchema = z.object({ + hash: z.string().optional(), + hashes: z.array(z.string()).optional(), +}); + +export class LifecycleRequestDto extends createZodDto(LifecycleRequestSchema) {} + +export const LifecycleResponseSchema = z.object({ + lifecycles: z.record( + z.string(), + z.object({ + firstSeen: z.string(), + lastSeen: z.string(), + occurrences: z.number(), + }), + ), +}); + +export class LifecycleResponseDto extends createZodDto(LifecycleResponseSchema) {} + +export const StorageUsageResponseSchema = z.object({ + usedBytes: z.number(), + documentCount: z.number(), + partCount: z.number(), + analyticsEnabled: z.boolean(), +}); +export class StorageUsageResponseDto extends createZodDto(StorageUsageResponseSchema) {} + +export const PurgeDataResponseSchema = z.object({ + tablesCleared: z.number(), +}); +export class PurgeDataResponseDto extends createZodDto(PurgeDataResponseSchema) {} diff --git a/backend/src/analytics/dto/analytics-settings.dto.ts b/backend/src/analytics/dto/analytics-settings.dto.ts index ce34c4d36..cb688077e 100644 --- a/backend/src/analytics/dto/analytics-settings.dto.ts +++ b/backend/src/analytics/dto/analytics-settings.dto.ts @@ -1,75 +1,24 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsEnum, IsInt, Min, Max, IsOptional } from 'class-validator'; -import type { SubscriptionTier } from '../../database/schema/organization-settings'; - -export type { SubscriptionTier }; - -export const TIER_LIMITS: Record = { - free: { name: 'Free', maxRetentionDays: 30 }, - pro: { name: 'Pro', maxRetentionDays: 90 }, - enterprise: { name: 'Enterprise', maxRetentionDays: 365 }, -}; - -export class AnalyticsSettingsResponseDto { - @ApiProperty({ - description: 'Organization ID', - example: 'org_abc123', - }) - organizationId!: string; - - @ApiProperty({ - description: 'Subscription tier', - enum: ['free', 'pro', 'enterprise'], - example: 'free', - }) - subscriptionTier!: SubscriptionTier; - - @ApiProperty({ - description: 'Data retention period in days', - example: 30, - }) - analyticsRetentionDays!: number; - - @ApiProperty({ - description: 'Maximum retention days allowed for this tier', - example: 30, - }) - maxRetentionDays!: number; - - @ApiProperty({ - description: 'Timestamp when settings were created', - example: '2026-01-20T00:00:00.000Z', - }) - createdAt!: Date; - - @ApiProperty({ - description: 'Timestamp when settings were last updated', - example: '2026-01-20T00:00:00.000Z', - }) - updatedAt!: Date; -} - -export class UpdateAnalyticsSettingsDto { - @ApiProperty({ - description: 'Data retention period in days (must be within tier limits)', - example: 30, - minimum: 1, - maximum: 365, - required: false, - }) - @IsOptional() - @IsInt() - @Min(1) - @Max(365) - analyticsRetentionDays?: number; - - // Optional: allow updating subscription tier (if needed in the future) - @ApiProperty({ - description: 'Subscription tier (optional - usually set by billing system)', - enum: ['free', 'pro', 'enterprise'], - required: false, - }) - @IsOptional() - @IsEnum(['free', 'pro', 'enterprise']) - subscriptionTier?: SubscriptionTier; -} +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +export const MAX_ANALYTICS_RETENTION_DAYS = 365; + +export const AnalyticsSettingsResponseSchema = z.object({ + organizationId: z.string(), + analyticsRetentionDays: z.number(), + maxRetentionDays: z.number(), + planId: z.string().nullable().optional(), + planName: z.string().nullable().optional(), + subscriptionTier: z.enum(['free', 'pro', 'team', 'enterprise']).nullable().optional(), + limitSource: z.enum(['single_tenant', 'plan']), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); + +export class AnalyticsSettingsResponseDto extends createZodDto(AnalyticsSettingsResponseSchema) {} + +export const UpdateAnalyticsSettingsSchema = z.object({ + analyticsRetentionDays: z.number().int().min(1).max(MAX_ANALYTICS_RETENTION_DAYS).optional(), +}); + +export class UpdateAnalyticsSettingsDto extends createZodDto(UpdateAnalyticsSettingsSchema) {} diff --git a/backend/src/analytics/opensearch-tenant.service.ts b/backend/src/analytics/opensearch-tenant.service.ts deleted file mode 100644 index 062a6dd28..000000000 --- a/backend/src/analytics/opensearch-tenant.service.ts +++ /dev/null @@ -1,417 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -const MAX_RETRIES = 3; -const RETRY_BASE_DELAY_MS = 1000; - -/** - * OpenSearch Tenant Service - * - * Handles dynamic tenant provisioning for multi-tenant analytics isolation. - * Creates OpenSearch Security tenants, roles, role mappings, index templates, - * seed indices, and index patterns for new organizations. - * - * This service is idempotent - safe to call multiple times for the same org. - * Guarded by OPENSEARCH_SECURITY_ENABLED - no-op when security is disabled. - */ -@Injectable() -export class OpenSearchTenantService { - private readonly logger = new Logger(OpenSearchTenantService.name); - private readonly securityEnabled: boolean; - private readonly opensearchUrl: string; - private readonly dashboardsUrl: string; - private readonly adminUsername: string; - private readonly adminPassword: string; - - constructor(private readonly configService: ConfigService) { - this.securityEnabled = this.configService.get('OPENSEARCH_SECURITY_ENABLED') === 'true'; - this.opensearchUrl = - this.configService.get('OPENSEARCH_URL') || 'http://opensearch:9200'; - this.dashboardsUrl = - this.configService.get('OPENSEARCH_DASHBOARDS_URL') || - 'http://opensearch-dashboards:5601'; - this.adminUsername = this.configService.get('OPENSEARCH_ADMIN_USERNAME') || 'admin'; - this.adminPassword = this.configService.get('OPENSEARCH_ADMIN_PASSWORD') || ''; - - this.logger.log( - `OpenSearch tenant service initialized (security: ${this.securityEnabled}, url: ${this.opensearchUrl})`, - ); - } - - /** - * Validates organization ID format. - * Must be lowercase alphanumeric with hyphens/underscores, starting with alphanumeric. - */ - private validateOrgId(orgId: string): boolean { - return /^[a-z0-9][a-z0-9_-]*$/.test(orgId); - } - - /** - * Creates Basic Auth header for OpenSearch API calls. - */ - private getAuthHeader(): string { - return `Basic ${Buffer.from(`${this.adminUsername}:${this.adminPassword}`).toString('base64')}`; - } - - /** - * Fetch wrapper with retry logic for transient connection errors. - * Bun's fetch can fail with various messages (ConnectionRefused, "typo in url", - * "Unable to connect") during concurrent request bursts. Retry all fetch-level - * errors (not HTTP errors) with exponential backoff. - */ - private async fetchWithRetry( - url: string, - options: RequestInit, - label: string, - ): Promise { - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { - try { - return await fetch(url, options); - } catch (error: any) { - if (attempt === MAX_RETRIES) { - throw error; - } - - const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1); - this.logger.warn( - `${label}: fetch failed (attempt ${attempt}/${MAX_RETRIES}): ${error?.message}. Retrying in ${delay}ms`, - ); - await new Promise((resolve) => setTimeout(resolve, delay)); - } - } - // Unreachable, but TypeScript needs it - throw new Error(`${label}: exhausted retries`); - } - - /** - * Ensures all tenant resources exist for the given organization. - * Creates: tenant, role, role mapping, index template, seed index, index pattern. - * - * This method is idempotent - safe to call multiple times. - * Returns true if all resources were created/verified successfully. - */ - async ensureTenantExists(orgId: string): Promise { - // No-op when security is disabled (dev mode) - if (!this.securityEnabled) { - this.logger.debug(`Tenant provisioning skipped (security disabled): ${orgId}`); - return true; - } - - // Normalize to lowercase for consistent tenant naming - const normalizedOrgId = orgId.toLowerCase(); - - // Validate format - if (!this.validateOrgId(normalizedOrgId)) { - this.logger.warn(`Invalid org ID format: ${orgId}`); - return false; - } - - this.logger.log(`Provisioning tenant for org: ${normalizedOrgId}`); - - try { - // Brief delay to let the nginx auth_request burst settle before - // making outbound connections (Bun's fetch can fail during bursts) - await new Promise((resolve) => setTimeout(resolve, 500)); - - // Step 1: Create tenant - await this.createTenant(normalizedOrgId); - - // Step 2: Create read-only role for this customer - await this.createCustomerRole(normalizedOrgId); - - // Step 3: Create role mapping - await this.createRoleMapping(normalizedOrgId); - - // Step 4: Create index template with field mappings - await this.createIndexTemplate(normalizedOrgId); - - // Step 5: Create seed index so the index pattern can resolve fields - await this.createSeedIndex(normalizedOrgId); - - // Step 6: Create index pattern in Dashboards - await this.createIndexPattern(normalizedOrgId); - - this.logger.log(`Tenant provisioned successfully: ${normalizedOrgId}`); - return true; - } catch (error: any) { - this.logger.error( - `Failed to provision tenant ${normalizedOrgId}: ${error?.message || error}`, - ); - return false; - } - } - - /** - * Creates a tenant in OpenSearch Security. - */ - private async createTenant(orgId: string): Promise { - const url = `${this.opensearchUrl}/_plugins/_security/api/tenants/${orgId}`; - - const response = await this.fetchWithRetry( - url, - { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: this.getAuthHeader(), - }, - body: JSON.stringify({ - description: `Tenant for organization ${orgId}`, - }), - }, - `createTenant(${orgId})`, - ); - - // 200 = created, 409 = already exists (both are OK) - if (!response.ok && response.status !== 409) { - throw new Error(`Failed to create tenant: ${response.status} ${response.statusText}`); - } - - this.logger.debug(`Tenant created/verified: ${orgId}`); - } - - /** - * Creates a read-only customer role for the organization. - * Grants read-only access to security findings indices, plus the minimum - * Dashboards/Notifications permissions required for tenant-scoped UI usage. - */ - private async createCustomerRole(orgId: string): Promise { - const roleName = `customer_${orgId}_ro`; - const url = `${this.opensearchUrl}/_plugins/_security/api/roles/${roleName}`; - const tenantSavedObjectsPattern = `.kibana_*_${orgId.replace(/[^a-z0-9]/g, '')}*`; - - const roleDefinition = { - cluster_permissions: [ - 'cluster_composite_ops_ro', - // Required for Dashboards saved objects (bulk writes to .kibana_* tenant indices) - 'indices:data/write/bulk', - // Alerting: monitor CRUD, execution, alerts, and destinations (legacy endpoints) - 'cluster:admin/opendistro/alerting/monitor/get', - 'cluster:admin/opendistro/alerting/monitor/search', - 'cluster:admin/opendistro/alerting/monitor/write', - 'cluster:admin/opendistro/alerting/monitor/execute', - 'cluster:admin/opendistro/alerting/alerts/get', - 'cluster:admin/opendistro/alerting/alerts/ack', - 'cluster:admin/opendistro/alerting/destination/get', - 'cluster:admin/opendistro/alerting/destination/write', - 'cluster:admin/opendistro/alerting/destination/delete', - // Notifications plugin (OpenSearch 2.x): channel features + config CRUD - 'cluster:admin/opensearch/notifications/features', - 'cluster:admin/opensearch/notifications/configs/get', - 'cluster:admin/opensearch/notifications/configs/create', - 'cluster:admin/opensearch/notifications/configs/update', - 'cluster:admin/opensearch/notifications/configs/delete', - ], - index_permissions: [ - { - index_patterns: [`security-findings-${orgId}-*`], - allowed_actions: ['read', 'indices:data/read/*'], - }, - { - // Tenant-scoped Dashboards saved objects index alias/index - index_patterns: [tenantSavedObjectsPattern], - allowed_actions: [ - 'read', - 'write', - 'create_index', - 'indices:data/read/*', - 'indices:data/write/*', - 'indices:admin/mapping/put', - ], - }, - ], - tenant_permissions: [ - { - tenant_patterns: [orgId], - allowed_actions: ['kibana_all_write'], - }, - ], - }; - - const response = await this.fetchWithRetry( - url, - { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: this.getAuthHeader(), - }, - body: JSON.stringify(roleDefinition), - }, - `createCustomerRole(${orgId})`, - ); - - if (!response.ok && response.status !== 409) { - throw new Error(`Failed to create role: ${response.status} ${response.statusText}`); - } - - this.logger.debug(`Role created/verified: ${roleName}`); - } - - /** - * Creates a role mapping for the customer role. - * Maps the role name to backend_roles so nginx proxy auth works. - */ - private async createRoleMapping(orgId: string): Promise { - const roleName = `customer_${orgId}_ro`; - const url = `${this.opensearchUrl}/_plugins/_security/api/rolesmapping/${roleName}`; - - const mappingDefinition = { - backend_roles: [roleName], - description: `Role mapping for ${orgId} read-only access`, - }; - - const response = await this.fetchWithRetry( - url, - { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: this.getAuthHeader(), - }, - body: JSON.stringify(mappingDefinition), - }, - `createRoleMapping(${orgId})`, - ); - - if (!response.ok && response.status !== 409) { - throw new Error(`Failed to create role mapping: ${response.status} ${response.statusText}`); - } - - this.logger.debug(`Role mapping created/verified: ${roleName}`); - } - - /** - * Creates an index template so all future security-findings-{orgId}-* indices - * get proper field mappings automatically. - */ - private async createIndexTemplate(orgId: string): Promise { - const templateName = `security-findings-${orgId}`; - const url = `${this.opensearchUrl}/_index_template/${templateName}`; - - const templateDefinition = { - index_patterns: [`security-findings-${orgId}-*`], - template: { - mappings: { - properties: { - '@timestamp': { type: 'date' }, - workflow_id: { type: 'keyword' }, - workflow_name: { type: 'keyword' }, - run_id: { type: 'keyword' }, - node_ref: { type: 'keyword' }, - component_id: { type: 'keyword' }, - asset_key: { type: 'keyword' }, - }, - }, - }, - priority: 100, - }; - - const response = await this.fetchWithRetry( - url, - { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: this.getAuthHeader(), - }, - body: JSON.stringify(templateDefinition), - }, - `createIndexTemplate(${orgId})`, - ); - - if (!response.ok) { - throw new Error(`Failed to create index template: ${response.status} ${response.statusText}`); - } - - this.logger.debug(`Index template created/verified: ${templateName}`); - } - - /** - * Creates a seed index with explicit mappings so the Dashboards index pattern - * can resolve fields (especially @timestamp) before any real data is ingested. - */ - private async createSeedIndex(orgId: string): Promise { - const indexName = `security-findings-${orgId}-seed`; - const url = `${this.opensearchUrl}/${indexName}`; - - const response = await this.fetchWithRetry( - url, - { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: this.getAuthHeader(), - }, - body: JSON.stringify({ - mappings: { - properties: { - '@timestamp': { type: 'date' }, - workflow_id: { type: 'keyword' }, - workflow_name: { type: 'keyword' }, - run_id: { type: 'keyword' }, - node_ref: { type: 'keyword' }, - component_id: { type: 'keyword' }, - asset_key: { type: 'keyword' }, - }, - }, - }), - }, - `createSeedIndex(${orgId})`, - ); - - // 200 = created, 400 with "already exists" = OK - if (!response.ok && response.status !== 400) { - throw new Error(`Failed to create seed index: ${response.status} ${response.statusText}`); - } - - this.logger.debug(`Seed index created/verified: ${indexName}`); - } - - /** - * Creates an index pattern in OpenSearch Dashboards for this tenant. - */ - private async createIndexPattern(orgId: string): Promise { - const patternId = `security-findings-${orgId}-*`; - const url = `${this.dashboardsUrl}/analytics/api/saved_objects/index-pattern/${encodeURIComponent(patternId)}`; - - const response = await this.fetchWithRetry( - url, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'osd-xsrf': 'true', - securitytenant: orgId, // Create in tenant's namespace - 'x-proxy-user': this.adminUsername, // Required for Dashboards proxy auth mode - 'x-proxy-roles': 'platform_admin', - 'x-forwarded-for': '127.0.0.1', // Required for proxy auth trust chain - }, - body: JSON.stringify({ - attributes: { - title: patternId, - timeFieldName: '@timestamp', - }, - }), - }, - `createIndexPattern(${orgId})`, - ); - - // 200 = created, 409 = already exists (both are OK) - if (!response.ok && response.status !== 409) { - const body = await response.text().catch(() => ''); - throw new Error( - `Failed to create index pattern: ${response.status} ${response.statusText} - ${body}`, - ); - } - - this.logger.debug(`Index pattern created/verified: ${patternId}`); - } - - /** - * Check if security mode is enabled. - */ - isSecurityEnabled(): boolean { - return this.securityEnabled; - } -} diff --git a/backend/src/analytics/organization-settings.service.ts b/backend/src/analytics/organization-settings.service.ts index c33adf691..b9187cd8a 100644 --- a/backend/src/analytics/organization-settings.service.ts +++ b/backend/src/analytics/organization-settings.service.ts @@ -6,10 +6,9 @@ import { DRIZZLE_TOKEN } from '../database/database.module'; import { organizationSettingsTable, OrganizationSettings, - SubscriptionTier, } from '../database/schema/organization-settings'; -import { TIER_LIMITS } from './dto/analytics-settings.dto'; -import { OpenSearchTenantService } from './opensearch-tenant.service'; +import { MAX_ANALYTICS_RETENTION_DAYS } from './dto/analytics-settings.dto'; +import { ClickHouseTenantService } from './clickhouse-tenant.service'; @Injectable() export class OrganizationSettingsService { @@ -18,7 +17,7 @@ export class OrganizationSettingsService { constructor( @Inject(DRIZZLE_TOKEN) private readonly db: NodePgDatabase, - private readonly tenantService: OpenSearchTenantService, + private readonly tenantService: ClickHouseTenantService, ) {} /** @@ -41,14 +40,13 @@ export class OrganizationSettingsService { .insert(organizationSettingsTable) .values({ organizationId, - subscriptionTier: 'free', analyticsRetentionDays: 30, }) .returning(); - // Provision OpenSearch tenant for the new organization (fire-and-forget) - this.tenantService.ensureTenantExists(organizationId).catch((err) => { - this.logger.error(`Failed to provision OpenSearch tenant for ${organizationId}: ${err}`); + // Provision ClickHouse schema (fire-and-forget) + this.tenantService.ensureSchemaExists().catch((err) => { + this.logger.error(`Failed to provision ClickHouse schema for ${organizationId}: ${err}`); }); return created; @@ -61,7 +59,6 @@ export class OrganizationSettingsService { organizationId: string, updates: { analyticsRetentionDays?: number; - subscriptionTier?: SubscriptionTier; }, ): Promise { // Ensure settings exist @@ -87,15 +84,14 @@ export class OrganizationSettingsService { /** * Validate retention period is within tier limits */ - validateRetentionPeriod(tier: SubscriptionTier, retentionDays: number): boolean { - const limit = TIER_LIMITS[tier]; - return retentionDays <= limit.maxRetentionDays && retentionDays > 0; + validateRetentionPeriod(retentionDays: number): boolean { + return retentionDays <= MAX_ANALYTICS_RETENTION_DAYS && retentionDays > 0; } /** - * Get max retention days for a tier + * Get the max retention days available in the public single-tenant build. */ - getMaxRetentionDays(tier: SubscriptionTier): number { - return TIER_LIMITS[tier].maxRetentionDays; + getMaxRetentionDays(): number { + return MAX_ANALYTICS_RETENTION_DAYS; } } diff --git a/backend/src/analytics/schema/security-findings.init.sql b/backend/src/analytics/schema/security-findings.init.sql new file mode 100644 index 000000000..e08e2586b --- /dev/null +++ b/backend/src/analytics/schema/security-findings.init.sql @@ -0,0 +1,160 @@ +-- ClickHouse schema initialization +-- This runs automatically on first container startup via /docker-entrypoint-initdb.d/ + +CREATE TABLE IF NOT EXISTS security_findings ( + -- Primary identifiers + organization_id LowCardinality(String), + finding_hash String, + + -- Timestamps (DoubleDelta codec: ~90% compression on sorted timestamps) + timestamp DateTime64(3) CODEC(DoubleDelta, ZSTD(3)), + ingested_at DateTime64(3) DEFAULT now64(3) CODEC(DoubleDelta, ZSTD(3)), + + -- Materialized partition key — computed from timestamp, zero storage overhead vs DateTime64 + event_date Date MATERIALIZED toDate(timestamp) CODEC(Delta, ZSTD(3)), + + -- Workflow context + workflow_id String DEFAULT '', + workflow_name String DEFAULT '', + run_id String DEFAULT '', + node_ref String DEFAULT '', + component_id LowCardinality(String) DEFAULT '', + + -- Asset context + asset_key String DEFAULT '', + + -- Trigger context + trigger_source String DEFAULT '', + trigger_type String DEFAULT '', + + -- Scan context + repository String DEFAULT '', + branch String DEFAULT '', + commit_sha String DEFAULT '', + account_id String DEFAULT '', + connection_name String DEFAULT '', + provider LowCardinality(String) DEFAULT '', + + -- Finding core fields + scanner LowCardinality(String) DEFAULT '', + severity LowCardinality(String) DEFAULT '', + status LowCardinality(String) DEFAULT '', + title String DEFAULT '', + description String DEFAULT '', + rule_id String DEFAULT '', + check_name String DEFAULT '', + check_id String DEFAULT '', + region LowCardinality(String) DEFAULT '', + service_name LowCardinality(String) DEFAULT '', + ip String DEFAULT '', + file String DEFAULT '', + source_input String DEFAULT '', + compliance Array(String) DEFAULT [], + line UInt32 DEFAULT 0 CODEC(T64, ZSTD(3)), + snippet String DEFAULT '', + resource_tags Map(String, String) DEFAULT map(), + + -- All other dynamic fields as JSON + extra JSON DEFAULT '{}', + + -- Skip indexes for filtered queries + INDEX idx_scanner scanner TYPE set(20) GRANULARITY 1, + INDEX idx_severity severity TYPE set(10) GRANULARITY 1, + INDEX idx_status status TYPE set(10) GRANULARITY 1, + INDEX idx_rule_id rule_id TYPE bloom_filter(0.01) GRANULARITY 4, + INDEX idx_asset_key asset_key TYPE bloom_filter(0.01) GRANULARITY 4, + INDEX idx_file file TYPE bloom_filter(0.01) GRANULARITY 4, + INDEX idx_check_name check_name TYPE bloom_filter(0.01) GRANULARITY 4, + -- Full-text indexes + INDEX idx_title title TYPE tokenbf_v1(10240, 3, 0) GRANULARITY 4, + INDEX idx_description description TYPE tokenbf_v1(10240, 3, 0) GRANULARITY 4, + + -- Projections for alternative sort orders (auto-selected by optimizer) + PROJECTION proj_by_severity ( + SELECT organization_id, severity, status, scanner, timestamp, finding_hash + ORDER BY (organization_id, severity, timestamp) + ), + PROJECTION proj_by_scanner ( + SELECT organization_id, scanner, severity, status, timestamp, finding_hash + ORDER BY (organization_id, scanner, timestamp) + ), + PROJECTION proj_severity_counts ( + SELECT organization_id, severity, status, count() AS cnt, max(timestamp) AS latest + GROUP BY organization_id, severity, status + ) +) +ENGINE = MergeTree() +PARTITION BY toYYYYMM(timestamp) +ORDER BY (organization_id, timestamp, finding_hash) +TTL timestamp + INTERVAL 90 DAY DELETE +SETTINGS index_granularity = 8192, + default_compression_codec = 'ZSTD(3)', + min_age_to_force_merge_seconds = 3600; + +CREATE TABLE IF NOT EXISTS security_findings_latest ( + organization_id LowCardinality(String), + finding_hash String, + timestamp DateTime64(3) CODEC(DoubleDelta, ZSTD(3)), + ingested_at DateTime64(3) DEFAULT now64(3) CODEC(DoubleDelta, ZSTD(3)), + workflow_id String DEFAULT '', + workflow_name String DEFAULT '', + run_id String DEFAULT '', + node_ref String DEFAULT '', + component_id LowCardinality(String) DEFAULT '', + asset_key String DEFAULT '', + trigger_source String DEFAULT '', + trigger_type String DEFAULT '', + repository String DEFAULT '', + branch String DEFAULT '', + commit_sha String DEFAULT '', + account_id String DEFAULT '', + connection_name String DEFAULT '', + provider LowCardinality(String) DEFAULT '', + scanner LowCardinality(String) DEFAULT '', + severity LowCardinality(String) DEFAULT '', + status LowCardinality(String) DEFAULT '', + title String DEFAULT '', + description String DEFAULT '', + rule_id String DEFAULT '', + check_name String DEFAULT '', + check_id String DEFAULT '', + region LowCardinality(String) DEFAULT '', + service_name LowCardinality(String) DEFAULT '', + ip String DEFAULT '', + file String DEFAULT '', + source_input String DEFAULT '', + compliance Array(String) DEFAULT [], + line UInt32 DEFAULT 0 CODEC(T64, ZSTD(3)), + snippet String DEFAULT '', + resource_tags Map(String, String) DEFAULT map(), + extra JSON DEFAULT '{}', + + -- Skip indexes + INDEX idx_scanner scanner TYPE set(20) GRANULARITY 1, + INDEX idx_severity severity TYPE set(10) GRANULARITY 1, + INDEX idx_status status TYPE set(10) GRANULARITY 1, + INDEX idx_rule_id rule_id TYPE bloom_filter(0.01) GRANULARITY 4, + INDEX idx_asset_key asset_key TYPE bloom_filter(0.01) GRANULARITY 4, + INDEX idx_file file TYPE bloom_filter(0.01) GRANULARITY 4, + INDEX idx_check_name check_name TYPE bloom_filter(0.01) GRANULARITY 4, + -- Full-text indexes + INDEX idx_title title TYPE tokenbf_v1(10240, 3, 0) GRANULARITY 4, + INDEX idx_description description TYPE tokenbf_v1(10240, 3, 0) GRANULARITY 4, + + -- Aggregate projection for dashboard counts + PROJECTION proj_latest_counts ( + SELECT organization_id, severity, status, scanner, count() AS cnt + GROUP BY organization_id, severity, status, scanner + ) +) +ENGINE = ReplacingMergeTree(ingested_at) +PARTITION BY organization_id +ORDER BY (organization_id, finding_hash) +SETTINGS index_granularity = 8192, + default_compression_codec = 'ZSTD(3)', + min_age_to_force_merge_seconds = 3600, + deduplicate_merge_projection_mode = 'rebuild'; + +CREATE MATERIALIZED VIEW IF NOT EXISTS security_findings_latest_mv +TO security_findings_latest +AS SELECT * FROM security_findings; diff --git a/backend/src/analytics/security-analytics.service.ts b/backend/src/analytics/security-analytics.service.ts index ce53a6451..3706148b4 100644 --- a/backend/src/analytics/security-analytics.service.ts +++ b/backend/src/analytics/security-analytics.service.ts @@ -1,5 +1,13 @@ import { Injectable, Logger } from '@nestjs/common'; -import { OpenSearchClient } from '../config/opensearch.client'; +import { ClickHouseClientService } from '../config/clickhouse.client'; +import { + FILTERABLE_COLUMNS, + ARRAY_COLUMNS, + MAP_COLUMNS, + buildFindingRow, + detectAssetKey, + severityOrderExpr, +} from '@shipsec/component-sdk'; interface IndexDocumentOptions { workflowId: string; @@ -8,232 +16,683 @@ interface IndexDocumentOptions { nodeRef: string; componentId: string; assetKeyField?: string; - indexSuffix?: string; } -type BulkIndexOptions = IndexDocumentOptions; - @Injectable() export class SecurityAnalyticsService { private readonly logger = new Logger(SecurityAnalyticsService.name); - constructor(private readonly openSearchClient: OpenSearchClient) {} + constructor(private readonly clickhouseClient: ClickHouseClientService) {} /** - * Index a single document to OpenSearch with metadata + * Index a single document to ClickHouse with metadata */ async indexDocument( orgId: string, document: Record, options: IndexDocumentOptions, ): Promise { - if (!this.openSearchClient.isClientEnabled()) { - this.logger.debug('OpenSearch client not enabled, skipping indexing'); + if (!this.clickhouseClient.isClientEnabled()) { + this.logger.debug('ClickHouse client not enabled, skipping indexing'); return; } - const client = this.openSearchClient.getClient(); - if (!client) { - this.logger.warn('OpenSearch client is null, skipping indexing'); + await this.bulkIndex(orgId, [document], options); + } + + /** + * Bulk index multiple documents to ClickHouse + */ + async bulkIndex( + orgId: string, + documents: Record[], + options: IndexDocumentOptions, + ): Promise { + if (!this.clickhouseClient.isClientEnabled()) { + this.logger.debug('ClickHouse client not enabled, skipping bulk indexing'); return; } + const client = this.clickhouseClient.getClient(); + if (!client || documents.length === 0) return; + try { - const indexName = this.buildIndexName(orgId, options.indexSuffix); - const assetKey = this.detectAssetKey(document, options.assetKeyField); - - const enrichedDocument = { - ...document, - '@timestamp': new Date().toISOString(), - workflow_id: options.workflowId, - workflow_name: options.workflowName, - run_id: options.runId, - node_ref: options.nodeRef, - component_id: options.componentId, - ...(assetKey && { asset_key: assetKey }), - }; + const rows = documents.map((document) => { + const assetKey = detectAssetKey(document, options.assetKeyField); + return buildFindingRow(orgId, document, { + workflowId: options.workflowId, + workflowName: options.workflowName, + runId: options.runId, + nodeRef: options.nodeRef, + componentId: options.componentId, + assetKey, + }); + }); - await client.index({ - index: indexName, - body: enrichedDocument, + await client.insert({ + table: 'security_findings', + values: rows, + format: 'JSONEachRow', }); - this.logger.debug(`Indexed document to ${indexName} for workflow ${options.workflowId}`); + this.logger.debug( + `Bulk indexed ${documents.length} documents for workflow ${options.workflowId}`, + ); } catch (error) { - this.logger.error(`Failed to index document: ${error}`); + this.logger.error(`Failed to bulk index documents: ${error}`); throw error; } } /** - * Bulk index multiple documents to OpenSearch + * Query analytics data for an organization. + * + * Accepts a simplified query interface that gets translated to ClickHouse SQL. */ - async bulkIndex( + async query( orgId: string, - documents: Record[], - options: BulkIndexOptions, - ): Promise { - if (!this.openSearchClient.isClientEnabled()) { - this.logger.debug('OpenSearch client not enabled, skipping bulk indexing'); - return; + options: { + filters?: { field: string; op: string; value?: any }[]; + search?: string; + size?: number; + from?: number; + sort?: { field: string; direction: 'asc' | 'desc' }[]; + aggs?: { name: string; field: string; size?: number }[]; + distinct?: string; + countDistinct?: string; + useLatest?: boolean; + }, + ): Promise<{ + total: number; + results: Record[]; + aggregations?: Record; + distinctCount?: number; + }> { + if (!this.clickhouseClient.isClientEnabled()) { + this.logger.warn('ClickHouse client not enabled, returning empty results'); + return { total: 0, results: [] }; } - const client = this.openSearchClient.getClient(); - if (!client) { - this.logger.warn('OpenSearch client is null, skipping bulk indexing'); - return; + const client = this.clickhouseClient.getClient(); + if (!client) return { total: 0, results: [] }; + + try { + const table = options.useLatest ? 'security_findings_latest FINAL' : 'security_findings'; + + const { conditions, params } = this.buildWhereConditions(orgId, options); + const whereClause = conditions.join(' AND '); + + // Run aggregations if requested + let aggregations: Record | undefined; + if (options.aggs && options.aggs.length > 0) { + aggregations = await this.executeAggs(client, table, whereClause, params, options.aggs); + } + + // Run COUNT(DISTINCT) if requested + let distinctCount: number | undefined; + if (options.countDistinct) { + const distinctField = options.countDistinct; + if (!FILTERABLE_COLUMNS.has(distinctField)) { + this.logger.warn(`Ignoring unknown countDistinct field: ${distinctField}`); + } else { + const dcResult = await client.query({ + query: `SELECT count(DISTINCT ${distinctField}) as cnt FROM ${table} WHERE ${whereClause}`, + query_params: params, + format: 'JSONEachRow', + }); + const dcRows = await dcResult.json<{ cnt: string }>(); + distinctCount = parseInt(dcRows[0]?.cnt ?? '0'); + } + } + + const countQuery = `SELECT count() as cnt FROM ${table} WHERE ${whereClause}`; + + // If size is 0, skip data query (agg-only request) + if (options.size === 0) { + const countResult = await client.query({ + query: countQuery, + query_params: params, + format: 'JSONEachRow', + }); + const countRows = await countResult.json<{ cnt: string }>(); + const total = parseInt(countRows[0]?.cnt ?? '0'); + return { total, results: [], aggregations, distinctCount }; + } + + const countResult = await client.query({ + query: countQuery, + query_params: params, + format: 'JSONEachRow', + }); + const countRows = await countResult.json<{ cnt: string }>(); + const total = parseInt(countRows[0]?.cnt ?? '0'); + + // Data query + const size = options.size ?? 10; + const from = options.from ?? 0; + const orderBy = this.buildOrderBy(options.sort); + + let limitByClause = ''; + if (options.distinct) { + if (!FILTERABLE_COLUMNS.has(options.distinct)) { + this.logger.warn(`Ignoring unknown distinct field: ${options.distinct}`); + } else { + limitByClause = `LIMIT 1 BY ${options.distinct}`; + } + } + + const dataQuery = `SELECT * FROM ${table} WHERE ${whereClause} ORDER BY ${orderBy} ${limitByClause} LIMIT {limit:UInt32} OFFSET {offset:UInt32}`; + + const dataResult = await client.query({ + query: dataQuery, + query_params: { ...params, limit: size, offset: from }, + format: 'JSONEachRow', + }); + const rows = await dataResult.json>(); + + const results = rows.map((row) => { + // With native JSON type, extra is returned as an object by ClickHouse + const { extra, ...rest } = row; + return { + id: row.finding_hash || '', + ...(extra && typeof extra === 'object' ? extra : {}), + ...rest, + }; + }); + + return { total, results, aggregations, distinctCount }; + } catch (error) { + this.logger.error(`Failed to query analytics data: ${error}`); + throw error; } + } - if (documents.length === 0) { - this.logger.debug('No documents to index, skipping bulk indexing'); - return; + /** + * Time-series query: returns { date, severity, count }[] buckets + */ + async timeSeries( + orgId: string, + options: { + filters?: { field: string; op: string; value?: any }[]; + interval?: 'day' | 'week' | 'month'; + days?: number; + timezone?: string; + }, + ): Promise<{ buckets: { date: string; severity: string; count: number }[] }> { + if (!this.clickhouseClient.isClientEnabled()) { + return { buckets: [] }; } + const client = this.clickhouseClient.getClient(); + if (!client) return { buckets: [] }; try { - const indexName = this.buildIndexName(orgId, options.indexSuffix); - - // Build bulk operations array - const bulkOps: any[] = []; - for (const document of documents) { - const assetKey = this.detectAssetKey(document, options.assetKeyField); - - const enrichedDocument = { - ...document, - '@timestamp': new Date().toISOString(), - workflow_id: options.workflowId, - workflow_name: options.workflowName, - run_id: options.runId, - node_ref: options.nodeRef, - component_id: options.componentId, - ...(assetKey && { asset_key: assetKey }), - }; + const table = 'security_findings'; + const days = options.days ?? 30; + const interval = options.interval ?? 'day'; + + const allFilters = [ + ...(options.filters ?? []), + { + field: 'timestamp', + op: 'gte', + value: new Date(Date.now() - days * 86_400_000).toISOString(), + }, + ]; + const { conditions, params } = this.buildWhereConditions(orgId, { filters: allFilters }); + const whereClause = conditions.join(' AND '); + + const truncFn = + interval === 'week' + ? 'toMonday(timestamp)' + : interval === 'month' + ? 'toStartOfMonth(timestamp)' + : 'toDate(timestamp)'; + + const result = await client.query({ + query: `SELECT toString(${truncFn}) as date, severity, count() as count FROM ${table} WHERE ${whereClause} GROUP BY date, severity ORDER BY date ASC`, + query_params: params, + format: 'JSONEachRow', + }); + const rows = await result.json<{ date: string; severity: string; count: string }>(); - bulkOps.push({ index: { _index: indexName } }); - bulkOps.push(enrichedDocument); - } + return { + buckets: rows.map((r) => ({ + date: r.date, + severity: r.severity, + count: parseInt(r.count), + })), + }; + } catch (error) { + this.logger.error(`Failed to query time series: ${error}`); + throw error; + } + } + + async scannerSeverityCounts( + orgId: string, + options: { + filters?: { field: string; op: string; value?: any }[]; + useLatest?: boolean; + }, + ): Promise<{ buckets: { scanner: string; severity: string; count: number }[] }> { + if (!this.clickhouseClient.isClientEnabled()) { + return { buckets: [] }; + } + + const client = this.clickhouseClient.getClient(); + if (!client) return { buckets: [] }; - const response = await client.bulk({ - body: bulkOps, + try { + const table = options.useLatest ? 'security_findings_latest FINAL' : 'security_findings'; + const { conditions, params } = this.buildWhereConditions(orgId, options); + const whereClause = conditions.join(' AND '); + + const result = await client.query({ + query: ` + SELECT scanner, severity, count() as count + FROM ${table} + WHERE ${whereClause} + GROUP BY scanner, severity + ORDER BY scanner ASC, severity ASC + `, + query_params: params, + format: 'JSONEachRow', }); + const rows = await result.json<{ scanner: string; severity: string; count: string }>(); + + return { + buckets: rows.map((row) => ({ + scanner: row.scanner, + severity: row.severity, + count: parseInt(row.count), + })), + }; + } catch (error) { + this.logger.error(`Failed to query scanner severity counts: ${error}`); + throw error; + } + } - if (response.body.errors) { - const errorCount = response.body.items.filter((item: any) => item.index?.error).length; - this.logger.warn( - `Bulk indexing completed with ${errorCount} errors out of ${documents.length} documents`, - ); - } else { - this.logger.debug( - `Bulk indexed ${documents.length} documents to ${indexName} for workflow ${options.workflowId}`, - ); + /** + * Finding lifecycle: first seen, last seen, occurrences + */ + async lifecycle( + orgId: string, + hashes: string[], + ): Promise> { + if (!this.clickhouseClient.isClientEnabled() || hashes.length === 0) return {}; + const client = this.clickhouseClient.getClient(); + if (!client) return {}; + + try { + const result = await client.query({ + query: ` + SELECT + finding_hash, + min(timestamp) as first_seen, + max(timestamp) as last_seen, + count() as occurrences + FROM security_findings + WHERE organization_id = {orgId:String} + AND finding_hash IN {hashes:Array(String)} + GROUP BY finding_hash + `, + query_params: { orgId: orgId.toLowerCase(), hashes }, + format: 'JSONEachRow', + }); + + const rows = await result.json<{ + finding_hash: string; + first_seen: string; + last_seen: string; + occurrences: string; + }>(); + + const lifecycles: Record< + string, + { firstSeen: string; lastSeen: string; occurrences: number } + > = {}; + for (const row of rows) { + lifecycles[row.finding_hash] = { + firstSeen: row.first_seen, + lastSeen: row.last_seen, + occurrences: parseInt(row.occurrences), + }; } + return lifecycles; } catch (error) { - this.logger.error(`Failed to bulk index documents: ${error}`); + this.logger.error(`Failed to query lifecycles: ${error}`); throw error; } } /** - * Build the index name with org scoping and date-based rotation - * Format: security-findings-{orgId}-{YYYY.MM.DD} + * Build ORDER BY clause with semantic severity ordering. */ - private buildIndexName(orgId: string, indexSuffix?: string): string { - const date = new Date(); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - - const suffix = indexSuffix || `${year}.${month}.${day}`; - return `security-findings-${orgId}-${suffix}`; + private buildOrderBy(sort?: { field: string; direction: 'asc' | 'desc' }[]): string { + if (!sort || sort.length === 0) return 'timestamp DESC'; + + return sort + .map((s) => { + if (!FILTERABLE_COLUMNS.has(s.field)) { + this.logger.warn(`Ignoring unknown sort field: ${s.field}`); + return 'timestamp DESC'; + } + if (s.field === 'severity') { + return severityOrderExpr(s.direction); + } + return `${s.field} ${s.direction}`; + }) + .join(', '); } /** - * Query analytics data for an organization + * Build WHERE conditions and params from filter options. + * All field names are validated against FILTERABLE_COLUMNS to prevent SQL injection. */ - async query( + private buildWhereConditions( orgId: string, options: { - query?: Record; - size?: number; - from?: number; - aggs?: Record; + filters?: { field: string; op: string; value?: any }[]; + search?: string; }, - ): Promise<{ - total: number; - hits: { _id: string; _source: Record; _score?: number }[]; - aggregations?: Record; + ): { conditions: string[]; params: Record } { + const conditions = [`organization_id = {orgId:String}`]; + const params: Record = { orgId: orgId.toLowerCase() }; + + if (options.filters) { + for (let i = 0; i < options.filters.length; i++) { + const f = options.filters[i]; + const paramName = `f${i}`; + + // Handle resource_tags.key filters specially + if (f.field.startsWith('resource_tags.')) { + const tagKey = f.field.slice('resource_tags.'.length); + const tagParamName = `tag${i}`; + if (f.op === 'exists') { + params[tagParamName] = tagKey; + conditions.push(`mapContains(resource_tags, {${tagParamName}:String})`); + } else if (f.op === 'eq') { + params[tagParamName] = tagKey; + params[paramName] = String(f.value); + conditions.push(`resource_tags[{${tagParamName}:String}] = {${paramName}:String}`); + } + continue; + } + + // Validate field name against allowlist + if (!FILTERABLE_COLUMNS.has(f.field)) { + this.logger.warn(`Ignoring unknown filter field: ${f.field}`); + continue; + } + + switch (f.op) { + case 'eq': + conditions.push(`${f.field} = {${paramName}:String}`); + params[paramName] = String(f.value); + break; + case 'not_eq': + conditions.push(`${f.field} != {${paramName}:String}`); + params[paramName] = String(f.value); + break; + case 'in': + if (ARRAY_COLUMNS.has(f.field)) { + // Array columns: use hasAny(column, [...]) to check intersection + conditions.push(`hasAny(${f.field}, {${paramName}:Array(String)})`); + } else { + conditions.push(`${f.field} IN {${paramName}:Array(String)}`); + } + params[paramName] = f.value; + break; + case 'gte': + conditions.push(this.buildComparisonCondition(f.field, '>=', paramName)); + params[paramName] = this.formatFilterValue(f.field, f.value); + break; + case 'lte': + conditions.push(this.buildComparisonCondition(f.field, '<=', paramName)); + params[paramName] = this.formatFilterValue(f.field, f.value); + break; + case 'like': + conditions.push(`${f.field} ILIKE {${paramName}:String}`); + params[paramName] = `%${f.value}%`; + break; + case 'prefix': + conditions.push(`${f.field} ILIKE {${paramName}:String}`); + params[paramName] = `${f.value}%`; + break; + case 'exists': + if (MAP_COLUMNS.has(f.field)) { + conditions.push(`notEmpty(${f.field})`); + } else { + conditions.push(`${f.field} != ''`); + } + break; + case 'not_exists': + if (MAP_COLUMNS.has(f.field)) { + conditions.push(`empty(${f.field})`); + } else { + conditions.push(`(${f.field} = '' OR ${f.field} IS NULL)`); + } + break; + } + } + } + + if (options.search) { + params.searchTerm = `%${options.search}%`; + conditions.push( + `(title ILIKE {searchTerm:String} OR description ILIKE {searchTerm:String} OR rule_id ILIKE {searchTerm:String} OR asset_key ILIKE {searchTerm:String} OR check_name ILIKE {searchTerm:String} OR file ILIKE {searchTerm:String})`, + ); + } + + return { conditions, params }; + } + + private buildComparisonCondition( + field: string, + operator: '>=' | '<=', + paramName: string, + ): string { + if (field === 'timestamp') { + return `${field} ${operator} toDateTime64({${paramName}:String}, 3, 'UTC')`; + } + return `${field} ${operator} {${paramName}:String}`; + } + + private formatFilterValue(field: string, value: any): string { + if (field !== 'timestamp') return String(value); + + const date = value instanceof Date ? value : new Date(String(value)); + if (Number.isNaN(date.getTime())) { + return String(value); + } + + return date.toISOString().replace('T', ' ').replace('Z', '').slice(0, 23); + } + + /** + * Execute named aggregations and return { name: [{ key, count }] } + */ + private async executeAggs( + client: NonNullable>, + table: string, + whereClause: string, + params: Record, + aggs: { name: string; field: string; size?: number }[], + ): Promise> { + const aggregations: Record = {}; + + await Promise.all( + aggs.map(async (agg) => { + // Validate aggregation field + if (!FILTERABLE_COLUMNS.has(agg.field)) { + this.logger.warn(`Ignoring unknown aggregation field: ${agg.field}`); + aggregations[agg.name] = []; + return; + } + + const limit = agg.size ?? 50; + // Array columns need arrayJoin to unnest elements for aggregation + const fieldExpr = ARRAY_COLUMNS.has(agg.field) ? `arrayJoin(${agg.field})` : agg.field; + const result = await client.query({ + query: `SELECT ${fieldExpr} as key, count() as count FROM ${table} WHERE ${whereClause} GROUP BY key ORDER BY count DESC LIMIT {aggLimit:UInt32}`, + query_params: { ...params, aggLimit: limit }, + format: 'JSONEachRow', + }); + const rows = await result.json<{ key: string; count: string }>(); + aggregations[agg.name] = rows.map((r) => ({ + key: String(r.key), + count: parseInt(r.count), + })); + }), + ); + + return aggregations; + } + + /** + * Get severity counts for multiple run IDs in a single query + */ + async batchRunSeverityCounts( + orgId: string, + runIds: string[], + ): Promise< + Record + > { + const zeroed = () => ({ critical: 0, high: 0, medium: 0, low: 0, info: 0 }); + const result: Record< + string, + { critical: number; high: number; medium: number; low: number; info: number } + > = {}; + for (const id of runIds) { + result[id] = zeroed(); + } + + if (!this.clickhouseClient.isClientEnabled()) return result; + + const client = this.clickhouseClient.getClient(); + if (!client) return result; + + try { + const queryResult = await client.query({ + query: ` + SELECT run_id, severity, count() as cnt + FROM security_findings + WHERE organization_id = {orgId:String} + AND run_id IN {runIds:Array(String)} + GROUP BY run_id, severity + `, + query_params: { orgId: orgId.toLowerCase(), runIds }, + format: 'JSONEachRow', + }); + + const rows = await queryResult.json<{ + run_id: string; + severity: string; + cnt: string; + }>(); + + for (const row of rows) { + const runId = row.run_id; + if (!result[runId]) continue; + const sev = row.severity.toLowerCase(); + if (sev in result[runId]) { + (result[runId] as any)[sev] = parseInt(row.cnt); + } + } + + return result; + } catch (error) { + this.logger.error(`Failed to batch query severity counts: ${error}`); + return result; + } + } + + /** + * Get storage usage for an organization's analytics data + */ + async getStorageUsage(orgId: string): Promise<{ + usedBytes: number; + documentCount: number; + partCount: number; + analyticsEnabled: boolean; }> { - if (!this.openSearchClient.isClientEnabled()) { - this.logger.warn('OpenSearch client not enabled, returning empty results'); - return { total: 0, hits: [], aggregations: undefined }; + if (!this.clickhouseClient.isClientEnabled()) { + return { usedBytes: 0, documentCount: 0, partCount: 0, analyticsEnabled: false }; } - const client = this.openSearchClient.getClient(); + const client = this.clickhouseClient.getClient(); if (!client) { - this.logger.warn('OpenSearch client is null, returning empty results'); - return { total: 0, hits: [], aggregations: undefined }; + return { usedBytes: 0, documentCount: 0, partCount: 0, analyticsEnabled: false }; } try { - // Build index pattern for org: security-findings-{orgId}-* - const indexPattern = `security-findings-${orgId}-*`; - - // Execute the search - const response = await client.search({ - index: indexPattern, - body: { - query: options.query || { match_all: {} }, - size: options.size ?? 10, - from: options.from ?? 0, - ...(options.aggs && { aggs: options.aggs }), - }, + const result = await client.query({ + query: ` + SELECT + sum(bytes_on_disk) as used_bytes, + sum(rows) as doc_count, + count() as part_count + FROM system.parts + WHERE active + AND database = currentDatabase() + AND table IN ('security_findings', 'security_findings_latest') + AND partition LIKE {orgPrefix:String} + `, + query_params: { orgPrefix: `%${orgId.toLowerCase()}%` }, + format: 'JSONEachRow', }); - // Extract results from OpenSearch response - const total: number = - typeof response.body.hits.total === 'object' - ? (response.body.hits.total.value ?? 0) - : (response.body.hits.total ?? 0); - - const hits = response.body.hits.hits.map((hit: any) => ({ - _id: hit._id, - _source: hit._source, - ...(hit._score !== undefined && { _score: hit._score }), - })); + const rows = await result.json<{ + used_bytes: string; + doc_count: string; + part_count: string; + }>(); + const row = rows[0]; return { - total, - hits, - aggregations: response.body.aggregations, + usedBytes: parseInt(row?.used_bytes ?? '0'), + documentCount: parseInt(row?.doc_count ?? '0'), + partCount: parseInt(row?.part_count ?? '0'), + analyticsEnabled: true, }; } catch (error) { - this.logger.error(`Failed to query analytics data: ${error}`); - throw error; + this.logger.error(`Failed to get storage usage: ${error}`); + return { usedBytes: 0, documentCount: 0, partCount: 0, analyticsEnabled: true }; } } /** - * Auto-detect asset key from common fields - * Priority: host > domain > subdomain > url > ip > asset > target + * Delete all analytics data for an organization. */ - private detectAssetKey(document: Record, explicitField?: string): string | null { - // If explicit field is provided, use it - if (explicitField && document[explicitField]) { - return String(document[explicitField]); + async purgeOrgData(orgId: string): Promise<{ tablesCleared: number }> { + if (!this.clickhouseClient.isClientEnabled()) { + return { tablesCleared: 0 }; } - if (document.asset_key) { - return String(document.asset_key); - } + const client = this.clickhouseClient.getClient(); + if (!client) return { tablesCleared: 0 }; - // Auto-detect from common fields - const assetFields = ['host', 'domain', 'subdomain', 'url', 'ip', 'asset', 'target']; + try { + const normalizedOrgId = orgId.toLowerCase(); - for (const field of assetFields) { - if (document[field]) { - return String(document[field]); - } - } + // Event log: lightweight DELETE (monthly partitions span all orgs) + await client.command({ + query: `DELETE FROM security_findings WHERE organization_id = {orgId:String}`, + query_params: { orgId: normalizedOrgId }, + }); - return null; + // State table: instant DROP PARTITION (partitioned by organization_id) + await client.command({ + query: `ALTER TABLE security_findings_latest DROP PARTITION {orgId:String}`, + query_params: { orgId: normalizedOrgId }, + }); + + this.logger.log(`Purged analytics data for org ${orgId}`); + return { tablesCleared: 1 }; + } catch (error) { + this.logger.error(`Failed to purge analytics data: ${error}`); + throw error; + } } } diff --git a/backend/src/api-keys/dto/api-key.dto.ts b/backend/src/api-keys/dto/api-key.dto.ts index 0589ffb83..a898fd027 100644 --- a/backend/src/api-keys/dto/api-key.dto.ts +++ b/backend/src/api-keys/dto/api-key.dto.ts @@ -49,6 +49,28 @@ export const ApiKeyPermissionsSchema = z.object({ resolve: z.boolean().optional(), }) .optional(), + analytics: z + .object({ + query: z.boolean().optional(), + }) + .optional(), + assets: z + .object({ + list: z.boolean().optional(), + read: z.boolean().optional(), + }) + .optional(), + 'cloud-graph': z + .object({ + query: z.boolean().optional(), + }) + .optional(), + 'report-templates': z + .object({ + read: z.boolean().optional(), + write: z.boolean().optional(), + }) + .optional(), }); export const CreateApiKeySchema = z.object({ diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts index 952f87eef..04fbfba51 100644 --- a/backend/src/app.controller.ts +++ b/backend/src/app.controller.ts @@ -5,6 +5,7 @@ import type { Response } from 'express'; import { AppService } from './app.service'; import { CurrentAuth } from './auth/auth-context.decorator'; +import { AuthService } from './auth/auth.service'; import type { AuthContext } from './auth/types'; import { Public } from './auth/public.decorator'; import type { AuthConfig } from './config/auth.config'; @@ -13,7 +14,7 @@ import { SESSION_COOKIE_MAX_AGE, createSessionToken, } from './auth/session.utils'; -import { OpenSearchTenantService } from './analytics/opensearch-tenant.service'; +import { ClickHouseTenantService } from './analytics/clickhouse-tenant.service'; @Controller() export class AppController { @@ -25,11 +26,13 @@ export class AppController { constructor( private readonly appService: AppService, private readonly configService: ConfigService, - private readonly tenantService: OpenSearchTenantService, + private readonly tenantService: ClickHouseTenantService, + private readonly authService: AuthService, ) { this.authCfg = this.configService.get('auth')!; } + @Public() @SkipThrottle() @Get('/health') health() { @@ -62,22 +65,20 @@ export class AppController { res.setHeader('X-Auth-Organization-Id', normalizedOrgId); res.setHeader('X-Auth-User-Id', auth.userId || ''); - // Ensure OpenSearch tenant exists for this org (fire-and-forget, cached) + // Ensure ClickHouse schema exists (fire-and-forget, cached) // Uses a Map of promises so: (1) concurrent requests share the same in-flight provisioning, // (2) failures are removed from cache to allow retry on next auth request. if (normalizedOrgId && !this.provisioningOrgs.has(normalizedOrgId)) { - const promise = this.tenantService.ensureTenantExists(normalizedOrgId).then( + const promise = this.tenantService.ensureSchemaExists().then( (success) => { if (!success) { - // Provisioning returned false (validation error, etc.) — allow retry this.provisioningOrgs.delete(normalizedOrgId); } return success; }, (err) => { - // Remove from cache so it retries next request this.provisioningOrgs.delete(normalizedOrgId); - this.logger.error(`Failed to provision OpenSearch tenant for ${normalizedOrgId}: ${err}`); + this.logger.error(`Failed to provision ClickHouse schema for ${normalizedOrgId}: ${err}`); return false; }, ); @@ -98,7 +99,7 @@ export class AppController { @Res({ passthrough: true }) res: Response, ) { // Only for local auth provider - if (this.authCfg.provider !== 'local') { + if (this.authService.providerName !== 'local') { throw new UnauthorizedException('Login endpoint only available for local auth'); } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index f7b24bf79..6cdfb0dc8 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; import { ConfigModule } from '@nestjs/config'; +import { EventEmitterModule } from '@nestjs/event-emitter'; import { join } from 'node:path'; import { ThrottlerModule, ThrottlerGuard, seconds } from '@nestjs/throttler'; import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis'; @@ -9,58 +10,24 @@ import Redis from 'ioredis'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { authConfig } from './config/auth.config'; -import { opensearchConfig } from './config/opensearch.config'; +import { clickhouseConfig } from './config/clickhouse.config'; import { validateBackendEnv } from './config/env.validate'; -import { OpenSearchModule } from './config/opensearch.module'; -import { AgentsModule } from './agents/agents.module'; -import { AuthModule } from './auth/auth.module'; +import { ClickHouseModule } from './config/clickhouse.module'; import { AuthGuard } from './auth/auth.guard'; import { RolesGuard } from './auth/roles.guard'; -import { ComponentsModule } from './components/components.module'; -import { StorageModule } from './storage/storage.module'; -import { SecretsModule } from './secrets/secrets.module'; -import { TraceModule } from './trace/trace.module'; -import { WorkflowsModule } from './workflows/workflows.module'; -import { TestingSupportModule } from './testing/testing.module'; -import { IntegrationsModule } from './integrations/integrations.module'; -import { SchedulesModule } from './schedules/schedules.module'; -import { AnalyticsModule } from './analytics/analytics.module'; -import { McpModule } from './mcp/mcp.module'; -import { StudioMcpModule } from './studio-mcp/studio-mcp.module'; -import { AuditModule } from './audit/audit.module'; - -import { ApiKeysModule } from './api-keys/api-keys.module'; -import { WebhooksModule } from './webhooks/webhooks.module'; -import { HumanInputsModule } from './human-inputs/human-inputs.module'; -import { McpServersModule } from './mcp-servers/mcp-servers.module'; -import { McpGroupsModule } from './mcp-groups/mcp-groups.module'; -import { TemplatesModule } from './templates/templates.module'; - -const coreModules = [ - AgentsModule, - AnalyticsModule, - AuthModule, - WorkflowsModule, - TraceModule, - ComponentsModule, - StorageModule, - SecretsModule, - IntegrationsModule, - SchedulesModule, - ApiKeysModule, - WebhooksModule, - HumanInputsModule, - McpServersModule, - McpGroupsModule, - McpModule, - StudioMcpModule, - TemplatesModule, - AuditModule, -]; - -const testingModules = process.env.NODE_ENV === 'production' ? [] : [TestingSupportModule]; +import { + getCoreBackendModules, + getOptionalBackendModules, + getTestingBackendModules, +} from './composition/backend-modules'; function getEnvFilePaths(): string[] { + const isProductionRuntime = + process.env.NODE_ENV === 'production' || process.env.SHIPSEC_ENV?.trim() === 'production'; + if (isProductionRuntime) { + return []; + } + // In multi-instance dev, each instance has its own env file under: // .instances/instance-N/backend.env // Backends run with cwd=backend/, so repo root is `..`. @@ -80,7 +47,7 @@ function getEnvFilePaths(): string[] { ConfigModule.forRoot({ isGlobal: true, envFilePath: getEnvFilePaths(), - load: [authConfig, opensearchConfig], + load: [authConfig, clickhouseConfig], validate: validateBackendEnv, }), ThrottlerModule.forRootAsync({ @@ -92,16 +59,18 @@ function getEnvFilePaths(): string[] { { name: 'default', ttl: seconds(60), // 60 seconds - limit: 100, // 100 requests per minute + limit: 500, // 500 requests per minute }, ], - storage: redisUrl ? new ThrottlerStorageRedisService(new Redis(redisUrl)) : undefined, // Falls back to in-memory storage if Redis not configured + storage: redisUrl ? new ThrottlerStorageRedisService(new Redis(redisUrl)) : undefined, }; }, }), - OpenSearchModule, - ...coreModules, - ...testingModules, + EventEmitterModule.forRoot(), + ClickHouseModule, + ...getCoreBackendModules(), + ...getOptionalBackendModules(), + ...getTestingBackendModules(), ], controllers: [AppController], providers: [ diff --git a/backend/src/asm/__tests__/asm.service.integration.spec.ts b/backend/src/asm/__tests__/asm.service.integration.spec.ts new file mode 100644 index 000000000..a1fef214b --- /dev/null +++ b/backend/src/asm/__tests__/asm.service.integration.spec.ts @@ -0,0 +1,201 @@ +import 'reflect-metadata'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'bun:test'; +import { randomUUID } from 'node:crypto'; + +import { drizzle } from 'drizzle-orm/node-postgres'; +import { eq, sql } from 'drizzle-orm'; +import { Pool } from 'pg'; + +import type { AuthContext } from '../../auth/types'; +import * as schema from '../../database/schema'; +import { asmAssets, asmDomains } from '../../database/schema/asm'; +import { AsmService } from '../asm.service'; + +const runIntegration = process.env.RUN_BACKEND_INTEGRATION === 'true'; + +const connectionString = + process.env.DATABASE_URL ?? + process.env.STUDIO_DATABASE_URL ?? + 'postgresql://shipsec:shipsec@localhost:5433/shipsec_instance_5'; + +const makeAuth = (organizationId: string): AuthContext => ({ + userId: `user-${randomUUID()}`, + organizationId, + roles: ['ADMIN'], + isAuthenticated: true, + provider: 'local', +}); + +describe.if(runIntegration)('AsmService integration', () => { + let pool: Pool; + let db: ReturnType>; + let service: AsmService; + const createdOrgIds = new Set(); + + beforeAll(() => { + pool = new Pool({ connectionString }); + db = drizzle(pool, { schema }); + service = new AsmService( + db as any, + { + list: async () => [], + } as any, + {} as any, + { + list: async () => [], + } as any, + ); + }); + + afterEach(async () => { + for (const organizationId of createdOrgIds) { + await db.delete(asmAssets).where(eq(asmAssets.organizationId, organizationId)); + await db.delete(asmDomains).where(eq(asmDomains.organizationId, organizationId)); + } + createdOrgIds.clear(); + }); + + afterAll(async () => { + await pool.end(); + }); + + it('reproduces the old ANY(($1)) Postgres failure with real ASM data', async () => { + const organizationId = `org-${randomUUID()}`; + createdOrgIds.add(organizationId); + + const [domain] = await db + .insert(asmDomains) + .values({ + organizationId, + domain: `example-${randomUUID().slice(0, 8)}.com`, + }) + .returning(); + + await db.insert(asmAssets).values({ + organizationId, + domainId: domain.id, + hostname: `app.${domain.domain}`, + type: 'subdomain', + techStack: ['nginx'], + }); + + await expect( + Promise.resolve( + db.execute(sql` + SELECT + domain_id, + COUNT(*)::int AS asset_count, + COALESCE( + ARRAY(SELECT DISTINCT unnest FROM unnest(array_agg(DISTINCT t.tech)) AS unnest WHERE unnest IS NOT NULL), + '{}' + ) AS techs + FROM asm_assets a + LEFT JOIN LATERAL unnest(a.tech_stack) AS t(tech) ON true + WHERE a.domain_id = ANY(${[domain.id]}) + GROUP BY domain_id + `), + ), + ).rejects.toThrow(); + }); + + it('lists ASM domains successfully with asset stats on real Postgres data', async () => { + const organizationId = `org-${randomUUID()}`; + createdOrgIds.add(organizationId); + const auth = makeAuth(organizationId); + + const [domain] = await db + .insert(asmDomains) + .values({ + organizationId, + domain: `example-${randomUUID().slice(0, 8)}.com`, + }) + .returning(); + + await db.insert(asmAssets).values([ + { + organizationId, + domainId: domain.id, + hostname: `app.${domain.domain}`, + type: 'subdomain', + techStack: ['nginx', 'react'], + }, + { + organizationId, + domainId: domain.id, + hostname: `api.${domain.domain}`, + type: 'subdomain', + techStack: ['nginx', 'bun'], + }, + ]); + + const result = await service.listDomains(auth, { limit: 10, offset: 0 }); + + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe(domain.id); + expect(result[0]?.domain).toBe(domain.domain); + expect(result[0]?.assetCount).toBe(2); + expect([...(result[0]?.techStack ?? [])].sort()).toEqual(['bun', 'nginx', 'react']); + }); + + it('upserts ASM recon assets by domain and hostname without duplicates', async () => { + const organizationId = `org-${randomUUID()}`; + createdOrgIds.add(organizationId); + + const [domain] = await db + .insert(asmDomains) + .values({ + organizationId, + domain: `example-${randomUUID().slice(0, 8)}.com`, + }) + .returning(); + + const first = await service.upsertAssetsForDomain(organizationId, { + domainId: domain.id, + runId: 'run-1', + workflowId: 'workflow-1', + assets: [ + { + hostname: `app.${domain.domain}`, + type: 'subdomain', + ipAddresses: ['1.2.3.4'], + techStack: ['nginx'], + ports: [{ port: 443, protocol: 'tcp', service: 'https' }], + dnsRecords: { answers: { a: ['1.2.3.4'] } }, + tlsInfo: { observedHttps: true }, + metadata: { url: `https://app.${domain.domain}` }, + }, + ], + }); + + const second = await service.upsertAssetsForDomain(organizationId, { + domainId: domain.id, + runId: 'run-2', + workflowId: 'workflow-1', + assets: [ + { + hostname: `app.${domain.domain}`, + type: 'subdomain', + ipAddresses: ['5.6.7.8'], + techStack: ['react'], + ports: [{ port: 80, protocol: 'tcp', service: 'http' }], + metadata: { title: 'Updated' }, + }, + ], + }); + + const rows = await db.select().from(asmAssets).where(eq(asmAssets.domainId, domain.id)); + + expect(first).toMatchObject({ created: 1, updated: 0, assetCount: 1 }); + expect(second).toMatchObject({ created: 0, updated: 1, assetCount: 1 }); + expect(rows).toHaveLength(1); + expect(rows[0]?.ipAddresses?.sort()).toEqual(['1.2.3.4', '5.6.7.8']); + expect(rows[0]?.techStack?.sort()).toEqual(['nginx', 'react']); + expect(rows[0]?.ports?.map((port) => port.port).sort()).toEqual([80, 443]); + expect(rows[0]?.metadata).toMatchObject({ + url: `https://app.${domain.domain}`, + title: 'Updated', + lastWorkflowRunId: 'run-2', + workflowId: 'workflow-1', + }); + }); +}); diff --git a/backend/src/asm/__tests__/asm.service.spec.ts b/backend/src/asm/__tests__/asm.service.spec.ts new file mode 100644 index 000000000..dec693da2 --- /dev/null +++ b/backend/src/asm/__tests__/asm.service.spec.ts @@ -0,0 +1,43 @@ +import 'reflect-metadata'; +import { describe, expect, it, mock } from 'bun:test'; + +import { AsmService } from '../asm.service'; +import type { AuthContext } from '../../auth/types'; + +const auth: AuthContext = { + userId: 'user-1', + organizationId: 'org-1', + roles: ['ADMIN'], + isAuthenticated: true, + provider: 'local', +}; + +describe('AsmService triggerDomainScan', () => { + it('injects ASM domain context into the asset inventory sink', async () => { + const run = mock(async () => ({ runId: 'run-1' })); + const service = Object.create(AsmService.prototype) as any; + + service.getDomain = async () => ({ id: 'domain-1', domain: 'shipsec.ai' }); + service.checkScanRateLimit = () => undefined; + service.resolveWorkflowIdForTemplate = async () => 'workflow-1'; + service.workflowsService = { run }; + service.logger = { log: () => undefined }; + + const result = await (service as AsmService).triggerDomainScan(auth, 'domain-1'); + + expect(result).toEqual({ runId: 'run-1', domain: 'shipsec.ai' }); + expect(run).toHaveBeenCalledTimes(1); + + const calls = run.mock.calls as unknown as any[][]; + const [, request, passedAuth, options] = calls[0] ?? []; + expect(request).toEqual({ inputs: { domain: 'shipsec.ai' } }); + expect(passedAuth).toBe(auth); + expect(options.componentParams['shipsec.subfinder.run']).toEqual({ + domain: 'shipsec.ai', + }); + expect(options.componentParams['core.asm.assets-upsert']).toEqual({ + domain: 'shipsec.ai', + domainId: 'domain-1', + }); + }); +}); diff --git a/backend/src/asm/asm-internal.controller.ts b/backend/src/asm/asm-internal.controller.ts new file mode 100644 index 000000000..796f45850 --- /dev/null +++ b/backend/src/asm/asm-internal.controller.ts @@ -0,0 +1,28 @@ +import { Body, Controller, Headers, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { ZodValidationPipe } from 'nestjs-zod'; + +import { InternalOnly } from '../common/guards/internal-only.guard'; +import { AsmService } from './asm.service'; +import { + UpsertAsmAssetsRequestDto, + UpsertAsmAssetsRequestSchema, + UpsertAsmAssetsResponseDto, +} from './dto/upsert-assets.dto'; + +@ApiTags('asm') +@Controller('asm/internal') +export class AsmInternalController { + constructor(private readonly asmService: AsmService) {} + + @Post('assets/upsert') + @InternalOnly() + @HttpCode(HttpStatus.OK) + @ApiOkResponse({ type: UpsertAsmAssetsResponseDto }) + async upsertAssets( + @Headers('x-organization-id') organizationId: string, + @Body(new ZodValidationPipe(UpsertAsmAssetsRequestSchema)) body: UpsertAsmAssetsRequestDto, + ) { + return this.asmService.upsertAssetsForDomain(organizationId, body); + } +} diff --git a/backend/src/asm/asm.controller.ts b/backend/src/asm/asm.controller.ts new file mode 100644 index 000000000..b5ff24658 --- /dev/null +++ b/backend/src/asm/asm.controller.ts @@ -0,0 +1,227 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiQuery, ApiOkResponse, ApiCreatedResponse } from '@nestjs/swagger'; +import { ZodValidationPipe } from 'nestjs-zod'; + +import { CurrentAuth } from '../auth/auth-context.decorator'; +import type { AuthContext } from '../auth/types'; +import { AuthGuard } from '../auth/auth.guard'; +import { AsmService } from './asm.service'; +import { CreateDomainDto, CreateDomainSchema } from './dto/create-domain.dto'; +import { UpdateDomainDto, UpdateDomainSchema } from './dto/update-domain.dto'; +import { TriggerScanDto, TriggerScanSchema } from './dto/trigger-scan.dto'; +import { ListDomainsQueryDto, ListDomainsQuerySchema } from './dto/list-domains.dto'; +import { ListAssetsQueryDto, ListAssetsQuerySchema } from './dto/list-assets.dto'; +import { ListScansQueryDto, ListScansQuerySchema } from './dto/list-scans.dto'; +import { + AsmDomainDto, + AsmDomainWithStatsDto, + AsmDomainCreateResponseDto, + AsmAssetDto, + AsmAssetListResponseDto, + AsmDeleteResponseDto, + AsmScanTriggerResponseDto, + AsmScanListResponseDto, + AsmDashboardStatsDto, +} from './dto/asm-response.dto'; + +@ApiTags('asm') +@Controller('asm') +@UseGuards(AuthGuard) +export class AsmController { + constructor(private readonly asmService: AsmService) {} + + // ── Domains ──────────────────────────────────────────────────────────────── + + @Get('domains') + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Max items to return (default 50)', + }) + @ApiQuery({ + name: 'offset', + required: false, + type: Number, + description: 'Items to skip (default 0)', + }) + @ApiOkResponse({ + description: 'List of ASM domains', + type: AsmDomainWithStatsDto, + isArray: true, + }) + async listDomains( + @CurrentAuth() auth: AuthContext, + @Query(new ZodValidationPipe(ListDomainsQuerySchema)) query: ListDomainsQueryDto, + ) { + return this.asmService.listDomains(auth, { + limit: query.limit, + offset: query.offset, + }); + } + + @Get('domains/:id') + @ApiOkResponse({ + description: 'Single ASM domain', + type: AsmDomainDto, + }) + async getDomain(@CurrentAuth() auth: AuthContext, @Param('id') id: string) { + return this.asmService.getDomain(auth, id); + } + + @Post('domains') + @ApiCreatedResponse({ + description: 'Newly created ASM domain', + type: AsmDomainCreateResponseDto, + }) + async createDomain( + @CurrentAuth() auth: AuthContext, + @Body(new ZodValidationPipe(CreateDomainSchema)) body: CreateDomainDto, + ) { + return this.asmService.createDomain(auth, { + domain: body.domain, + scopeInclude: body.scopeInclude, + scopeExclude: body.scopeExclude, + scanNow: body.scanNow, + }); + } + + @Patch('domains/:id') + @ApiOkResponse({ + description: 'Updated ASM domain', + type: AsmDomainDto, + }) + async updateDomain( + @CurrentAuth() auth: AuthContext, + @Param('id') id: string, + @Body(new ZodValidationPipe(UpdateDomainSchema)) body: UpdateDomainDto, + ) { + return this.asmService.updateDomain(auth, id, body); + } + + @Delete('domains/:id') + @ApiOkResponse({ + description: 'Domain deleted', + type: AsmDeleteResponseDto, + }) + async deleteDomain(@CurrentAuth() auth: AuthContext, @Param('id') id: string) { + return this.asmService.deleteDomain(auth, id); + } + + @Post('domains/:id/scan') + @ApiCreatedResponse({ + description: 'Scan triggered', + type: AsmScanTriggerResponseDto, + }) + async triggerScan( + @CurrentAuth() auth: AuthContext, + @Param('id') id: string, + @Body(new ZodValidationPipe(TriggerScanSchema)) body: TriggerScanDto, + ) { + return this.asmService.triggerDomainScan(auth, id, body.templateSlug); + } + + // ── Assets ───────────────────────────────────────────────────────────────── + + @Get('assets') + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'offset', required: false, type: Number }) + @ApiQuery({ name: 'domainId', required: false, type: String }) + @ApiQuery({ name: 'type', required: false, type: String }) + @ApiQuery({ name: 'search', required: false, type: String }) + @ApiQuery({ + name: 'sortBy', + required: false, + enum: ['hostname', 'type', 'riskScore', 'firstSeen', 'lastSeen'], + }) + @ApiQuery({ name: 'sortOrder', required: false, enum: ['asc', 'desc'] }) + @ApiOkResponse({ + description: 'List of ASM assets', + type: AsmAssetListResponseDto, + }) + async listAssets( + @CurrentAuth() auth: AuthContext, + @Query(new ZodValidationPipe(ListAssetsQuerySchema)) query: ListAssetsQueryDto, + ) { + return this.asmService.listAssets(auth, { + limit: query.limit, + offset: query.offset, + domainId: query.domainId, + type: query.type, + search: query.search, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + }); + } + + @Get('assets/:id') + @ApiOkResponse({ + description: 'Single ASM asset', + type: AsmAssetDto, + }) + async getAsset(@CurrentAuth() auth: AuthContext, @Param('id') id: string) { + return this.asmService.getAsset(auth, id); + } + + @Post('assets/:id/scan') + @ApiCreatedResponse({ + description: 'Asset scan triggered', + type: AsmScanTriggerResponseDto, + }) + async triggerAssetScan( + @CurrentAuth() auth: AuthContext, + @Param('id') id: string, + @Body(new ZodValidationPipe(TriggerScanSchema)) body: TriggerScanDto, + ) { + const asset = await this.asmService.getAsset(auth, id); + return this.asmService.triggerDomainScan(auth, asset.domainId, body.templateSlug); + } + + // ── Scans ────────────────────────────────────────────────────────────────── + + @Get('scans') + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'offset', required: false, type: Number }) + @ApiQuery({ + name: 'status', + required: false, + enum: ['running', 'completed', 'failed', 'timed_out'], + }) + @ApiQuery({ name: 'domainId', required: false, type: String }) + @ApiOkResponse({ + description: 'List of ASM scans', + type: AsmScanListResponseDto, + }) + async listScans( + @CurrentAuth() auth: AuthContext, + @Query(new ZodValidationPipe(ListScansQuerySchema)) query: ListScansQueryDto, + ) { + return this.asmService.listScans(auth, { + limit: query.limit, + offset: query.offset, + status: query.status, + domainId: query.domainId, + }); + } + + // ── Dashboard ────────────────────────────────────────────────────────────── + + @Get('stats') + @ApiOkResponse({ + description: 'ASM dashboard statistics', + type: AsmDashboardStatsDto, + }) + async getDashboardStats(@CurrentAuth() auth: AuthContext) { + return this.asmService.getDashboardStats(auth); + } +} diff --git a/backend/src/asm/asm.module.ts b/backend/src/asm/asm.module.ts new file mode 100644 index 000000000..785770a8e --- /dev/null +++ b/backend/src/asm/asm.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { AsmController } from './asm.controller'; +import { AsmInternalController } from './asm-internal.controller'; +import { AsmService } from './asm.service'; +import { WorkflowsModule } from '../workflows/workflows.module'; +import { WorkflowTemplatesModule } from '../workflow-templates/workflow-templates.module'; +import { ApiKeysModule } from '../api-keys/api-keys.module'; +import { AuthModule } from '../auth/auth.module'; +import { DatabaseModule } from '../database/database.module'; + +@Module({ + imports: [DatabaseModule, WorkflowsModule, WorkflowTemplatesModule, ApiKeysModule, AuthModule], + controllers: [AsmController, AsmInternalController], + providers: [AsmService], + exports: [AsmService], +}) +export class AsmModule {} diff --git a/backend/src/asm/asm.service.ts b/backend/src/asm/asm.service.ts new file mode 100644 index 000000000..9693c9d29 --- /dev/null +++ b/backend/src/asm/asm.service.ts @@ -0,0 +1,801 @@ +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { DRIZZLE_TOKEN } from '../database/database.module'; +import { type NodePgDatabase } from 'drizzle-orm/node-postgres'; +import * as schema from '../database/schema'; +import { asmDomains, asmAssets } from '../database/schema/asm'; +import { workflowRunsTable } from '../database/schema/workflow-runs'; +import { eq, and, isNull, desc, asc, ilike, count, sql, inArray } from 'drizzle-orm'; +import type { AuthContext } from '../auth/types'; +import { WorkflowsService } from '../workflows/workflows.service'; +import { SystemWorkflowsService } from '../workflows/system-workflows.service'; +import { WorkflowTemplatesService } from '../workflow-templates/workflow-templates.service'; +import type { AsmAssetUpsertItemDto } from './dto/upsert-assets.dto'; + +const MAX_DOMAINS_PER_ORG = 50; +const MAX_SCANS_PER_DOMAIN_PER_HOUR = 5; + +// In-memory rate limit tracker (per-process; fine for dev, use Redis in production) +const scanRateMap = new Map(); + +interface AsmAssetPort { + port: number; + protocol: string; + service: string; +} + +const normalizeDomain = (value: string) => value.toLowerCase().trim().replace(/\.$/, ''); + +const normalizeInventoryHostname = (value: string): string | null => { + const trimmed = value.trim(); + if (!trimmed) return null; + + const stripAndLower = (host: string): string | null => { + const normalized = host.trim().toLowerCase().replace(/\.$/, ''); + return normalized.length > 0 && normalized.length <= 512 ? normalized : null; + }; + + try { + const hostname = new URL(trimmed).hostname; + if (hostname) return stripAndLower(hostname); + } catch { + // Fall through to host-like parsing. + } + + try { + const hostname = new URL(`http://${trimmed}`).hostname; + if (hostname) return stripAndLower(hostname); + } catch { + // Fall through to conservative string parsing. + } + + return stripAndLower(trimmed.split('/')[0] ?? trimmed); +}; + +const uniqueStrings = (...values: (string[] | null | undefined)[]): string[] => { + const byKey = new Map(); + for (const value of values.flatMap((entry) => entry ?? [])) { + const trimmed = value.trim(); + if (!trimmed) continue; + const key = trimmed.toLowerCase(); + if (!byKey.has(key)) { + byKey.set(key, trimmed); + } + } + return Array.from(byKey.values()); +}; + +const mergePorts = ( + existing: AsmAssetPort[] | null | undefined, + incoming: AsmAssetPort[] | null | undefined, +): AsmAssetPort[] => { + const byKey = new Map(); + for (const port of [...(existing ?? []), ...(incoming ?? [])]) { + if (!port || typeof port.port !== 'number') continue; + const protocol = port.protocol?.trim().toLowerCase() || 'tcp'; + const service = port.service?.trim() || ''; + const normalized = { port: port.port, protocol, service }; + byKey.set(`${normalized.port}:${normalized.protocol}:${normalized.service}`, normalized); + } + return Array.from(byKey.values()).sort((a, b) => a.port - b.port); +}; + +const mergeRecords = ( + existing: Record | null | undefined, + incoming: Record | null | undefined, +): Record | null => { + const merged = { ...(existing ?? {}), ...(incoming ?? {}) }; + return Object.keys(merged).length > 0 ? merged : null; +}; + +const normalizeRiskScore = (value: AsmAssetUpsertItemDto['riskScore']): string | undefined => { + if (value === undefined || value === null) return undefined; + const numeric = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(numeric)) return undefined; + return Math.max(0, Math.min(100, numeric)).toFixed(1); +}; + +@Injectable() +export class AsmService { + private readonly logger = new Logger(AsmService.name); + + constructor( + @Inject(DRIZZLE_TOKEN) + private readonly db: NodePgDatabase, + private readonly workflowsService: WorkflowsService, + private readonly systemWorkflowsService: SystemWorkflowsService, + private readonly workflowTemplatesService: WorkflowTemplatesService, + ) {} + + private requireOrganizationId(auth: AuthContext | null): string { + const orgId = auth?.organizationId; + if (!orgId) throw new BadRequestException('Organization context required'); + return orgId; + } + + // ── Domain CRUD ──────────────────────────────────────────────────────────── + + async listDomains(auth: AuthContext, options: { limit?: number; offset?: number } = {}) { + const organizationId = this.requireOrganizationId(auth); + const limit = options.limit ?? 50; + const offset = options.offset ?? 0; + + const domains = await this.db + .select() + .from(asmDomains) + .where(and(eq(asmDomains.organizationId, organizationId), isNull(asmDomains.deletedAt))) + .orderBy(desc(asmDomains.createdAt)) + .limit(limit) + .offset(offset); + + // Compute lastScanAt from completed ASM workflow runs (covers both manual & scheduled scans) + const lastScanByDomain = await this.computeLastScanTimes(auth, organizationId, domains); + + // Aggregate stats per domain in a single batched query + const domainIds = domains.map((d) => d.id); + const statsMap = new Map(); + + if (domainIds.length > 0) { + const domainIdArray = sql`ARRAY[${sql.join( + domainIds.map((domainId) => sql`${domainId}`), + sql`, `, + )}]::uuid[]`; + + const statsRows = await this.db.execute(sql` + SELECT + domain_id, + COUNT(DISTINCT a.id)::int AS asset_count, + COALESCE( + ARRAY(SELECT DISTINCT unnest FROM unnest(array_agg(DISTINCT t.tech)) AS unnest WHERE unnest IS NOT NULL), + '{}' + ) AS techs + FROM asm_assets a + LEFT JOIN LATERAL unnest(a.tech_stack) AS t(tech) ON true + WHERE a.domain_id = ANY(${domainIdArray}) + GROUP BY domain_id + `); + + for (const row of statsRows.rows as { + domain_id: string; + asset_count: number; + techs: string[]; + }[]) { + statsMap.set(row.domain_id, { + assetCount: row.asset_count, + techStack: row.techs ?? [], + }); + } + } + + return domains.map((domain) => { + const stats = statsMap.get(domain.id); + return { + ...domain, + lastScanAt: lastScanByDomain.get(domain.domain) ?? domain.lastScanAt, + assetCount: stats?.assetCount ?? 0, + techStack: stats?.techStack ?? [], + }; + }); + } + + /** + * Compute the most recent completed scan time for each domain by querying workflow_runs. + * Considers both per-domain manual scans (triggerSource = 'asm:{domain}') and + * org-wide scheduled scans (any completed ASM workflow run). + */ + private async computeLastScanTimes( + auth: AuthContext, + organizationId: string, + domains: { domain: string }[], + ): Promise> { + const result = new Map(); + if (domains.length === 0) return result; + + const asmWorkflowIds = await this.resolveAsmWorkflowIds(auth); + if (asmWorkflowIds.length === 0) return result; + + // Latest completed ASM run for the org (covers scheduled multi-domain scans) + const [orgLatest] = await this.db + .select({ + lastScan: sql`MAX(COALESCE(${workflowRunsTable.closeTime}, ${workflowRunsTable.createdAt}))`, + }) + .from(workflowRunsTable) + .where( + and( + eq(workflowRunsTable.organizationId, organizationId), + inArray(workflowRunsTable.workflowId, asmWorkflowIds), + eq(workflowRunsTable.status, 'COMPLETED'), + ), + ); + const orgLastScan = orgLatest?.lastScan ? new Date(orgLatest.lastScan) : null; + + // Per-domain manual scan times (triggerSource = 'asm:{domain}') + const domainNames = domains.map((d) => d.domain); + const perDomainResults = await this.db + .select({ + triggerSource: workflowRunsTable.triggerSource, + lastScan: sql`MAX(COALESCE(${workflowRunsTable.closeTime}, ${workflowRunsTable.createdAt}))`, + }) + .from(workflowRunsTable) + .where( + and( + eq(workflowRunsTable.organizationId, organizationId), + inArray( + workflowRunsTable.triggerSource, + domainNames.map((d) => `asm:${d}`), + ), + eq(workflowRunsTable.status, 'COMPLETED'), + ), + ) + .groupBy(workflowRunsTable.triggerSource); + + const perDomainMap = new Map(); + for (const row of perDomainResults) { + if (row.triggerSource && row.lastScan) { + const domainName = row.triggerSource.replace(/^asm:/, ''); + perDomainMap.set(domainName, new Date(row.lastScan)); + } + } + + // For each domain, take the most recent of org-wide and per-domain scans + for (const domain of domains) { + const candidates: Date[] = []; + if (orgLastScan) candidates.push(orgLastScan); + const perDomain = perDomainMap.get(domain.domain); + if (perDomain) candidates.push(perDomain); + + if (candidates.length > 0) { + result.set(domain.domain, new Date(Math.max(...candidates.map((d) => d.getTime())))); + } + } + + return result; + } + + /** + * Resolve workflow IDs for ASM-related workflow templates. + */ + private async resolveAsmWorkflowIds(auth: AuthContext): Promise { + const templates = await this.workflowTemplatesService.list(); + const asmTemplateSlugs = new Set([ + 'sys-attack-surface-recon', + 'sys-quick-recon', + 'sys-port-scan', + 'sys-vuln-scan', + ]); + const asmTemplateIds = templates + .filter((t) => t.slug && asmTemplateSlugs.has(t.slug)) + .map((t) => t.id); + + const workflows = await this.workflowsService.list(auth); + return workflows + .filter((w: any) => { + if (asmTemplateIds.includes(w.templateId)) return true; + if (w.slug && asmTemplateSlugs.has(w.slug)) return true; + return false; + }) + .map((w: any) => w.id); + } + + async getDomain(auth: AuthContext, id: string) { + const organizationId = this.requireOrganizationId(auth); + + const [domain] = await this.db + .select() + .from(asmDomains) + .where( + and( + eq(asmDomains.id, id), + eq(asmDomains.organizationId, organizationId), + isNull(asmDomains.deletedAt), + ), + ); + + if (!domain) throw new NotFoundException(`Domain ${id} not found`); + return domain; + } + + async createDomain( + auth: AuthContext, + dto: { + domain: string; + scopeInclude?: string[]; + scopeExclude?: string[]; + scanNow?: boolean; + }, + ) { + const organizationId = this.requireOrganizationId(auth); + + // Check org domain limit + const [{ count: domainCount }] = await this.db + .select({ count: count() }) + .from(asmDomains) + .where(and(eq(asmDomains.organizationId, organizationId), isNull(asmDomains.deletedAt))); + + if (domainCount >= MAX_DOMAINS_PER_ORG) { + throw new BadRequestException( + `Maximum of ${MAX_DOMAINS_PER_ORG} domains per organization reached`, + ); + } + + // Check for duplicate (partial unique index handles DB level, but give nice error) + const [existing] = await this.db + .select() + .from(asmDomains) + .where( + and( + eq(asmDomains.organizationId, organizationId), + eq(asmDomains.domain, dto.domain), + isNull(asmDomains.deletedAt), + ), + ); + + if (existing) { + throw new ConflictException(`Domain ${dto.domain} already exists`); + } + + const [domain] = await this.db + .insert(asmDomains) + .values({ + organizationId, + domain: dto.domain, + scopeInclude: dto.scopeInclude ?? null, + scopeExclude: dto.scopeExclude ?? null, + }) + .returning(); + + this.logger.log(`Created domain ${domain.domain} for org ${organizationId}`); + + // Optionally trigger scan + if (dto.scanNow) { + try { + const scanResult = await this.triggerDomainScan(auth, domain.id); + return { ...domain, assetCount: 0, scanTriggered: true, runId: scanResult.runId }; + } catch (error) { + this.logger.warn(`Failed to auto-trigger scan for ${domain.domain}: ${error}`); + return { ...domain, assetCount: 0, scanTriggered: false }; + } + } + + return { ...domain, assetCount: 0 }; + } + + async updateDomain( + auth: AuthContext, + id: string, + dto: { scopeInclude?: string[]; scopeExclude?: string[] }, + ) { + const organizationId = this.requireOrganizationId(auth); + + const [updated] = await this.db + .update(asmDomains) + .set({ + ...dto, + updatedAt: new Date(), + }) + .where( + and( + eq(asmDomains.id, id), + eq(asmDomains.organizationId, organizationId), + isNull(asmDomains.deletedAt), + ), + ) + .returning(); + + if (!updated) throw new NotFoundException(`Domain ${id} not found`); + return updated; + } + + async deleteDomain(auth: AuthContext, id: string) { + const organizationId = this.requireOrganizationId(auth); + + const [deleted] = await this.db + .update(asmDomains) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(asmDomains.id, id), + eq(asmDomains.organizationId, organizationId), + isNull(asmDomains.deletedAt), + ), + ) + .returning(); + + if (!deleted) throw new NotFoundException(`Domain ${id} not found`); + this.logger.log(`Soft-deleted domain ${deleted.domain} for org ${organizationId}`); + return { success: true }; + } + + // ── Scan Trigger ─────────────────────────────────────────────────────────── + + async triggerDomainScan(auth: AuthContext, domainId: string, templateSlug?: string) { + const domain = await this.getDomain(auth, domainId); + const slug = templateSlug || 'sys-attack-surface-recon'; + + // Rate limiting + this.checkScanRateLimit(domainId); + + // Find the system workflow for this template + const workflowId = await this.resolveWorkflowIdForTemplate(slug, auth); + + // Trigger the workflow run with domain as componentParams + const runHandle = await this.workflowsService.run( + workflowId, + { inputs: { domain: domain.domain } }, + auth, + { + trigger: { + type: 'manual', + sourceId: `asm:${domain.domain}`, + label: `ASM Scan: ${domain.domain}`, + }, + componentParams: { + 'shipsec.subfinder.run': { + domain: domain.domain, + }, + 'core.asm.assets-upsert': { + domain: domain.domain, + domainId: domain.id, + }, + 'shipsec.asm-findings.normalize': { + domain: domain.domain, + }, + }, + }, + ); + + this.logger.log( + `Triggered ASM scan for ${domain.domain} (runId: ${runHandle.runId}, template: ${slug})`, + ); + + return { runId: runHandle.runId, domain: domain.domain }; + } + + private checkScanRateLimit(domainId: string) { + const now = Date.now(); + const oneHourAgo = now - 60 * 60 * 1000; + const timestamps = (scanRateMap.get(domainId) ?? []).filter((t) => t > oneHourAgo); + + if (timestamps.length >= MAX_SCANS_PER_DOMAIN_PER_HOUR) { + throw new BadRequestException( + `Rate limit exceeded: max ${MAX_SCANS_PER_DOMAIN_PER_HOUR} scans per domain per hour`, + ); + } + + timestamps.push(now); + scanRateMap.set(domainId, timestamps); + } + + private async resolveWorkflowIdForTemplate( + templateSlug: string, + auth: AuthContext, + ): Promise { + // Get all templates and find the one matching the slug + const templates = await this.workflowTemplatesService.list(); + const template = templates.find((t) => t.slug === templateSlug); + + if (!template) { + throw new NotFoundException(`Template ${templateSlug} not found`); + } + + // Ensure system workflows exist for this org, then select by templateId only. + // Using slug-based matching here can pick stale/legacy workflows that no + // longer match the template graph and fail compile-time validation. + const organizationId = this.requireOrganizationId(auth); + const systemWorkflows = await this.systemWorkflowsService.ensureSystemWorkflows(organizationId); + const systemWorkflow = systemWorkflows.find((w) => w.templateId === template.id); + + if (systemWorkflow) { + return systemWorkflow.id; + } + + // If no workflow exists for this template, instantiate one + this.logger.log( + `Instantiating workflow from template ${templateSlug} for org ${organizationId}`, + ); + const graph = template.graph as { + nodes: unknown[]; + edges: unknown[]; + name?: string; + description?: string; + }; + + const workflowGraph = { + ...graph, + name: template.name, + description: template.description ?? graph.description ?? '', + }; + + const workflow = await this.workflowsService.create(workflowGraph as any, auth); + return workflow.id; + } + + // ── Assets ───────────────────────────────────────────────────────────────── + + async listAssets( + auth: AuthContext, + options: { + limit?: number; + offset?: number; + domainId?: string; + type?: string; + search?: string; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; + } = {}, + ) { + const organizationId = this.requireOrganizationId(auth); + const limit = options.limit ?? 25; + const offset = options.offset ?? 0; + + const conditions = [eq(asmAssets.organizationId, organizationId)]; + + if (options.domainId) { + conditions.push(eq(asmAssets.domainId, options.domainId)); + } + if (options.type) { + conditions.push(eq(asmAssets.type, options.type)); + } + if (options.search) { + conditions.push(ilike(asmAssets.hostname, `%${options.search}%`)); + } + + const sortColumn = + { + hostname: asmAssets.hostname, + type: asmAssets.type, + riskScore: asmAssets.riskScore, + firstSeen: asmAssets.firstSeen, + lastSeen: asmAssets.lastSeen, + }[options.sortBy ?? 'lastSeen'] ?? asmAssets.lastSeen; + + const orderFn = options.sortOrder === 'asc' ? asc : desc; + + const assets = await this.db + .select() + .from(asmAssets) + .where(and(...conditions)) + .orderBy(orderFn(sortColumn)) + .limit(limit) + .offset(offset); + + const [totalResult] = await this.db + .select({ count: count() }) + .from(asmAssets) + .where(and(...conditions)); + + return { assets, total: totalResult?.count ?? 0 }; + } + + async getAsset(auth: AuthContext, id: string) { + const organizationId = this.requireOrganizationId(auth); + + const [asset] = await this.db + .select() + .from(asmAssets) + .where(and(eq(asmAssets.id, id), eq(asmAssets.organizationId, organizationId))); + + if (!asset) throw new NotFoundException(`Asset ${id} not found`); + return asset; + } + + async upsertAssetsForDomain( + organizationId: string, + input: { + domainId?: string; + domain?: string; + runId?: string; + workflowId?: string; + assets: AsmAssetUpsertItemDto[]; + }, + ) { + if (!organizationId) { + throw new BadRequestException('Organization context required'); + } + + const domain = await this.resolveDomainForAssetUpsert(organizationId, input); + const now = new Date(); + const normalizedAssets = input.assets + .map((asset) => { + const hostname = normalizeInventoryHostname(asset.hostname); + return hostname ? { ...asset, hostname } : null; + }) + .filter((asset): asset is AsmAssetUpsertItemDto & { hostname: string } => Boolean(asset)); + + if (normalizedAssets.length === 0) { + return { + domainId: domain.id, + assetCount: 0, + created: 0, + updated: 0, + skipped: input.assets.length, + }; + } + + const byHostname = new Map(); + for (const asset of normalizedAssets) { + const existing = byHostname.get(asset.hostname); + if (!existing) { + byHostname.set(asset.hostname, asset); + continue; + } + + byHostname.set(asset.hostname, { + ...existing, + ...asset, + ipAddresses: uniqueStrings(existing.ipAddresses, asset.ipAddresses), + techStack: uniqueStrings(existing.techStack, asset.techStack), + ports: mergePorts(existing.ports, asset.ports), + dnsRecords: mergeRecords(existing.dnsRecords, asset.dnsRecords) ?? undefined, + tlsInfo: mergeRecords(existing.tlsInfo, asset.tlsInfo) ?? undefined, + metadata: mergeRecords(existing.metadata, asset.metadata) ?? undefined, + }); + } + + const assets = Array.from(byHostname.values()); + const hostnames = assets.map((asset) => asset.hostname); + const existingRows = await this.db + .select() + .from(asmAssets) + .where( + and( + eq(asmAssets.organizationId, organizationId), + eq(asmAssets.domainId, domain.id), + inArray(asmAssets.hostname, hostnames), + ), + ); + + const existingByHostname = new Map(existingRows.map((asset) => [asset.hostname, asset])); + let created = 0; + let updated = 0; + + for (const asset of assets) { + const existing = existingByHostname.get(asset.hostname); + const ipAddresses = uniqueStrings(existing?.ipAddresses, asset.ipAddresses); + const techStack = uniqueStrings(existing?.techStack, asset.techStack); + const ports = mergePorts(existing?.ports, asset.ports); + const dnsRecords = mergeRecords(existing?.dnsRecords, asset.dnsRecords); + const tlsInfo = mergeRecords(existing?.tlsInfo, asset.tlsInfo); + const metadata = mergeRecords(existing?.metadata, { + ...(asset.metadata ?? {}), + ...(input.runId ? { lastWorkflowRunId: input.runId } : {}), + ...(input.workflowId ? { workflowId: input.workflowId } : {}), + }); + const riskScore = normalizeRiskScore(asset.riskScore) ?? existing?.riskScore ?? null; + + const values = { + organizationId, + domainId: domain.id, + hostname: asset.hostname, + type: asset.type || existing?.type || 'subdomain', + ipAddresses: ipAddresses.length > 0 ? ipAddresses : null, + techStack: techStack.length > 0 ? techStack : null, + ports: ports.length > 0 ? ports : null, + riskScore, + dnsRecords, + tlsInfo, + metadata, + lastSeen: now, + }; + + await this.db + .insert(asmAssets) + .values(values) + .onConflictDoUpdate({ + target: [asmAssets.domainId, asmAssets.hostname], + set: { + type: values.type, + ipAddresses: values.ipAddresses, + techStack: values.techStack, + ports: values.ports, + riskScore: values.riskScore, + dnsRecords: values.dnsRecords, + tlsInfo: values.tlsInfo, + metadata: values.metadata, + lastSeen: now, + }, + }); + + if (existing) { + updated += 1; + } else { + created += 1; + } + } + + await this.db + .update(asmDomains) + .set({ lastScanAt: now, updatedAt: now }) + .where(eq(asmDomains.id, domain.id)); + + return { + domainId: domain.id, + assetCount: assets.length, + created, + updated, + skipped: input.assets.length - normalizedAssets.length, + }; + } + + private async resolveDomainForAssetUpsert( + organizationId: string, + input: { domainId?: string; domain?: string }, + ) { + const conditions = [ + eq(asmDomains.organizationId, organizationId), + isNull(asmDomains.deletedAt), + input.domainId + ? eq(asmDomains.id, input.domainId) + : eq(asmDomains.domain, normalizeDomain(input.domain ?? '')), + ]; + + const [domain] = await this.db + .select() + .from(asmDomains) + .where(and(...conditions)); + + if (!domain) { + throw new NotFoundException( + input.domainId + ? `Domain ${input.domainId} not found` + : `Domain ${input.domain ?? ''} not found`, + ); + } + + return domain; + } + + // ── Scans (derived from workflow runs) ───────────────────────────────────── + + async listScans( + auth: AuthContext, + options: { + limit?: number; + offset?: number; + status?: string; + domainId?: string; + } = {}, + ) { + const asmWorkflowIds = await this.resolveAsmWorkflowIds(auth); + + if (asmWorkflowIds.length === 0) { + return { runs: [], total: 0 }; + } + + const result = await this.workflowsService.listRuns(auth, { + workflowIds: asmWorkflowIds, + status: options.status as any, + limit: options.limit ?? 25, + offset: options.offset ?? 0, + }); + + return result; + } + + // ── Dashboard Stats ──────────────────────────────────────────────────────── + + async getDashboardStats(auth: AuthContext) { + const organizationId = this.requireOrganizationId(auth); + + const [domainStats] = await this.db + .select({ count: count() }) + .from(asmDomains) + .where(and(eq(asmDomains.organizationId, organizationId), isNull(asmDomains.deletedAt))); + + const [assetStats] = await this.db + .select({ count: count() }) + .from(asmAssets) + .where(eq(asmAssets.organizationId, organizationId)); + + return { + domains: domainStats?.count ?? 0, + assets: assetStats?.count ?? 0, + }; + } +} diff --git a/backend/src/asm/dto/asm-response.dto.ts b/backend/src/asm/dto/asm-response.dto.ts new file mode 100644 index 000000000..2bcf0d5aa --- /dev/null +++ b/backend/src/asm/dto/asm-response.dto.ts @@ -0,0 +1,86 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; +import { RunListItemSchema } from '../../workflows/dto/workflow-response.dto'; + +// --- Base entity schemas --- + +export const AsmDomainSchema = z.object({ + id: z.string().uuid(), + organizationId: z.string(), + domain: z.string(), + scopeInclude: z.array(z.string()).nullable(), + scopeExclude: z.array(z.string()).nullable(), + monitoringEnabled: z.boolean(), + frequency: z.string().nullable(), + lastScanAt: z.string().datetime().nullable(), + deletedAt: z.string().datetime().nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); +export class AsmDomainDto extends createZodDto(AsmDomainSchema) {} + +export const AsmDomainWithStatsSchema = AsmDomainSchema.extend({ + assetCount: z.number(), +}); +export class AsmDomainWithStatsDto extends createZodDto(AsmDomainWithStatsSchema) {} + +export const AsmDomainCreateResponseSchema = AsmDomainWithStatsSchema.extend({ + scanTriggered: z.boolean(), + runId: z.string(), +}); +export class AsmDomainCreateResponseDto extends createZodDto(AsmDomainCreateResponseSchema) {} + +const AsmPortSchema = z.object({ + port: z.number(), + protocol: z.string(), + service: z.string(), +}); + +export const AsmAssetSchema = z.object({ + id: z.string().uuid(), + organizationId: z.string(), + domainId: z.string().uuid(), + hostname: z.string(), + type: z.string(), + ipAddresses: z.array(z.string()).nullable(), + techStack: z.array(z.string()).nullable(), + ports: z.array(AsmPortSchema).nullable(), + riskScore: z.string().nullable(), + dnsRecords: z.record(z.string(), z.unknown()).nullable(), + tlsInfo: z.record(z.string(), z.unknown()).nullable(), + firstSeen: z.string().datetime(), + lastSeen: z.string().datetime(), + metadata: z.record(z.string(), z.unknown()).nullable(), +}); +export class AsmAssetDto extends createZodDto(AsmAssetSchema) {} + +// --- Endpoint response schemas --- + +export const AsmAssetListResponseSchema = z.object({ + assets: z.array(AsmAssetSchema), + total: z.number(), +}); +export class AsmAssetListResponseDto extends createZodDto(AsmAssetListResponseSchema) {} + +export const AsmDeleteResponseSchema = z.object({ + success: z.boolean(), +}); +export class AsmDeleteResponseDto extends createZodDto(AsmDeleteResponseSchema) {} + +export const AsmScanTriggerResponseSchema = z.object({ + runId: z.string(), + domain: z.string(), +}); +export class AsmScanTriggerResponseDto extends createZodDto(AsmScanTriggerResponseSchema) {} + +export const AsmScanListResponseSchema = z.object({ + runs: z.array(RunListItemSchema), + total: z.number(), +}); +export class AsmScanListResponseDto extends createZodDto(AsmScanListResponseSchema) {} + +export const AsmDashboardStatsSchema = z.object({ + domains: z.number(), + assets: z.number(), +}); +export class AsmDashboardStatsDto extends createZodDto(AsmDashboardStatsSchema) {} diff --git a/backend/src/asm/dto/create-domain.dto.ts b/backend/src/asm/dto/create-domain.dto.ts new file mode 100644 index 000000000..0d3ac6a60 --- /dev/null +++ b/backend/src/asm/dto/create-domain.dto.ts @@ -0,0 +1,40 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +// Reject private/reserved IP ranges, localhost, internal hostnames +const PRIVATE_IP_PATTERNS = [ + /^10\./, + /^172\.(1[6-9]|2\d|3[01])\./, + /^192\.168\./, + /^127\./, + /^0\./, + /^169\.254\./, + /^::1$/, + /^fc00:/, + /^fd/, + /^fe80:/, +]; + +const DOMAIN_REGEX = /^(?!-)[a-zA-Z0-9-]{1,63}(? d.toLowerCase().trim().replace(/\.$/, '')) + .refine((d) => DOMAIN_REGEX.test(d), 'Invalid domain format') + .refine( + (d) => !PRIVATE_IP_PATTERNS.some((p) => p.test(d)), + 'Private/reserved IP addresses are not allowed', + ) + .refine( + (d) => d !== 'localhost' && !d.endsWith('.local') && !d.endsWith('.internal'), + 'Internal hostnames are not allowed', + ), + scopeInclude: z.array(z.string().max(500)).max(100).optional(), + scopeExclude: z.array(z.string().max(500)).max(100).optional(), + scanNow: z.boolean().optional().default(false), +}); + +export class CreateDomainDto extends createZodDto(CreateDomainSchema) {} diff --git a/backend/src/asm/dto/list-assets.dto.ts b/backend/src/asm/dto/list-assets.dto.ts new file mode 100644 index 000000000..045bc6165 --- /dev/null +++ b/backend/src/asm/dto/list-assets.dto.ts @@ -0,0 +1,14 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +export const ListAssetsQuerySchema = z.object({ + limit: z.string().regex(/^\d+$/).default('25').transform(Number), + offset: z.string().regex(/^\d+$/).default('0').transform(Number), + domainId: z.string().uuid().optional(), + type: z.string().optional(), + search: z.string().optional(), + sortBy: z.enum(['hostname', 'type', 'riskScore', 'firstSeen', 'lastSeen']).default('lastSeen'), + sortOrder: z.enum(['asc', 'desc']).default('desc'), +}); + +export class ListAssetsQueryDto extends createZodDto(ListAssetsQuerySchema) {} diff --git a/backend/src/asm/dto/list-domains.dto.ts b/backend/src/asm/dto/list-domains.dto.ts new file mode 100644 index 000000000..f9259d615 --- /dev/null +++ b/backend/src/asm/dto/list-domains.dto.ts @@ -0,0 +1,9 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +export const ListDomainsQuerySchema = z.object({ + limit: z.string().regex(/^\d+$/).default('50').transform(Number), + offset: z.string().regex(/^\d+$/).default('0').transform(Number), +}); + +export class ListDomainsQueryDto extends createZodDto(ListDomainsQuerySchema) {} diff --git a/backend/src/asm/dto/list-scans.dto.ts b/backend/src/asm/dto/list-scans.dto.ts new file mode 100644 index 000000000..7d6dd0f7c --- /dev/null +++ b/backend/src/asm/dto/list-scans.dto.ts @@ -0,0 +1,11 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +export const ListScansQuerySchema = z.object({ + limit: z.string().regex(/^\d+$/).default('25').transform(Number), + offset: z.string().regex(/^\d+$/).default('0').transform(Number), + status: z.enum(['running', 'completed', 'failed', 'timed_out']).optional(), + domainId: z.string().uuid().optional(), +}); + +export class ListScansQueryDto extends createZodDto(ListScansQuerySchema) {} diff --git a/backend/src/asm/dto/trigger-scan.dto.ts b/backend/src/asm/dto/trigger-scan.dto.ts new file mode 100644 index 000000000..4f8a3e33d --- /dev/null +++ b/backend/src/asm/dto/trigger-scan.dto.ts @@ -0,0 +1,8 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +export const TriggerScanSchema = z.object({ + templateSlug: z.string().default('sys-attack-surface-recon'), +}); + +export class TriggerScanDto extends createZodDto(TriggerScanSchema) {} diff --git a/backend/src/asm/dto/update-domain.dto.ts b/backend/src/asm/dto/update-domain.dto.ts new file mode 100644 index 000000000..822c748e5 --- /dev/null +++ b/backend/src/asm/dto/update-domain.dto.ts @@ -0,0 +1,9 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +export const UpdateDomainSchema = z.object({ + scopeInclude: z.array(z.string().max(500)).max(100).optional(), + scopeExclude: z.array(z.string().max(500)).max(100).optional(), +}); + +export class UpdateDomainDto extends createZodDto(UpdateDomainSchema) {} diff --git a/backend/src/asm/dto/upsert-assets.dto.ts b/backend/src/asm/dto/upsert-assets.dto.ts new file mode 100644 index 000000000..585a3b317 --- /dev/null +++ b/backend/src/asm/dto/upsert-assets.dto.ts @@ -0,0 +1,48 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +const JsonRecordSchema = z.record(z.string(), z.unknown()); + +export const AsmAssetPortSchema = z.object({ + port: z.number().int().min(1).max(65535), + protocol: z.string().trim().min(1).max(32).default('tcp'), + service: z.string().trim().max(128).default(''), +}); + +export const AsmAssetUpsertItemSchema = z.object({ + hostname: z.string().trim().min(1).max(512), + type: z.string().trim().min(1).max(50).default('subdomain'), + ipAddresses: z.array(z.string().trim().min(1)).default([]), + techStack: z.array(z.string().trim().min(1)).default([]), + ports: z.array(AsmAssetPortSchema).default([]), + riskScore: z.union([z.number(), z.string()]).optional(), + dnsRecords: JsonRecordSchema.optional(), + tlsInfo: JsonRecordSchema.optional(), + metadata: JsonRecordSchema.optional(), +}); + +export const UpsertAsmAssetsRequestSchema = z + .object({ + domainId: z.string().uuid().optional(), + domain: z.string().trim().min(1).max(255).optional(), + runId: z.string().optional(), + workflowId: z.string().optional(), + assets: z.array(AsmAssetUpsertItemSchema).max(5000).default([]), + }) + .refine((value) => Boolean(value.domainId || value.domain), { + message: 'domainId or domain is required', + path: ['domainId'], + }); + +export const UpsertAsmAssetsResponseSchema = z.object({ + domainId: z.string().uuid(), + assetCount: z.number().int().nonnegative(), + created: z.number().int().nonnegative(), + updated: z.number().int().nonnegative(), + skipped: z.number().int().nonnegative(), +}); + +export class UpsertAsmAssetsRequestDto extends createZodDto(UpsertAsmAssetsRequestSchema) {} +export class UpsertAsmAssetsResponseDto extends createZodDto(UpsertAsmAssetsResponseSchema) {} + +export type AsmAssetUpsertItemDto = z.infer; diff --git a/backend/src/asset-discovery/__tests__/asset-discovery.service.spec.ts b/backend/src/asset-discovery/__tests__/asset-discovery.service.spec.ts new file mode 100644 index 000000000..80d2869d6 --- /dev/null +++ b/backend/src/asset-discovery/__tests__/asset-discovery.service.spec.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, mock } from 'bun:test'; + +import { AssetDiscoveryService } from '../asset-discovery.service'; + +describe('AssetDiscoveryService.refreshFromGraphSnapshot', () => { + it('derives sync counters from prior completed run totals', async () => { + const service = new AssetDiscoveryService( + { + getHistoricalStatusCounts: mock(() => Promise.resolve([])), + upsertGraphSnapshots: mock(() => + Promise.resolve({ + created: 0, + updated: 1, + stale: 2, + }), + ), + markStaleUnseenAssets: mock(() => Promise.resolve(2)), + } as any, + { + findLatestCompletedSyncForIntegration: mock(() => Promise.resolve({ assetsDiscovered: 3 })), + } as any, + { + listAssets: mock(() => + Promise.resolve([ + { + id: 'asset-1', + organizationId: 'org-1', + integrationId: 'conn-1', + provider: 'gcp', + assetType: 'gcp_project', + externalId: 'project-a', + name: 'Project A', + region: 'global', + accountIdentifier: 'project-a', + metadata: {}, + firstSeenAt: new Date().toISOString(), + lastSeenAt: new Date().toISOString(), + }, + ]), + ), + } as any, + {} as any, + ); + + const result = await service.refreshFromGraphSnapshot('org-1', 'conn-1', 'sync-1', [ + { connectionId: 'conn-1', accountIdentifier: 'project-a' }, + ]); + + expect(result.assetsDiscovered).toBe(1); + expect(result.assetsCreated).toBe(0); + expect(result.assetsUpdated).toBe(1); + expect(result.assetsStale).toBe(2); + }); +}); + +describe('AssetDiscoveryService.listAssets', () => { + it('binds CE cloud credentials when filtering graph assets', async () => { + const cloudGraphListAssets = mock(() => Promise.resolve([])); + const records = [ + { + id: 'aws-access-key-connection', + provider: 'aws', + credentialType: 'access_key', + metadata: { accountId: '123456789012' }, + }, + { + id: 'gcp-service-account-key-connection', + provider: 'gcp', + credentialType: 'service_account_key', + metadata: { + defaultProjectId: 'shipsec', + discoveredResources: [{ id: '281737364847' }], + }, + }, + ]; + const db = { + select: mock(() => ({ + from: mock(() => ({ + where: mock(() => Promise.resolve(records)), + })), + })), + }; + const service = new AssetDiscoveryService( + { + getHistoricalStatusCounts: mock(() => Promise.resolve([])), + } as any, + { + findLatestCompletedSyncForIntegration: mock(() => Promise.resolve(null)), + } as any, + { + listAssets: cloudGraphListAssets, + } as any, + db as any, + ); + + await service.listAssets({ organizationId: 'org-1' } as any); + + expect(cloudGraphListAssets).toHaveBeenCalledWith('org-1', [ + { connectionId: 'aws-access-key-connection', accountIdentifier: '123456789012' }, + { connectionId: 'gcp-service-account-key-connection', accountIdentifier: 'shipsec' }, + { connectionId: 'gcp-service-account-key-connection', accountIdentifier: '281737364847' }, + ]); + }); +}); diff --git a/backend/src/asset-discovery/__tests__/asset-sync.service.spec.ts b/backend/src/asset-discovery/__tests__/asset-sync.service.spec.ts new file mode 100644 index 000000000..e365f38ac --- /dev/null +++ b/backend/src/asset-discovery/__tests__/asset-sync.service.spec.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, mock } from 'bun:test'; + +import { AssetSyncService } from '../asset-sync.service'; + +function makeDbWithConnections(connections: Record[]) { + return { + select: () => ({ + from: () => ({ + where: () => connections, + }), + }), + } as any; +} + +describe('AssetSyncService.prepareSyncContext', () => { + it('limits explicit integration syncs to the selected GCP connection', async () => { + const assertConnectionBelongsToOrg = mock(() => Promise.resolve()); + const resolveConnectionCredentials = mock((connectionId: string) => + Promise.resolve({ + provider: 'gcp', + credentialType: 'service_account', + accountId: connectionId === 'gcp-1' ? 'project-a' : 'project-b', + data: {}, + }), + ); + const createSyncRun = mock(() => Promise.resolve({ id: 'sync-run-1' })); + + const service = new AssetSyncService( + { + assertConnectionBelongsToOrg, + resolveConnectionCredentials, + } as any, + { + createSyncRun, + } as any, + { + getDatabaseName: () => 'neo4j', + } as any, + {} as any, + makeDbWithConnections([ + { + id: 'gcp-1', + provider: 'gcp', + credentialType: 'service_account', + displayName: 'GCP One', + metadata: { + defaultProjectId: 'project-a', + discoveredResources: [ + { id: 'project-a', type: 'project' }, + { id: 'project-c', type: 'project' }, + ], + }, + }, + { + id: 'gcp-2', + provider: 'gcp', + credentialType: 'service_account', + displayName: 'GCP Two', + metadata: { + defaultProjectId: 'project-b', + }, + }, + ]), + ); + + const context = await service.prepareSyncContext({ + organizationId: 'org-1', + integrationId: 'gcp-1', + }); + + expect(context.integrationId).toBe('gcp-1'); + expect(context.targets).toHaveLength(1); + expect(context.targets[0]?.connectionId).toBe('gcp-1'); + expect(context.connectionBindings).toEqual( + expect.arrayContaining([ + { connectionId: 'gcp-1', accountIdentifier: 'project-a' }, + { connectionId: 'gcp-1', accountIdentifier: 'project-c' }, + ]), + ); + expect(resolveConnectionCredentials).toHaveBeenCalledTimes(1); + expect(resolveConnectionCredentials).toHaveBeenCalledWith('gcp-1'); + }); + + it('includes multiple providers when no integration is pinned', async () => { + const resolveConnectionCredentials = mock((connectionId: string) => + Promise.resolve( + connectionId === 'aws-1' + ? { + provider: 'aws', + credentialType: 'iam_role', + accountId: '111111111111', + region: 'us-east-1', + data: { accessKeyId: 'AKIA', secretAccessKey: 'SECRET', region: 'us-east-1' }, + } + : { + provider: 'gcp', + credentialType: 'service_account', + accountId: 'project-a', + data: { targetServiceAccountEmail: 'svc@example.com' }, + }, + ), + ); + const createSyncRun = mock(() => Promise.resolve({ id: 'sync-run-1' })); + + const service = new AssetSyncService( + { + resolveConnectionCredentials, + } as any, + { + createSyncRun, + } as any, + { + getDatabaseName: () => 'neo4j', + } as any, + {} as any, + makeDbWithConnections([ + { + id: 'aws-1', + provider: 'aws', + credentialType: 'iam_role', + displayName: 'AWS One', + metadata: { + accountId: '111111111111', + region: 'us-east-1', + }, + }, + { + id: 'gcp-1', + provider: 'gcp', + credentialType: 'service_account', + displayName: 'GCP One', + metadata: { + defaultProjectId: 'project-a', + }, + }, + ]), + ); + + const context = await service.prepareSyncContext({ + organizationId: 'org-1', + }); + + expect(context.targets).toHaveLength(2); + expect(context.targets.map((target: any) => target.connectionId)).toEqual( + expect.arrayContaining(['aws-1', 'gcp-1']), + ); + expect(resolveConnectionCredentials).toHaveBeenCalledTimes(2); + }); +}); diff --git a/backend/src/asset-discovery/asset-discovery.controller.ts b/backend/src/asset-discovery/asset-discovery.controller.ts new file mode 100644 index 000000000..ed7e1780a --- /dev/null +++ b/backend/src/asset-discovery/asset-discovery.controller.ts @@ -0,0 +1,162 @@ +import { + Controller, + Get, + Post, + Param, + Query, + Body, + Headers, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiAcceptedResponse, ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { AssetDiscoveryService } from './asset-discovery.service'; +import { AssetSyncService } from './asset-sync.service'; +import { CurrentAuth } from '../auth/auth-context.decorator'; +import { InternalOnly } from '../common/guards/internal-only.guard'; +import type { AuthContext } from '../auth/types'; +import { ListAssetsDto } from './dto/list-assets.dto'; +import { ListSyncRunsDto } from './dto/list-sync-runs.dto'; +import { + CloudAssetDto, + CloudAssetListResponseDto, + CloudAssetSummaryDto, + CloudSyncTriggerResponseDto, + CloudSyncRunDto, + CloudSyncRunListResponseDto, +} from './dto/asset-discovery-response.dto'; +import { + PrepareSyncContextRequestDto, + PrepareSyncContextResponseDto, + RefreshGraphSnapshotRequestDto, + RefreshGraphSnapshotResponseDto, + TriggerSyncDto, + UpdateSyncRunStatusRequestDto, + UpdateSyncRunStatusResponseDto, +} from './dto/sync-assets.dto'; + +@ApiTags('asset-discovery') +@Controller('asset-discovery') +export class AssetDiscoveryController { + constructor( + private readonly service: AssetDiscoveryService, + private readonly syncService: AssetSyncService, + ) {} + + // ── Public endpoints (AuthGuard applied globally) ────────────────────────── + + @Get('assets') + @ApiQuery({ name: 'provider', required: false, type: String }) + @ApiQuery({ name: 'assetType', required: false, type: String }) + @ApiQuery({ name: 'region', required: false, type: String }) + @ApiQuery({ name: 'status', required: false, enum: ['active', 'stale', 'removed'] }) + @ApiQuery({ name: 'integrationId', required: false, type: String }) + @ApiQuery({ name: 'search', required: false, type: String }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'offset', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'sortBy', required: false, enum: ['name', 'lastSeenAt', 'firstDiscoveredAt'] }) + @ApiQuery({ name: 'sortOrder', required: false, enum: ['asc', 'desc'] }) + @ApiOkResponse({ + description: 'Cloud assets with pagination metadata', + type: CloudAssetListResponseDto, + }) + async listAssets(@CurrentAuth() auth: AuthContext, @Query() query: ListAssetsDto) { + return this.service.listAssets(auth, query); + } + + @Get('assets/summary') + @ApiOkResponse({ + description: 'Cloud asset summary aggregates', + type: CloudAssetSummaryDto, + }) + async getAssetSummary(@CurrentAuth() auth: AuthContext) { + return this.service.getSummary(auth); + } + + @Get('assets/:id') + @ApiOkResponse({ + description: 'Single cloud asset', + type: CloudAssetDto, + }) + async getAsset(@CurrentAuth() auth: AuthContext, @Param('id') id: string) { + return this.service.getAsset(auth, id); + } + + @Post('assets/sync') + @HttpCode(HttpStatus.ACCEPTED) + @ApiAcceptedResponse({ + description: 'Cloud sync accepted', + type: CloudSyncTriggerResponseDto, + }) + async triggerSync(@CurrentAuth() auth: AuthContext, @Body() body: TriggerSyncDto) { + return this.syncService.triggerSync(body.integrationId, auth); + } + + @Get('sync-runs') + @ApiQuery({ name: 'integrationId', required: false, type: String }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'offset', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiOkResponse({ + description: 'Cloud sync runs with pagination metadata', + type: CloudSyncRunListResponseDto, + }) + async listSyncRuns(@CurrentAuth() auth: AuthContext, @Query() query: ListSyncRunsDto) { + return this.service.listSyncRuns(auth, query); + } + + @Get('sync-runs/:id') + @ApiOkResponse({ + description: 'Single cloud sync run', + type: CloudSyncRunDto, + }) + async getSyncRun(@CurrentAuth() auth: AuthContext, @Param('id') id: string) { + return this.service.getSyncRun(auth, id); + } + + // ── Internal endpoints (called by worker activities) ─────────────────────── + + @Post('internal/sync-context') + @InternalOnly() + @HttpCode(HttpStatus.OK) + @ApiOkResponse({ type: PrepareSyncContextResponseDto }) + async prepareSyncContext( + @Headers('x-organization-id') orgId: string, + @Body() body: PrepareSyncContextRequestDto, + ) { + return this.syncService.prepareSyncContext({ + organizationId: orgId, + integrationId: body.integrationId, + temporalWorkflowId: body.temporalWorkflowId, + temporalRunId: body.temporalRunId, + }); + } + + @Post('internal/assets/refresh-from-graph') + @InternalOnly() + @HttpCode(HttpStatus.OK) + @ApiOkResponse({ type: RefreshGraphSnapshotResponseDto }) + async refreshFromGraph( + @Headers('x-organization-id') orgId: string, + @Body() body: RefreshGraphSnapshotRequestDto, + ) { + return this.service.refreshFromGraphSnapshot( + orgId, + body.integrationId, + body.syncRunId, + body.connectionBindings, + ); + } + + @Post('internal/sync-runs/:id/status') + @InternalOnly() + @HttpCode(HttpStatus.OK) + @ApiOkResponse({ type: UpdateSyncRunStatusResponseDto }) + async updateSyncRunStatus( + @Param('id') syncRunId: string, + @Body() body: UpdateSyncRunStatusRequestDto, + ) { + return this.service.updateSyncRunStatus(syncRunId, body.status, body); + } +} diff --git a/backend/src/asset-discovery/asset-discovery.module.ts b/backend/src/asset-discovery/asset-discovery.module.ts new file mode 100644 index 000000000..d648d7bbd --- /dev/null +++ b/backend/src/asset-discovery/asset-discovery.module.ts @@ -0,0 +1,34 @@ +import { Module } from '@nestjs/common'; +import { AssetDiscoveryController } from './asset-discovery.controller'; +import { AssetDiscoveryService } from './asset-discovery.service'; +import { AssetDiscoveryRepository } from './asset-discovery.repository'; +import { AssetSyncService } from './asset-sync.service'; +import { AssetSyncRunsRepository } from './asset-sync-runs.repository'; +import { DatabaseModule } from '../database/database.module'; +import { ApiKeysModule } from '../api-keys/api-keys.module'; +import { AuthModule } from '../auth/auth.module'; +import { TemporalModule } from '../temporal/temporal.module'; +import { IntegrationsModule } from '../integrations/integrations.module'; +import { CloudGraphService } from './cloud-graph.service'; +import { SchedulesModule } from '../schedules/schedules.module'; + +@Module({ + imports: [ + DatabaseModule, + ApiKeysModule, + AuthModule, + TemporalModule, + IntegrationsModule, + SchedulesModule, + ], + controllers: [AssetDiscoveryController], + providers: [ + AssetDiscoveryService, + AssetDiscoveryRepository, + AssetSyncService, + AssetSyncRunsRepository, + CloudGraphService, + ], + exports: [AssetDiscoveryService, AssetSyncService], +}) +export class AssetDiscoveryModule {} diff --git a/backend/src/asset-discovery/asset-discovery.repository.ts b/backend/src/asset-discovery/asset-discovery.repository.ts new file mode 100644 index 000000000..991288172 --- /dev/null +++ b/backend/src/asset-discovery/asset-discovery.repository.ts @@ -0,0 +1,144 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { and, count, eq, inArray, sql } from 'drizzle-orm'; +import { type NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { DRIZZLE_TOKEN } from '../database/database.module'; +import * as schema from '../database/schema'; +import { assets } from '../database/schema/asset-discovery'; +import type { GraphAssetSnapshot } from './cloud-graph.service'; + +@Injectable() +export class AssetDiscoveryRepository { + constructor( + @Inject(DRIZZLE_TOKEN) + private readonly db: NodePgDatabase, + ) {} + + async getHistoricalStatusCounts(organizationId: string) { + const rows = await this.db + .select({ status: assets.status, count: count() }) + .from(assets) + .where( + and( + eq(assets.organizationId, organizationId), + inArray(assets.status, ['stale', 'removed']), + ), + ) + .groupBy(assets.status); + + return rows.map((row) => ({ + status: row.status ?? 'unknown', + count: Number(row.count), + })); + } + + async upsertGraphSnapshots( + organizationId: string, + integrationId: string, + snapshots: GraphAssetSnapshot[], + ): Promise<{ created: number; updated: number }> { + if (snapshots.length === 0) return { created: 0, updated: 0 }; + + let created = 0; + let updated = 0; + + for (const s of snapshots) { + const existing = await this.db + .select({ id: assets.id, name: assets.name }) + .from(assets) + .where( + and( + eq(assets.organizationId, organizationId), + eq(assets.provider, s.provider), + eq(assets.assetType, s.assetType), + eq(assets.externalId, s.externalId), + ), + ) + .limit(1); + + if (existing.length > 0) { + await this.db + .update(assets) + .set({ + integrationId, + name: s.name ?? existing[0]?.name ?? null, + region: s.region ?? null, + metadata: s.metadata, + lastSeenAt: new Date(s.lastSeenAt), + status: 'active', + updatedAt: new Date(), + }) + .where(eq(assets.id, existing[0].id)); + updated++; + } else { + await this.db + .insert(assets) + .values({ + id: s.id, + organizationId, + integrationId, + provider: s.provider, + accountIdentifier: s.accountIdentifier ?? null, + assetType: s.assetType, + externalId: s.externalId, + name: s.name ?? null, + region: s.region ?? null, + metadata: s.metadata, + firstDiscoveredAt: new Date(s.firstSeenAt), + lastSeenAt: new Date(s.lastSeenAt), + }) + .onConflictDoUpdate({ + target: [assets.organizationId, assets.provider, assets.assetType, assets.externalId], + set: { + integrationId, + accountIdentifier: s.accountIdentifier ?? null, + name: s.name ?? null, + region: s.region ?? null, + metadata: s.metadata, + lastSeenAt: new Date(s.lastSeenAt), + status: 'active', + updatedAt: new Date(), + }, + }); + created++; + } + } + + return { created, updated }; + } + + async markStaleUnseenAssets( + organizationId: string, + integrationId: string, + seenExternalIds: string[], + ): Promise { + if (seenExternalIds.length === 0) { + const result = await this.db + .update(assets) + .set({ status: 'stale', updatedAt: new Date() }) + .where( + and( + eq(assets.organizationId, organizationId), + eq(assets.integrationId, integrationId), + eq(assets.status, 'active'), + ), + ); + return result.rowCount ?? 0; + } + + const result = await this.db + .update(assets) + .set({ status: 'stale', updatedAt: new Date() }) + .where( + and( + eq(assets.organizationId, organizationId), + eq(assets.integrationId, integrationId), + eq(assets.status, 'active'), + sql`${assets.externalId} NOT IN (${sql.join( + seenExternalIds.map((id) => sql`${id}`), + sql`, `, + )})`, + ), + ); + return result.rowCount ?? 0; + } +} diff --git a/backend/src/asset-discovery/asset-discovery.service.ts b/backend/src/asset-discovery/asset-discovery.service.ts new file mode 100644 index 000000000..c101943a2 --- /dev/null +++ b/backend/src/asset-discovery/asset-discovery.service.ts @@ -0,0 +1,356 @@ +import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { eq } from 'drizzle-orm'; +import { type NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { AssetDiscoveryRepository } from './asset-discovery.repository'; +import { AssetSyncRunsRepository } from './asset-sync-runs.repository'; +import type { AuthContext } from '../auth/types'; +import { CloudGraphService } from './cloud-graph.service'; +import type { CartographySyncConnectionBinding } from './cartography-utils'; +import { DRIZZLE_TOKEN } from '../database/database.module'; +import * as schema from '../database/schema'; +import { integrationTokens } from '../database/schema/integrations'; + +@Injectable() +export class AssetDiscoveryService { + private readonly logger = new Logger(AssetDiscoveryService.name); + + constructor( + private readonly assetRepo: AssetDiscoveryRepository, + private readonly syncRunsRepo: AssetSyncRunsRepository, + private readonly cloudGraphService: CloudGraphService, + @Inject(DRIZZLE_TOKEN) + private readonly db: NodePgDatabase, + ) {} + + private requireOrganizationId(auth: AuthContext | null): string { + const orgId = auth?.organizationId; + if (!orgId) throw new BadRequestException('Organization context required'); + return orgId; + } + + // ── Asset Queries ────────────────────────────────────────────────────────── + + async listAssets(auth: AuthContext, options: Record = {}) { + const organizationId = this.requireOrganizationId(auth); + const status = options.status; + if (status === 'stale' || status === 'removed') { + return { + assets: [], + total: 0, + page: Number(options.page ?? 1), + limit: Number(options.limit ?? 50), + }; + } + + const snapshot = await this.listGraphAssetsForOrg(organizationId); + + const filtered = snapshot + .filter((asset) => !options.provider || asset.provider === options.provider) + .filter((asset) => !options.assetType || asset.assetType === options.assetType) + .filter((asset) => !options.region || asset.region === options.region) + .filter((asset) => !options.integrationId || asset.integrationId === options.integrationId) + .filter((asset) => { + if (!options.search) return true; + const search = String(options.search).toLowerCase(); + return ( + (asset.name ?? '').toLowerCase().includes(search) || + asset.externalId.toLowerCase().includes(search) + ); + }); + + const sortBy: 'name' | 'firstSeenAt' | 'lastSeenAt' = + options.sortBy === 'name' + ? 'name' + : options.sortBy === 'firstDiscoveredAt' + ? 'firstSeenAt' + : 'lastSeenAt'; + const sortOrder = options.sortOrder === 'asc' ? 'asc' : 'desc'; + filtered.sort((a, b) => compareAssetValues(a[sortBy], b[sortBy], sortOrder)); + + const limit = Math.min(Number(options.limit ?? 50), 200); + const page = Number(options.page ?? 1); + const offset = options.offset != null ? Number(options.offset) : Math.max(page - 1, 0) * limit; + + return { + assets: filtered.slice(offset, offset + limit).map((asset) => ({ + ...asset, + status: 'active', + firstDiscoveredAt: asset.firstSeenAt, + lastSeenAt: asset.lastSeenAt, + createdAt: asset.firstSeenAt, + updatedAt: asset.lastSeenAt, + })), + total: filtered.length, + page, + limit, + }; + } + + async getAsset(auth: AuthContext, assetId: string) { + const organizationId = this.requireOrganizationId(auth); + const assets = await this.listGraphAssetsForOrg(organizationId); + const asset = assets.find((candidate) => candidate.id === assetId); + if (!asset) throw new NotFoundException('Asset not found'); + return { + ...asset, + status: 'active', + firstDiscoveredAt: asset.firstSeenAt, + lastSeenAt: asset.lastSeenAt, + createdAt: asset.firstSeenAt, + updatedAt: asset.lastSeenAt, + }; + } + + async getSummary(auth: AuthContext) { + const organizationId = this.requireOrganizationId(auth); + const [graphAssets, historicalStatuses] = await Promise.all([ + this.listGraphAssetsForOrg(organizationId), + this.assetRepo.getHistoricalStatusCounts(organizationId), + ]); + + const byType = new Map(); + const byRegion = new Map(); + for (const asset of graphAssets) { + byType.set(asset.assetType, (byType.get(asset.assetType) ?? 0) + 1); + byRegion.set(asset.region ?? 'global', (byRegion.get(asset.region ?? 'global') ?? 0) + 1); + } + + const byStatus = [{ status: 'active', count: graphAssets.length }, ...historicalStatuses]; + + return { + totalAssets: byStatus.reduce((sum, row) => sum + row.count, 0), + byType: Array.from(byType.entries()).map(([assetType, count]) => ({ assetType, count })), + byRegion: Array.from(byRegion.entries()).map(([region, count]) => ({ region, count })), + byStatus, + }; + } + + // ── Sync Runs ────────────────────────────────────────────────────────────── + + async listSyncRuns( + auth: AuthContext, + options: { integrationId?: string; limit?: number; page?: number; offset?: number } = {}, + ) { + const organizationId = this.requireOrganizationId(auth); + return this.syncRunsRepo.listByOrg(organizationId, options); + } + + async getSyncRun(auth: AuthContext, syncRunId: string) { + const organizationId = this.requireOrganizationId(auth); + const run = await this.syncRunsRepo.getById(syncRunId); + if (!run || run.organizationId !== organizationId) { + throw new NotFoundException('Sync run not found'); + } + return run; + } + + // ── Internal endpoints (called by worker activities) ─────────────────────── + + async createSyncRun( + organizationId: string, + integrationId: string, + accountIdentifier: string | null, + regions: string[], + temporalWorkflowId?: string, + temporalRunId?: string, + ) { + const now = new Date(); + return this.syncRunsRepo.create({ + organizationId, + integrationId, + accountIdentifier, + temporalWorkflowId, + temporalRunId, + status: 'running', + coveredRegions: regions, + startedAt: now, + }); + } + + async updateSyncRunStatus( + syncRunId: string, + status: string, + stats?: { + assetsDiscovered?: number; + assetsCreated?: number; + assetsUpdated?: number; + assetsStale?: number; + coveredRegions?: string[]; + errors?: { message: string; timestamp: string }[]; + }, + ) { + return this.syncRunsRepo.updateStatus(syncRunId, { + status, + ...stats, + completedAt: status === 'completed' || status === 'failed' ? new Date() : undefined, + }); + } + + async refreshFromGraphSnapshot( + organizationId: string, + integrationId: string, + syncRunId: string, + connectionBindings?: CartographySyncConnectionBinding[], + ) { + const previousCompletedSync = await this.syncRunsRepo.findLatestCompletedSyncForIntegration( + organizationId, + integrationId, + ); + const bindings = connectionBindings?.length ? connectionBindings : undefined; + const graphAssets = await this.listGraphAssetsForOrg(organizationId, bindings); + + // Persist graph assets into the Postgres assets table + const upsertResult = await this.assetRepo.upsertGraphSnapshots( + organizationId, + integrationId, + graphAssets, + ); + + // Mark assets not seen in this sync as stale + const staleCount = await this.assetRepo.markStaleUnseenAssets( + organizationId, + integrationId, + graphAssets.map((a) => a.externalId), + ); + + this.logger.log( + `Sync ${syncRunId}: upserted ${upsertResult.created} created, ${upsertResult.updated} updated, ${staleCount} marked stale`, + ); + + const currentDiscovered = graphAssets.length; + const previousDiscovered = Number(previousCompletedSync?.assetsDiscovered ?? 0); + const assetsCreated = Math.max(currentDiscovered - previousDiscovered, 0); + const assetsStale = Math.max(previousDiscovered - currentDiscovered, 0); + const assetsUpdated = Math.min(currentDiscovered, previousDiscovered); + + return { + assetsDiscovered: currentDiscovered, + assetsCreated, + assetsUpdated, + assetsStale, + coveredRegions: Array.from( + new Set( + graphAssets.map((asset) => asset.region).filter((region): region is string => !!region), + ), + ), + }; + } + + private async listGraphAssetsForOrg( + organizationId: string, + bindings?: CartographySyncConnectionBinding[], + ) { + const connectionBindings = + bindings && bindings.length > 0 + ? dedupeConnectionBindings(bindings) + : await this.listCloudConnectionBindingsForOrg(organizationId); + return this.cloudGraphService.listAssets(organizationId, connectionBindings); + } + + private async listCloudConnectionBindingsForOrg( + organizationId: string, + ): Promise { + const records = await this.db + .select({ + id: integrationTokens.id, + provider: integrationTokens.provider, + credentialType: integrationTokens.credentialType, + metadata: integrationTokens.metadata, + }) + .from(integrationTokens) + .where(eq(integrationTokens.organizationId, organizationId)); + + const bindings: CartographySyncConnectionBinding[] = []; + for (const record of records) { + const accountIdentifiers = extractConnectionIdentifiers( + record.provider, + record.credentialType, + coerceMetadata(record.metadata), + ); + for (const accountIdentifier of accountIdentifiers) { + bindings.push({ + connectionId: record.id, + accountIdentifier, + }); + } + } + + return dedupeConnectionBindings(bindings); + } +} + +function compareAssetValues( + left: string | null | undefined, + right: string | null | undefined, + sortOrder: 'asc' | 'desc', +): number { + const lhs = left ?? ''; + const rhs = right ?? ''; + return sortOrder === 'asc' ? lhs.localeCompare(rhs) : rhs.localeCompare(lhs); +} + +function dedupeConnectionBindings( + bindings: CartographySyncConnectionBinding[], +): CartographySyncConnectionBinding[] { + const deduped = new Map(); + for (const binding of bindings) { + if (!binding.accountIdentifier) { + continue; + } + + deduped.set(`${binding.connectionId}:${binding.accountIdentifier}`, binding); + } + return Array.from(deduped.values()); +} + +function extractConnectionIdentifiers( + provider: string, + credentialType: string, + metadata: Record, +): string[] { + if (provider === 'aws' && (credentialType === 'iam_role' || credentialType === 'access_key')) { + const accountId = + (typeof metadata.accountId === 'string' && metadata.accountId.trim()) || + (typeof metadata.roleArn === 'string' + ? metadata.roleArn.match(/^arn:aws:iam::(\d{12}):role\/.+$/)?.[1] + : undefined); + return accountId ? [accountId] : []; + } + + if ( + provider !== 'gcp' || + (credentialType !== 'service_account' && credentialType !== 'service_account_key') + ) { + return []; + } + + const identifiers = new Set(); + for (const value of [metadata.organizationId, metadata.defaultProjectId]) { + if (typeof value === 'string' && value.trim()) { + identifiers.add(value.trim()); + } + } + + const discoveredResources = metadata.discoveredResources; + if (Array.isArray(discoveredResources)) { + for (const resource of discoveredResources) { + if ( + resource && + typeof resource === 'object' && + typeof resource.id === 'string' && + resource.id.trim() + ) { + identifiers.add(resource.id.trim()); + } + } + } + + return Array.from(identifiers); +} + +function coerceMetadata(metadata: unknown): Record { + if (!metadata || typeof metadata !== 'object') { + return {}; + } + return metadata as Record; +} diff --git a/backend/src/asset-discovery/asset-sync-runs.repository.ts b/backend/src/asset-discovery/asset-sync-runs.repository.ts new file mode 100644 index 000000000..cea8956e6 --- /dev/null +++ b/backend/src/asset-discovery/asset-sync-runs.repository.ts @@ -0,0 +1,122 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { type NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { and, eq, desc, count } from 'drizzle-orm'; +import { DRIZZLE_TOKEN } from '../database/database.module'; +import * as schema from '../database/schema'; +import { assetSyncRuns } from '../database/schema/asset-discovery'; +import type { NewAssetSyncRun } from '../database/schema/asset-discovery'; + +@Injectable() +export class AssetSyncRunsRepository { + constructor( + @Inject(DRIZZLE_TOKEN) + private readonly db: NodePgDatabase, + ) {} + + async create(data: NewAssetSyncRun) { + const [row] = await this.db.insert(assetSyncRuns).values(data).returning(); + return row; + } + + async updateStatus( + syncRunId: string, + updates: { + status: string; + assetsDiscovered?: number; + assetsCreated?: number; + assetsUpdated?: number; + assetsStale?: number; + coveredRegions?: string[]; + errors?: { message: string; timestamp: string }[]; + completedAt?: Date; + }, + ) { + const [row] = await this.db + .update(assetSyncRuns) + .set(updates) + .where(eq(assetSyncRuns.id, syncRunId)) + .returning(); + return row; + } + + async getById(syncRunId: string) { + const [row] = await this.db + .select() + .from(assetSyncRuns) + .where(eq(assetSyncRuns.id, syncRunId)) + .limit(1); + return row ?? null; + } + + async listByOrg( + organizationId: string, + options: { + integrationId?: string; + limit?: number; + page?: number; + offset?: number; + } = {}, + ) { + const limit = Math.min(options.limit ?? 20, 100); + const offset = options.offset != null ? options.offset : ((options.page ?? 1) - 1) * limit; + + const conditions = [eq(assetSyncRuns.organizationId, organizationId)]; + if (options.integrationId) { + conditions.push(eq(assetSyncRuns.integrationId, options.integrationId)); + } + + const where = and(...conditions); + + const [rows, [{ total }]] = await Promise.all([ + this.db + .select() + .from(assetSyncRuns) + .where(where) + .orderBy(desc(assetSyncRuns.createdAt)) + .limit(limit) + .offset(offset), + this.db.select({ total: count() }).from(assetSyncRuns).where(where), + ]); + + return { syncRuns: rows, total, page: options.page ?? 1, limit }; + } + + /** + * Find the most recent completed sync for an account (used by stale-guard). + */ + async findRecentCompletedSync( + organizationId: string, + accountIdentifier: string, + _excludeIntegrationId: string, + ) { + const [row] = await this.db + .select() + .from(assetSyncRuns) + .where( + and( + eq(assetSyncRuns.organizationId, organizationId), + eq(assetSyncRuns.accountIdentifier, accountIdentifier), + eq(assetSyncRuns.status, 'completed'), + ), + ) + .orderBy(desc(assetSyncRuns.completedAt)) + .limit(1); + return row ?? null; + } + + async findLatestCompletedSyncForIntegration(organizationId: string, integrationId: string) { + const [row] = await this.db + .select() + .from(assetSyncRuns) + .where( + and( + eq(assetSyncRuns.organizationId, organizationId), + eq(assetSyncRuns.integrationId, integrationId), + eq(assetSyncRuns.status, 'completed'), + ), + ) + .orderBy(desc(assetSyncRuns.completedAt)) + .limit(1); + return row ?? null; + } +} diff --git a/backend/src/asset-discovery/asset-sync.service.ts b/backend/src/asset-discovery/asset-sync.service.ts new file mode 100644 index 000000000..2ea98d6aa --- /dev/null +++ b/backend/src/asset-discovery/asset-sync.service.ts @@ -0,0 +1,261 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { and, eq } from 'drizzle-orm'; +import { type NodePgDatabase } from 'drizzle-orm/node-postgres'; + +import type { AuthContext } from '../auth/types'; +import { DRIZZLE_TOKEN } from '../database/database.module'; +import * as schema from '../database/schema'; +import { integrationTokens } from '../database/schema/integrations'; +import { IntegrationsService } from '../integrations/integrations.service'; +import { AssetDiscoveryService } from './asset-discovery.service'; +import { CloudGraphService } from './cloud-graph.service'; +import { IndexInfraService } from '../schedules/index-infra.service'; + +const ASSET_DISCOVERY_ENABLED = process.env.ASSET_DISCOVERY_ENABLED !== 'false'; +const SUPPORTED_CLOUD_CREDENTIALS: readonly { provider: string; credentialType: string }[] = [ + { provider: 'aws', credentialType: 'iam_role' }, + { provider: 'aws', credentialType: 'access_key' }, + { provider: 'gcp', credentialType: 'service_account' }, + { provider: 'gcp', credentialType: 'service_account_key' }, + { provider: 'cloudflare', credentialType: 'api_token' }, +]; + +@Injectable() +export class AssetSyncService { + constructor( + private readonly integrationsService: IntegrationsService, + private readonly assetDiscoveryService: AssetDiscoveryService, + private readonly cloudGraphService: CloudGraphService, + private readonly indexInfraService: IndexInfraService, + @Inject(DRIZZLE_TOKEN) + private readonly db: NodePgDatabase, + ) {} + + /** Manual sync trigger via REST API. */ + async triggerSync(integrationId: string, auth: AuthContext) { + if (!ASSET_DISCOVERY_ENABLED) { + throw new BadRequestException('Asset discovery is not enabled'); + } + + const orgId = auth?.organizationId; + if (!orgId) throw new BadRequestException('Organization context required'); + + await this.integrationsService.assertConnectionBelongsToOrg(integrationId, orgId); + + const [connection] = await this.db + .select({ + provider: integrationTokens.provider, + credentialType: integrationTokens.credentialType, + }) + .from(integrationTokens) + .where( + and(eq(integrationTokens.id, integrationId), eq(integrationTokens.organizationId, orgId)), + ) + .limit(1); + + if (!isSupportedCloudConnection(connection)) { + throw new BadRequestException( + 'Only supported AWS and GCP integration connections support asset sync', + ); + } + + const run = await this.indexInfraService.triggerRun({ + auth, + integrationId, + trigger: { + type: 'manual', + sourceId: integrationId, + label: 'Cloud inventory sync', + }, + }); + + return { workflowId: run.workflowId, runId: run.runId }; + } + + async prepareSyncContext(input: { + organizationId: string; + integrationId?: string; + temporalWorkflowId?: string; + temporalRunId?: string; + }) { + if (input.integrationId) { + await this.integrationsService.assertConnectionBelongsToOrg( + input.integrationId, + input.organizationId, + ); + } + + const orgConnections = await this.db + .select({ + id: integrationTokens.id, + provider: integrationTokens.provider, + credentialType: integrationTokens.credentialType, + displayName: integrationTokens.displayName, + metadata: integrationTokens.metadata, + }) + .from(integrationTokens) + .where(eq(integrationTokens.organizationId, input.organizationId)); + + const supportedConnections = orgConnections.filter((connection) => + isSupportedCloudConnection(connection), + ); + + if (input.integrationId) { + const selectedConnection = orgConnections.find( + (connection) => connection.id === input.integrationId, + ); + if (!isSupportedCloudConnection(selectedConnection)) { + throw new BadRequestException( + 'Only supported AWS and GCP integration connections support asset sync', + ); + } + } + + if (supportedConnections.length === 0) { + throw new BadRequestException('No supported cloud connections available for org sync'); + } + + const effectiveConnection = input.integrationId + ? supportedConnections.find((connection) => connection.id === input.integrationId) + : supportedConnections[0]; + if (!effectiveConnection) { + throw new BadRequestException('Failed to resolve supported cloud connection for sync'); + } + const effectiveIntegrationId = effectiveConnection.id; + const connections = input.integrationId ? [effectiveConnection] : supportedConnections; + + const targets = []; + const coveredRegions = new Set(); + const connectionBindings = []; + + for (const connection of connections) { + const resolved = await this.integrationsService.resolveConnectionCredentials(connection.id); + const bindingIdentifiers = new Set(); + if (resolved.accountId) { + bindingIdentifiers.add(resolved.accountId); + } + for (const identifier of extractConnectionIdentifiers( + connection.provider, + connection.credentialType, + coerceMetadata(connection.metadata), + )) { + bindingIdentifiers.add(identifier); + } + + if (bindingIdentifiers.size === 0) { + continue; + } + + const region = + resolved.provider === 'aws' + ? (resolved.region ?? + (typeof resolved.data?.region === 'string' ? resolved.data.region : null) ?? + null) + : null; + if (region) { + coveredRegions.add(region); + } + + targets.push({ + connectionId: connection.id, + displayName: connection.displayName, + accountIdentifier: resolved.accountId ?? Array.from(bindingIdentifiers)[0] ?? null, + region, + }); + for (const accountIdentifier of bindingIdentifiers) { + connectionBindings.push({ + connectionId: connection.id, + accountIdentifier, + }); + } + } + + if (targets.length === 0) { + throw new BadRequestException('Failed to resolve usable cloud credentials for org sync'); + } + + const run = await this.assetDiscoveryService.createSyncRun( + input.organizationId, + effectiveIntegrationId, + null, + Array.from(coveredRegions), + input.temporalWorkflowId, + input.temporalRunId, + ); + + return { + syncRunId: run.id, + integrationId: effectiveIntegrationId, + neo4jDatabase: this.cloudGraphService.getDatabaseName(), + targets, + coveredRegions: Array.from(coveredRegions), + connectionBindings, + }; + } +} + +function isSupportedCloudConnection( + connection: { provider?: string | null; credentialType?: string | null } | undefined, +): connection is { provider: string; credentialType: string } { + if (!connection?.provider || !connection?.credentialType) { + return false; + } + + return SUPPORTED_CLOUD_CREDENTIALS.some( + (supported) => + supported.provider === connection.provider && + supported.credentialType === connection.credentialType, + ); +} + +function coerceMetadata(metadata: unknown): Record { + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) { + return {}; + } + return metadata as Record; +} + +function extractConnectionIdentifiers( + provider: string, + credentialType: string, + metadata: Record, +): string[] { + if (provider === 'aws' && (credentialType === 'iam_role' || credentialType === 'access_key')) { + const accountId = + (typeof metadata.accountId === 'string' && metadata.accountId.trim()) || + (typeof metadata.roleArn === 'string' + ? metadata.roleArn.match(/^arn:aws:iam::(\d{12}):role\/.+$/)?.[1] + : undefined); + return accountId ? [accountId] : []; + } + + if ( + provider !== 'gcp' || + (credentialType !== 'service_account' && credentialType !== 'service_account_key') + ) { + return []; + } + + const identifiers = new Set(); + for (const value of [metadata.organizationId, metadata.defaultProjectId]) { + if (typeof value === 'string' && value.trim()) { + identifiers.add(value.trim()); + } + } + + const discoveredResources = metadata.discoveredResources; + if (Array.isArray(discoveredResources)) { + for (const resource of discoveredResources) { + if ( + resource && + typeof resource === 'object' && + typeof resource.id === 'string' && + resource.id.trim() + ) { + identifiers.add(resource.id.trim()); + } + } + } + + return Array.from(identifiers); +} diff --git a/backend/src/asset-discovery/cartography-utils.ts b/backend/src/asset-discovery/cartography-utils.ts new file mode 100644 index 000000000..3e6e3d0c6 --- /dev/null +++ b/backend/src/asset-discovery/cartography-utils.ts @@ -0,0 +1,24 @@ +const CARTOGRAPHY_DB_PREFIX = 'cartography_org_'; +const MAX_DATABASE_NAME_LENGTH = 63; + +export function buildCartographyDatabaseName(organizationId: string): string { + const sanitized = organizationId + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); + + const suffix = sanitized.length > 0 ? sanitized : 'default'; + const maxSuffixLength = MAX_DATABASE_NAME_LENGTH - CARTOGRAPHY_DB_PREFIX.length; + + return `${CARTOGRAPHY_DB_PREFIX}${suffix.slice(0, Math.max(maxSuffixLength, 1))}`; +} + +export interface CartographySyncConnectionBinding { + connectionId: string; + accountIdentifier: string | null; +} + +export function buildOrgAssetSyncWorkflowId(organizationId: string): string { + return `asset-sync-org-${organizationId}`; +} diff --git a/backend/src/asset-discovery/cloud-graph.service.ts b/backend/src/asset-discovery/cloud-graph.service.ts new file mode 100644 index 000000000..988f2a569 --- /dev/null +++ b/backend/src/asset-discovery/cloud-graph.service.ts @@ -0,0 +1,854 @@ +import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import neo4j from 'neo4j-driver'; +import type { Driver } from 'neo4j-driver'; +import { createHash } from 'node:crypto'; +import type { CartographySyncConnectionBinding } from './cartography-utils'; + +interface AssetTypeConfig { + labels: string[]; + externalIdCandidates: string[]; + nameCandidates: string[]; + regionCandidates: string[]; + accountCandidates: string[]; + firstSeenCandidates?: string[]; + lastSeenCandidates?: string[]; +} + +type CloudProvider = 'aws' | 'gcp' | 'cloudflare'; + +interface AssetTypeDescriptor { + provider: CloudProvider; + assetType: string; + config: AssetTypeConfig; +} + +export interface GraphAssetSnapshot { + id: string; + organizationId: string; + integrationId: string | null; + provider: string; + assetType: string; + externalId: string; + name: string | null; + region: string | null; + accountIdentifier: string | null; + metadata: Record; + firstSeenAt: string; + lastSeenAt: string; +} + +const AWS_ASSET_TYPE_CONFIG: Record = { + iam_user: { + labels: ['AWSUser'], + externalIdCandidates: ['arn', 'id', 'userid', 'name'], + nameCandidates: ['name', 'username', 'id'], + regionCandidates: [], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen', 'createdate'], + lastSeenCandidates: ['lastupdated', 'passwordlastused'], + }, + iam_role: { + labels: ['AWSRole'], + externalIdCandidates: ['arn', 'roleid', 'id', 'name'], + nameCandidates: ['name', 'rolename', 'id'], + regionCandidates: [], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen', 'createdate'], + lastSeenCandidates: ['lastupdated'], + }, + iam_policy: { + labels: ['AWSPolicy'], + externalIdCandidates: ['arn', 'id', 'policyid', 'name'], + nameCandidates: ['name', 'policyname', 'id'], + regionCandidates: [], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen'], + lastSeenCandidates: ['lastupdated'], + }, + iam_group: { + labels: ['AWSGroup'], + externalIdCandidates: ['arn', 'id', 'groupid', 'name'], + nameCandidates: ['name', 'groupname', 'id'], + regionCandidates: [], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen', 'createdate'], + lastSeenCandidates: ['lastupdated'], + }, + s3_bucket: { + labels: ['S3Bucket'], + externalIdCandidates: ['arn', 'bucketarn', 'id', 'name'], + nameCandidates: ['name', 'id'], + regionCandidates: ['region'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen', 'creationdate'], + lastSeenCandidates: ['lastupdated'], + }, + ec2_instance: { + labels: ['EC2Instance'], + externalIdCandidates: ['arn', 'instanceid', 'id'], + nameCandidates: ['name', 'instanceid', 'id'], + regionCandidates: ['region', 'availabilityzone'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen', 'launchtime'], + lastSeenCandidates: ['lastupdated'], + }, + ec2_security_group: { + labels: ['EC2SecurityGroup'], + externalIdCandidates: ['id', 'groupid', 'arn', 'name'], + nameCandidates: ['name', 'groupname', 'id'], + regionCandidates: ['region'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen'], + lastSeenCandidates: ['lastupdated'], + }, + ec2_eip: { + labels: ['EC2ElasticIP', 'EC2ElasticIPAddress', 'ElasticIPAddress'], + externalIdCandidates: ['allocationid', 'arn', 'publicip', 'id'], + nameCandidates: ['publicip', 'allocationid', 'id'], + regionCandidates: ['region'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen'], + lastSeenCandidates: ['lastupdated'], + }, + ec2_volume: { + labels: ['EBSVolume', 'EC2Volume'], + externalIdCandidates: ['volumeid', 'arn', 'id'], + nameCandidates: ['name', 'volumeid', 'id'], + regionCandidates: ['region', 'availabilityzone'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen', 'createtime'], + lastSeenCandidates: ['lastupdated'], + }, + route53_hosted_zone: { + labels: ['Route53HostedZone'], + externalIdCandidates: ['id', 'arn', 'name'], + nameCandidates: ['name', 'id'], + regionCandidates: [], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen'], + lastSeenCandidates: ['lastupdated'], + }, + route53_record: { + labels: ['Route53Record'], + externalIdCandidates: ['id', 'recordname', 'fqdn', 'name'], + nameCandidates: ['recordname', 'fqdn', 'name', 'id'], + regionCandidates: [], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen'], + lastSeenCandidates: ['lastupdated'], + }, + lambda_function: { + labels: ['LambdaFunction'], + externalIdCandidates: ['arn', 'functionarn', 'id', 'name'], + nameCandidates: ['name', 'functionname', 'id'], + regionCandidates: ['region'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen', 'lastmodified'], + lastSeenCandidates: ['lastupdated', 'lastmodified'], + }, + ecr_repository: { + labels: ['ECRRepository'], + externalIdCandidates: ['arn', 'repositoryuri', 'repositoryname', 'id'], + nameCandidates: ['repositoryname', 'name', 'id'], + regionCandidates: ['region'], + accountCandidates: ['registryid', 'accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen', 'createdat'], + lastSeenCandidates: ['lastupdated'], + }, + ecr_image: { + labels: ['ECRImage', 'ECRRepositoryImage'], + externalIdCandidates: ['arn', 'imageid', 'imagedigest', 'id'], + nameCandidates: ['name', 'imageid', 'imagedigest', 'id'], + regionCandidates: ['region'], + accountCandidates: ['registryid', 'accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen', 'imagepushedat'], + lastSeenCandidates: ['lastupdated'], + }, + eks_cluster: { + labels: ['EKSCluster'], + externalIdCandidates: ['arn', 'name', 'id'], + nameCandidates: ['name', 'id'], + regionCandidates: ['region'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen', 'createdat'], + lastSeenCandidates: ['lastupdated'], + }, + rds_instance: { + labels: ['RDSInstance'], + externalIdCandidates: ['arn', 'dbinstanceidentifier', 'id'], + nameCandidates: ['dbinstanceidentifier', 'name', 'id'], + regionCandidates: ['region'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen', 'instancecreateTime'], + lastSeenCandidates: ['lastupdated'], + }, + rds_cluster: { + labels: ['RDSCluster'], + externalIdCandidates: ['arn', 'dbclusteridentifier', 'id'], + nameCandidates: ['dbclusteridentifier', 'name', 'id'], + regionCandidates: ['region'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen'], + lastSeenCandidates: ['lastupdated'], + }, + cloudfront_distribution: { + labels: ['CloudFrontDistribution'], + externalIdCandidates: ['arn', 'id', 'domainname'], + nameCandidates: ['domainname', 'id'], + regionCandidates: [], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen', 'lastmodifiedtime'], + lastSeenCandidates: ['lastupdated', 'lastmodifiedtime'], + }, + elb_load_balancer: { + labels: ['LoadBalancer', 'ELBv2LoadBalancer', 'ELBLoadBalancer'], + externalIdCandidates: ['arn', 'loadbalancerarn', 'id', 'dnsname'], + nameCandidates: ['name', 'dnsname', 'id'], + regionCandidates: ['region'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen', 'createdtime'], + lastSeenCandidates: ['lastupdated'], + }, + elb_target_group: { + labels: ['TargetGroup', 'ELBTargetGroup'], + externalIdCandidates: ['arn', 'targetgrouparn', 'id', 'name'], + nameCandidates: ['name', 'id'], + regionCandidates: ['region'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen', 'createdtime'], + lastSeenCandidates: ['lastupdated'], + }, + vpc: { + labels: ['AWSVpc', 'VPC'], + externalIdCandidates: ['vpcid', 'arn', 'id'], + nameCandidates: ['name', 'vpcid', 'id'], + regionCandidates: ['region'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen'], + lastSeenCandidates: ['lastupdated'], + }, + vpc_subnet: { + labels: ['EC2Subnet', 'Subnet'], + externalIdCandidates: ['subnetid', 'arn', 'id'], + nameCandidates: ['name', 'subnetid', 'id'], + regionCandidates: ['region', 'availabilityzone'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen'], + lastSeenCandidates: ['lastupdated'], + }, + secretsmanager_secret: { + labels: ['SecretsManagerSecret'], + externalIdCandidates: ['arn', 'name', 'id'], + nameCandidates: ['name', 'id'], + regionCandidates: ['region'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen', 'createddate'], + lastSeenCandidates: ['lastupdated', 'lastchangeddate'], + }, + kms_key: { + labels: ['KMSKey'], + externalIdCandidates: ['arn', 'keyid', 'id'], + nameCandidates: ['aliasname', 'keyid', 'id'], + regionCandidates: ['region'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen', 'creationdate'], + lastSeenCandidates: ['lastupdated'], + }, + ecs_cluster: { + labels: ['ECSCluster'], + externalIdCandidates: ['arn', 'clusterarn', 'id', 'name'], + nameCandidates: ['name', 'clustername', 'id'], + regionCandidates: ['region'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen'], + lastSeenCandidates: ['lastupdated'], + }, + ecs_service: { + labels: ['ECSService'], + externalIdCandidates: ['arn', 'servicearn', 'id', 'name'], + nameCandidates: ['name', 'servicename', 'id'], + regionCandidates: ['region'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen', 'createdat'], + lastSeenCandidates: ['lastupdated'], + }, + acm_certificate: { + labels: ['ACMCertificate'], + externalIdCandidates: ['arn', 'certificatearn', 'id'], + nameCandidates: ['domainname', 'id'], + regionCandidates: ['region'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen', 'createdat'], + lastSeenCandidates: ['lastupdated'], + }, + cloudtrail_trail: { + labels: ['CloudTrailTrail'], + externalIdCandidates: ['arn', 'trailarn', 'name', 'id'], + nameCandidates: ['name', 'trailarn', 'id'], + regionCandidates: ['region', 'homeregion'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen'], + lastSeenCandidates: ['lastupdated'], + }, + guardduty_detector: { + labels: ['GuardDutyDetector'], + externalIdCandidates: ['id', 'detectorid', 'arn'], + nameCandidates: ['detectorid', 'id'], + regionCandidates: ['region'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen', 'createdat'], + lastSeenCandidates: ['lastupdated'], + }, + sns_topic: { + labels: ['SNSTopic'], + externalIdCandidates: ['arn', 'topicarn', 'name', 'id'], + nameCandidates: ['name', 'id'], + regionCandidates: ['region'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen'], + lastSeenCandidates: ['lastupdated'], + }, + sqs_queue: { + labels: ['SQSQueue'], + externalIdCandidates: ['arn', 'queueurl', 'name', 'id'], + nameCandidates: ['name', 'queuename', 'id'], + regionCandidates: ['region'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen', 'createdtimestamp'], + lastSeenCandidates: ['lastupdated', 'lastmodifiedtimestamp'], + }, + dynamodb_table: { + labels: ['DynamoDBTable'], + externalIdCandidates: ['arn', 'tablename', 'id'], + nameCandidates: ['tablename', 'name', 'id'], + regionCandidates: ['region'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen', 'creationdate'], + lastSeenCandidates: ['lastupdated'], + }, + elasticache_cluster: { + labels: ['ElastiCacheCluster'], + externalIdCandidates: ['arn', 'cacheclusterid', 'id'], + nameCandidates: ['cacheclusterid', 'name', 'id'], + regionCandidates: ['region'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen'], + lastSeenCandidates: ['lastupdated'], + }, + apigateway_rest_api: { + labels: ['APIGatewayRestApi'], + externalIdCandidates: ['arn', 'id', 'name'], + nameCandidates: ['name', 'id'], + regionCandidates: ['region'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen', 'createddate'], + lastSeenCandidates: ['lastupdated'], + }, + apigateway_http_api: { + labels: ['APIGatewayHttpApi'], + externalIdCandidates: ['arn', 'id', 'name'], + nameCandidates: ['name', 'id'], + regionCandidates: ['region'], + accountCandidates: ['accountid', 'account_id', 'awsaccountid'], + firstSeenCandidates: ['firstseen', 'createddate'], + lastSeenCandidates: ['lastupdated'], + }, +}; + +const GCP_ASSET_TYPE_CONFIG: Record = { + gcp_organization: { + labels: ['GCPOrganization'], + externalIdCandidates: ['id', 'name', 'displayname'], + nameCandidates: ['displayname', 'name', 'id'], + regionCandidates: [], + accountCandidates: ['id', 'name'], + firstSeenCandidates: ['firstseen', 'creationtime'], + lastSeenCandidates: ['lastupdated'], + }, + gcp_folder: { + labels: ['GCPFolder'], + externalIdCandidates: ['id', 'name', 'displayname'], + nameCandidates: ['displayname', 'name', 'id'], + regionCandidates: [], + accountCandidates: ['id', 'name', 'parent'], + firstSeenCandidates: ['firstseen', 'creationtime'], + lastSeenCandidates: ['lastupdated'], + }, + gcp_project: { + labels: ['GCPProject'], + externalIdCandidates: ['project_id', 'projectid', 'id', 'name'], + nameCandidates: ['displayname', 'project_id', 'projectid', 'name', 'id'], + regionCandidates: [], + accountCandidates: ['project_id', 'projectid', 'id', 'name', 'parent'], + firstSeenCandidates: ['firstseen', 'creationtime'], + lastSeenCandidates: ['lastupdated'], + }, + gcp_compute_instance: { + labels: ['GCPInstance'], + externalIdCandidates: ['id', 'selflink', 'name'], + nameCandidates: ['name', 'id'], + regionCandidates: ['region', 'zone', 'location'], + accountCandidates: ['project_id', 'projectid', 'project', 'name'], + firstSeenCandidates: ['firstseen', 'creationtimestamp'], + lastSeenCandidates: ['lastupdated'], + }, + gcp_storage_bucket: { + labels: ['GCPBucket'], + externalIdCandidates: ['id', 'selflink', 'name'], + nameCandidates: ['name', 'id'], + regionCandidates: ['location', 'region'], + accountCandidates: ['project_id', 'projectid', 'project'], + firstSeenCandidates: ['firstseen', 'timecreated'], + lastSeenCandidates: ['lastupdated', 'updated'], + }, + gcp_service_account: { + labels: ['GCPServiceAccount'], + externalIdCandidates: ['id', 'email', 'uniqueid', 'name'], + nameCandidates: ['email', 'displayname', 'name', 'id'], + regionCandidates: [], + accountCandidates: ['project_id', 'projectid', 'project'], + firstSeenCandidates: ['firstseen', 'oauth2clientid'], + lastSeenCandidates: ['lastupdated'], + }, + gcp_dns_zone: { + labels: ['GCPDNSZone'], + externalIdCandidates: ['id', 'name', 'dnsname'], + nameCandidates: ['name', 'dnsname', 'id'], + regionCandidates: [], + accountCandidates: ['project_id', 'projectid', 'project'], + firstSeenCandidates: ['firstseen'], + lastSeenCandidates: ['lastupdated'], + }, + gcp_gke_cluster: { + labels: ['GKECluster'], + externalIdCandidates: ['id', 'selflink', 'name'], + nameCandidates: ['name', 'id'], + regionCandidates: ['location', 'zone', 'region'], + accountCandidates: ['project_id', 'projectid', 'project'], + firstSeenCandidates: ['firstseen', 'createTime', 'createtime'], + lastSeenCandidates: ['lastupdated', 'updateTime', 'updatetime'], + }, + gcp_cloud_sql_instance: { + labels: ['GCPCloudSQLInstance'], + externalIdCandidates: ['id', 'selflink', 'name'], + nameCandidates: ['name', 'id'], + regionCandidates: ['region', 'location'], + accountCandidates: ['project_id', 'projectid', 'project'], + firstSeenCandidates: ['firstseen', 'createTime', 'createtime'], + lastSeenCandidates: ['lastupdated', 'updateTime', 'updatetime'], + }, + gcp_secret: { + labels: ['GCPSecretManagerSecret', 'GCPSecret'], + externalIdCandidates: ['id', 'name'], + nameCandidates: ['name', 'displayname', 'id'], + regionCandidates: ['location'], + accountCandidates: ['project_id', 'projectid', 'project'], + firstSeenCandidates: ['firstseen', 'createTime', 'createtime'], + lastSeenCandidates: ['lastupdated', 'updateTime', 'updatetime'], + }, + gcp_cloud_run_service: { + labels: ['GCPCloudRunService'], + externalIdCandidates: ['id', 'name', 'selflink'], + nameCandidates: ['name', 'displayname', 'id'], + regionCandidates: ['location', 'region'], + accountCandidates: ['project_id', 'projectid', 'project'], + firstSeenCandidates: ['firstseen', 'createTime', 'createtime'], + lastSeenCandidates: ['lastupdated', 'updateTime', 'updatetime'], + }, + gcp_kms_key_ring: { + labels: ['GCPKeyRing'], + externalIdCandidates: ['id', 'name'], + nameCandidates: ['name', 'id'], + regionCandidates: ['location'], + accountCandidates: ['project_id', 'projectid', 'project'], + firstSeenCandidates: ['firstseen'], + lastSeenCandidates: ['lastupdated'], + }, + gcp_kms_crypto_key: { + labels: ['GCPCryptoKey'], + externalIdCandidates: ['id', 'name'], + nameCandidates: ['name', 'id'], + regionCandidates: ['location'], + accountCandidates: ['project_id', 'projectid', 'project', 'key_ring_id'], + firstSeenCandidates: ['firstseen'], + lastSeenCandidates: ['lastupdated'], + }, +}; + +const CLOUDFLARE_ASSET_TYPE_CONFIG: Record = { + cloudflare_zone: { + labels: ['CloudflareZone'], + externalIdCandidates: ['id', 'name', 'cf_zone_id'], + nameCandidates: ['name', 'id'], + regionCandidates: [], + accountCandidates: [], + firstSeenCandidates: ['firstseen', 'createdon'], + lastSeenCandidates: ['lastupdated'], + }, + cloudflare_dns_record: { + labels: ['CloudflareDNSRecord'], + externalIdCandidates: ['id', 'cf_record_id', 'name'], + nameCandidates: ['name', 'id'], + regionCandidates: [], + accountCandidates: [], + firstSeenCandidates: ['firstseen'], + lastSeenCandidates: ['lastupdated'], + }, +}; + +const LABEL_TO_ASSET_DESCRIPTOR = new Map( + [ + ['aws', AWS_ASSET_TYPE_CONFIG], + ['gcp', GCP_ASSET_TYPE_CONFIG], + ['cloudflare', CLOUDFLARE_ASSET_TYPE_CONFIG], + ].flatMap(([provider, configMap]) => + Object.entries(configMap).flatMap(([assetType, config]) => + config.labels.map( + (label: string) => + [ + label, + { + provider: provider as CloudProvider, + assetType, + config, + }, + ] as const, + ), + ), + ), +); + +const SUPPORTED_LABELS = Array.from(LABEL_TO_ASSET_DESCRIPTOR.keys()); + +const GRAPH_SNAPSHOT_QUERY = ` +MATCH (n) +WHERE any(label IN labels(n) WHERE label IN $labels) +RETURN labels(n) AS labels, properties(n) AS props +`; + +@Injectable() +export class CloudGraphService implements OnModuleDestroy { + private readonly logger = new Logger(CloudGraphService.name); + private driver?: Driver; + + constructor(private readonly configService: ConfigService) {} + + async onModuleDestroy(): Promise { + if (this.driver) { + await this.driver.close(); + this.driver = undefined; + } + } + + getDatabaseName(): string { + return this.configService.get('NEO4J_DATABASE', 'neo4j'); + } + + async listAssets( + organizationId: string, + connectionBindings: CartographySyncConnectionBinding[], + ): Promise { + if (connectionBindings.length === 0) { + return []; + } + + const database = this.getDatabaseName(); + const session = this.getDriver().session({ database }); + const bindingByIdentifier = new Map(); + for (const binding of connectionBindings) { + if (binding.accountIdentifier && !bindingByIdentifier.has(binding.accountIdentifier)) { + bindingByIdentifier.set(binding.accountIdentifier, binding.connectionId); + } + } + + try { + const result = await session.run(GRAPH_SNAPSHOT_QUERY, { labels: SUPPORTED_LABELS }); + return result.records + .map((record: any) => + this.normalizeNode( + organizationId, + toPlainValue(record.get('labels')) as string[], + toPlainValue(record.get('props')) as Record, + ), + ) + .filter((asset: GraphAssetSnapshot | null): asset is GraphAssetSnapshot => asset !== null) + .filter( + (asset) => !!asset.accountIdentifier && bindingByIdentifier.has(asset.accountIdentifier), + ) + .map((asset) => ({ + ...asset, + integrationId: asset.accountIdentifier + ? (bindingByIdentifier.get(asset.accountIdentifier) ?? null) + : null, + })); + } finally { + await session.close(); + } + } + + async getAssetByKey( + organizationId: string, + connectionBindings: CartographySyncConnectionBinding[], + assetType: string, + externalId: string, + ): Promise { + const allAssets = await this.listAssets(organizationId, connectionBindings); + return ( + allAssets.find((asset) => asset.assetType === assetType && asset.externalId === externalId) ?? + null + ); + } + + private getDriver(): Driver { + if (this.driver) { + return this.driver; + } + + const uri = this.configService.get('NEO4J_URI', 'bolt://localhost:7687'); + const user = this.configService.get('NEO4J_USER', ''); + const password = this.configService.get('NEO4J_PASSWORD', ''); + const auth = + user.trim().length > 0 && password.trim().length > 0 + ? neo4j.auth.basic(user, password) + : undefined; + + this.driver = neo4j.driver(uri, auth, { + disableLosslessIntegers: true, + }); + return this.driver; + } + + private normalizeNode( + organizationId: string, + labels: string[], + props: Record, + ): GraphAssetSnapshot | null { + const descriptor = labels + .map((label) => LABEL_TO_ASSET_DESCRIPTOR.get(label)) + .find((candidate): candidate is AssetTypeDescriptor => Boolean(candidate)); + if (!descriptor) { + return null; + } + + const { assetType, config, provider } = descriptor; + const externalId = firstString(props, config.externalIdCandidates) ?? deriveExternalId(props); + if (!externalId) { + return null; + } + + const parsedArn = provider === 'aws' ? parseAwsArn(externalId) : null; + const gcpContext = provider === 'gcp' ? parseGcpResourceContext(externalId) : null; + const region = + normalizeRegion(firstString(props, config.regionCandidates)) ?? + parsedArn?.region ?? + normalizeRegion(gcpContext?.location ?? null); + const accountIdentifier = + provider === 'gcp' + ? normalizeGcpAccountIdentifier( + firstString(props, config.accountCandidates) ?? + gcpContext?.projectId ?? + gcpContext?.folderId ?? + gcpContext?.organizationId ?? + (assetType === 'gcp_project' || + assetType === 'gcp_folder' || + assetType === 'gcp_organization' + ? externalId + : null), + ) + : (firstString(props, config.accountCandidates) ?? parsedArn?.accountId ?? null); + const lastSeenAt = normalizeDate( + firstScalar( + props, + config.lastSeenCandidates ?? [ + 'lastupdated', + 'last_seen_at', + 'lastSeenAt', + 'updatedat', + 'updatedAt', + ], + ), + ); + const firstSeenAt = normalizeDate( + firstScalar( + props, + config.firstSeenCandidates ?? [ + 'firstseen', + 'first_seen_at', + 'firstSeenAt', + 'createdat', + 'createdAt', + 'lastupdated', + ], + ), + ); + + return { + id: buildGraphAssetId(organizationId, assetType, externalId), + organizationId, + integrationId: null, + provider, + assetType, + externalId, + name: firstString(props, config.nameCandidates) ?? null, + region, + accountIdentifier, + metadata: props, + firstSeenAt, + lastSeenAt, + }; + } +} + +function buildGraphAssetId(organizationId: string, assetType: string, externalId: string): string { + const hex = createHash('sha256') + .update(`${organizationId}:${assetType}:${externalId}`) + .digest('hex') + .slice(0, 32); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} + +function firstString(props: Record, candidates: string[]): string | null { + for (const candidate of candidates) { + const value = props[candidate]; + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim(); + } + } + return null; +} + +function firstScalar(props: Record, candidates: string[]): string | null { + for (const candidate of candidates) { + const value = props[candidate]; + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim(); + } + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value); + } + } + return null; +} + +function deriveExternalId(props: Record): string | null { + return ( + firstString(props, ['arn', 'id', 'name']) ?? + firstString(props, ['instanceid', 'bucketarn', 'repositoryuri', 'dbinstanceidentifier']) + ); +} + +function normalizeRegion(value: string | null): string | null { + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + + const zoneMatch = trimmed.match(/^([a-z]{2}-[a-z]+-\d)[a-z]$/); + if (zoneMatch) { + return zoneMatch[1]; + } + return trimmed; +} + +function normalizeDate(value: string | null): string { + if (!value) return new Date(0).toISOString(); + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric > 0) { + return new Date(numeric > 1_000_000_000_000 ? numeric : numeric * 1000).toISOString(); + } + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) { + return new Date(parsed).toISOString(); + } + return new Date(0).toISOString(); +} + +function parseAwsArn(value: string): { accountId: string | null; region: string | null } | null { + if (!value.startsWith('arn:')) { + return null; + } + const parts = value.split(':'); + if (parts.length < 6) { + return null; + } + return { + region: parts[3] || null, + accountId: parts[4] || null, + }; +} + +function parseGcpResourceContext(value: string): { + projectId: string | null; + folderId: string | null; + organizationId: string | null; + location: string | null; +} | null { + const projectMatch = value.match(/(?:^|\/)projects\/([^/]+)/); + const folderMatch = value.match(/(?:^|\/)folders\/([^/]+)/); + const organizationMatch = value.match(/(?:^|\/)organizations\/([^/]+)/); + const locationMatch = value.match(/(?:^|\/)(?:locations|regions|zones)\/([^/]+)/); + + if (!projectMatch && !folderMatch && !organizationMatch && !locationMatch) { + return null; + } + + return { + projectId: projectMatch?.[1] ?? null, + folderId: folderMatch?.[1] ?? null, + organizationId: organizationMatch?.[1] ?? null, + location: locationMatch?.[1] ?? null, + }; +} + +function normalizeGcpAccountIdentifier(value: string | null): string | null { + if (!value) { + return null; + } + + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + const directMatch = + trimmed.match(/^projects\/([^/]+)$/) ?? + trimmed.match(/^folders\/([^/]+)$/) ?? + trimmed.match(/^organizations\/([^/]+)$/); + if (directMatch) { + return directMatch[1]; + } + + const nestedMatch = + trimmed.match(/\/projects\/([^/]+)/) ?? + trimmed.match(/\/folders\/([^/]+)/) ?? + trimmed.match(/\/organizations\/([^/]+)/); + if (nestedMatch) { + return nestedMatch[1]; + } + + return trimmed; +} + +function toPlainValue(value: unknown): unknown { + if (value === null || value === undefined) return value; + + if (neo4j.isInt(value)) { + return neo4j.integer.inSafeRange(value) + ? neo4j.integer.toNumber(value) + : neo4j.integer.toString(value); + } + + if (Array.isArray(value)) { + return value.map(toPlainValue); + } + + if (typeof value === 'object') { + const result: Record = {}; + for (const [key, val] of Object.entries(value as Record)) { + result[key] = toPlainValue(val); + } + return result; + } + + return value; +} diff --git a/backend/src/asset-discovery/dto/asset-discovery-response.dto.ts b/backend/src/asset-discovery/dto/asset-discovery-response.dto.ts new file mode 100644 index 000000000..c93b1fe18 --- /dev/null +++ b/backend/src/asset-discovery/dto/asset-discovery-response.dto.ts @@ -0,0 +1,80 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +export const CloudAssetSchema = z.object({ + id: z.string().uuid(), + organizationId: z.string(), + integrationId: z.string().uuid().nullable(), + provider: z.string(), + accountIdentifier: z.string().nullable(), + assetType: z.string(), + externalId: z.string(), + name: z.string().nullable(), + region: z.string().nullable(), + status: z.string(), + metadata: z.record(z.string(), z.unknown()).nullable(), + firstDiscoveredAt: z.string().datetime(), + lastSeenAt: z.string().datetime(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); +export class CloudAssetDto extends createZodDto(CloudAssetSchema) {} + +export const CloudAssetListResponseSchema = z.object({ + assets: z.array(CloudAssetSchema), + total: z.number(), + page: z.number(), + limit: z.number(), +}); +export class CloudAssetListResponseDto extends createZodDto(CloudAssetListResponseSchema) {} + +const TypeCountSchema = z.object({ assetType: z.string(), count: z.number() }); +const RegionCountSchema = z.object({ region: z.string(), count: z.number() }); +const StatusCountSchema = z.object({ status: z.string(), count: z.number() }); + +export const CloudAssetSummarySchema = z.object({ + totalAssets: z.number(), + byType: z.array(TypeCountSchema), + byRegion: z.array(RegionCountSchema), + byStatus: z.array(StatusCountSchema), +}); +export class CloudAssetSummaryDto extends createZodDto(CloudAssetSummarySchema) {} + +export const CloudSyncTriggerResponseSchema = z.object({ + workflowId: z.string(), + runId: z.string(), +}); +export class CloudSyncTriggerResponseDto extends createZodDto(CloudSyncTriggerResponseSchema) {} + +const SyncRunErrorSchema = z.object({ + message: z.string(), + timestamp: z.string(), +}); + +export const CloudSyncRunSchema = z.object({ + id: z.string().uuid(), + organizationId: z.string(), + integrationId: z.string().uuid(), + accountIdentifier: z.string().nullable(), + temporalWorkflowId: z.string().nullable(), + temporalRunId: z.string().nullable(), + status: z.string(), + coveredRegions: z.array(z.string()).nullable(), + assetsDiscovered: z.number().nullable(), + assetsCreated: z.number().nullable(), + assetsUpdated: z.number().nullable(), + assetsStale: z.number().nullable(), + errors: z.array(SyncRunErrorSchema).nullable(), + startedAt: z.string().datetime(), + completedAt: z.string().datetime().nullable(), + createdAt: z.string().datetime(), +}); +export class CloudSyncRunDto extends createZodDto(CloudSyncRunSchema) {} + +export const CloudSyncRunListResponseSchema = z.object({ + syncRuns: z.array(CloudSyncRunSchema), + total: z.number(), + page: z.number(), + limit: z.number(), +}); +export class CloudSyncRunListResponseDto extends createZodDto(CloudSyncRunListResponseSchema) {} diff --git a/backend/src/asset-discovery/dto/list-assets.dto.ts b/backend/src/asset-discovery/dto/list-assets.dto.ts new file mode 100644 index 000000000..fb3c155cf --- /dev/null +++ b/backend/src/asset-discovery/dto/list-assets.dto.ts @@ -0,0 +1,17 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +export const ListAssetsSchema = z.object({ + provider: z.string().optional(), + assetType: z.string().optional(), + region: z.string().optional(), + status: z.enum(['active', 'stale', 'removed']).optional(), + integrationId: z.string().optional(), + search: z.string().optional(), + page: z.coerce.number().int().min(1).optional(), + offset: z.coerce.number().int().min(0).optional(), + limit: z.coerce.number().int().min(1).max(200).optional(), + sortBy: z.enum(['name', 'lastSeenAt', 'firstDiscoveredAt']).optional(), + sortOrder: z.enum(['asc', 'desc']).optional(), +}); +export class ListAssetsDto extends createZodDto(ListAssetsSchema) {} diff --git a/backend/src/asset-discovery/dto/list-sync-runs.dto.ts b/backend/src/asset-discovery/dto/list-sync-runs.dto.ts new file mode 100644 index 000000000..7f241653f --- /dev/null +++ b/backend/src/asset-discovery/dto/list-sync-runs.dto.ts @@ -0,0 +1,10 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +export const ListSyncRunsSchema = z.object({ + integrationId: z.string().optional(), + page: z.coerce.number().int().min(1).optional(), + offset: z.coerce.number().int().min(0).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), +}); +export class ListSyncRunsDto extends createZodDto(ListSyncRunsSchema) {} diff --git a/backend/src/asset-discovery/dto/sync-assets.dto.ts b/backend/src/asset-discovery/dto/sync-assets.dto.ts new file mode 100644 index 000000000..b76f31272 --- /dev/null +++ b/backend/src/asset-discovery/dto/sync-assets.dto.ts @@ -0,0 +1,93 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +export const TriggerSyncSchema = z.object({ + integrationId: z.string().uuid(), +}); +export class TriggerSyncDto extends createZodDto(TriggerSyncSchema) {} + +const AssetSyncErrorSchema = z.object({ + message: z.string(), + timestamp: z.string().datetime(), +}); + +const AssetSyncRunSchema = z.object({ + id: z.string().uuid(), + organizationId: z.string(), + integrationId: z.string().uuid(), + accountIdentifier: z.string().nullable(), + temporalWorkflowId: z.string().nullable(), + temporalRunId: z.string().nullable(), + status: z.string(), + coveredRegions: z.array(z.string()).nullable(), + assetsDiscovered: z.number().int().nullable(), + assetsCreated: z.number().int().nullable(), + assetsUpdated: z.number().int().nullable(), + assetsStale: z.number().int().nullable(), + errors: z.array(AssetSyncErrorSchema).nullable(), + startedAt: z.string().datetime(), + completedAt: z.string().datetime().nullable(), + createdAt: z.string().datetime(), +}); + +const SyncConnectionBindingSchema = z.object({ + connectionId: z.string().uuid(), + accountIdentifier: z.string().nullable(), +}); + +export const PrepareSyncContextRequestSchema = z.object({ + integrationId: z.string().uuid().optional(), + temporalWorkflowId: z.string().optional(), + temporalRunId: z.string().optional(), +}); +export class PrepareSyncContextRequestDto extends createZodDto(PrepareSyncContextRequestSchema) {} + +export const PrepareSyncContextResponseSchema = z.object({ + syncRunId: z.string().uuid(), + integrationId: z.string().uuid(), + neo4jDatabase: z.string(), + targets: z.array( + z.object({ + connectionId: z.string().uuid(), + displayName: z.string(), + accountIdentifier: z.string().nullable(), + region: z.string().nullable(), + }), + ), + coveredRegions: z.array(z.string()), + connectionBindings: z.array(SyncConnectionBindingSchema), +}); +export class PrepareSyncContextResponseDto extends createZodDto(PrepareSyncContextResponseSchema) {} + +export const RefreshGraphSnapshotRequestSchema = z.object({ + integrationId: z.string().uuid(), + syncRunId: z.string().uuid(), + connectionBindings: z.array(SyncConnectionBindingSchema), +}); +export class RefreshGraphSnapshotRequestDto extends createZodDto( + RefreshGraphSnapshotRequestSchema, +) {} + +export const RefreshGraphSnapshotResponseSchema = z.object({ + assetsDiscovered: z.number().int().nonnegative(), + assetsCreated: z.number().int().nonnegative(), + assetsUpdated: z.number().int().nonnegative(), + assetsStale: z.number().int().nonnegative(), + coveredRegions: z.array(z.string()), +}); +export class RefreshGraphSnapshotResponseDto extends createZodDto( + RefreshGraphSnapshotResponseSchema, +) {} + +export const UpdateSyncRunStatusRequestSchema = z.object({ + status: z.string(), + assetsDiscovered: z.number().int().optional(), + assetsCreated: z.number().int().optional(), + assetsUpdated: z.number().int().optional(), + assetsStale: z.number().int().optional(), + coveredRegions: z.array(z.string()).optional(), + errors: z.array(AssetSyncErrorSchema).optional(), +}); +export class UpdateSyncRunStatusRequestDto extends createZodDto(UpdateSyncRunStatusRequestSchema) {} + +export class UpdateSyncRunStatusResponseDto extends createZodDto(AssetSyncRunSchema) {} diff --git a/backend/src/assets/prowler-checks.json b/backend/src/assets/prowler-checks.json new file mode 100644 index 000000000..4f5c7b7dc --- /dev/null +++ b/backend/src/assets/prowler-checks.json @@ -0,0 +1,10559 @@ +{ + "network_vpc_subnet_enable_dhcp": { + "checkTitle": "Check if DHCP is enabled for subnets in VPC", + "recommendation": "Ensure that DHCP is enabled for all subnets where automatic IP address allocation is needed.", + "recommendationUrl": null, + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "network_vpc_has_empty_routingtables": { + "checkTitle": "Check if VPC has empty routing tables", + "recommendation": "Ensure that VPC has properly configured routing tables with necessary routes to ensure proper network connectivity. If not needed, delete the empty routing tables.", + "recommendationUrl": null, + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "network_vpc_subnet_has_external_router": { + "checkTitle": "Check for External Router in NHN VPC Subnet", + "recommendation": "Review the external router settings for the VPC Subnet.", + "recommendationUrl": null, + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "compute_instance_public_ip": { + "checkTitle": "Check for Virtual Machine Instances with Public IP Addresses", + "recommendation": "Ensure that your Google Compute Engine instances are not configured to have external IP addresses in order to minimize their exposure to the Internet.", + "recommendationUrl": "https://cloud.google.com/compute/docs/instances/connecting-to-instance", + "cli": null, + "nativeIaC": null, + "terraform": "https://docs.prowler.com/checks/gcp/google-cloud-public-policies/bc_gcp_public_2#terraform", + "other": "https://docs.prowler.com/checks/gcp/google-cloud-public-policies/bc_gcp_public_2" + }, + "compute_instance_login_user": { + "checkTitle": "Check for Administrative Login Users in NHN Compute Instances", + "recommendation": "Review the login users configured for each VM instance.", + "recommendationUrl": null, + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "compute_instance_security_groups": { + "checkTitle": "Check NHN Compute Security Group Configuration", + "recommendation": "Review and modify security group rules for each VM instance.", + "recommendationUrl": null, + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "etcd_no_auto_tls": { + "checkTitle": "Etcd pod has --auto-tls disabled", + "recommendation": "Disable `--auto-tls` and use **CA-signed certificates** with **mutual TLS** for etcd clients. Apply managed PKI to enforce trusted CAs, rotate and revoke keys, and prefer modern TLS versions and strong cipher suites. Monitor certificate expiry and limit access per **least privilege** for **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/etcd_no_auto_tls", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. SSH to the control plane node running etcd\n2. Edit the static Pod manifest: sudo vi /etc/kubernetes/manifests/etcd.yaml\n3. In containers -> command or args, remove any occurrence of --auto-tls or --auto-tls=true (do not set it to false)\n4. Save and exit; kubelet will recreate the etcd pod automatically\n5. Verify the flag is absent: kubectl -n kube-system get pod -l component=etcd -o yaml | grep -q \"auto-tls\" || echo \"PASS: --auto-tls not set\"" + }, + "etcd_unique_ca": { + "checkTitle": "Etcd pod uses a unique Certificate Authority distinct from the Kubernetes API server CA", + "recommendation": "Adopt a **separate PKI** for etcd: issue client and peer certs from an etcd-only CA and trust only that CA. Enforce mTLS (`--client-cert-auth`, `--peer-client-cert-auth`), avoid `--auto-tls`, rotate keys independently, and apply **least privilege** to CA issuance with regular certificate audits.", + "recommendationUrl": "https://hub.prowler.com/check/etcd_unique_ca", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. SSH to a control-plane node that runs etcd\n2. Open the API server manifest: sudo vi /etc/kubernetes/manifests/kube-apiserver.yaml and note the value of --client-ca-file=\n3. Ensure an etcd-specific CA file exists at a different path (for example: /etc/kubernetes/pki/etcd/ca.crt) and is readable by the etcd container\n4. Edit the etcd manifest: sudo vi /etc/kubernetes/manifests/etcd.yaml\n - In the etcd container command/args, add or update: --trusted-ca-file=/etc/kubernetes/pki/etcd/ca.crt (this path must NOT equal )\n - Save the file; the kubelet will restart the etcd pod automatically\n5. Verify the change: kubectl -n kube-system get pods -o wide | grep etcd, then describe the etcd pod and confirm --trusted-ca-file points to a different path than the API server --client-ca-file" + }, + "etcd_tls_encryption": { + "checkTitle": "Etcd pod has TLS encryption configured", + "recommendation": "Enforce **mTLS** for etcd client and peer traffic and disable plaintext listeners. Restrict access to etcd to control-plane components via tight network policies and firewalls. Use strong TLS versions/ciphers, rotate certificates, and safeguard keys, applying **least privilege** and **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/etcd_tls_encryption", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. SSH to the control-plane node\n2. Open /etc/kubernetes/manifests/etcd.yaml\n3. In spec.containers[0].command add:\n - --cert-file=/etc/kubernetes/pki/etcd/server.crt\n - --key-file=/etc/kubernetes/pki/etcd/server.key\n4. Save the file; kubelet will automatically restart the etcd Pod\n5. Confirm the etcd container command now includes both --cert-file and --key-file" + }, + "etcd_peer_client_cert_auth": { + "checkTitle": "Etcd pod has peer client certificate authentication enabled", + "recommendation": "Enforce **mTLS** for etcd peers with client certificate auth. Use a dedicated CA, validate SANs, and apply **least privilege** to issued certs. Rotate and revoke certificates regularly, restrict network access to peer ports, and avoid auto-generated self-signed peer TLS to maintain strong identity assurance.", + "recommendationUrl": "https://hub.prowler.com/check/etcd_peer_client_cert_auth", + "cli": null, + "nativeIaC": null, + "terraform": "```hcl\nresource \"kubernetes_pod\" \"\" {\n metadata {\n name = \"\"\n namespace = \"kube-system\"\n }\n spec {\n container {\n name = \"etcd\"\n image = \"registry.k8s.io/etcd:3.5.12-0\"\n command = [\n \"etcd\",\n \"--peer-client-cert-auth=true\" # Critical: enables peer client certificate authentication for peer traffic\n ]\n }\n }\n}\n```", + "other": "1. SSH to the control-plane node\n2. Edit the etcd static Pod manifest: /etc/kubernetes/manifests/etcd.yaml\n3. In spec.containers[0].command, add this entry:\n - --peer-client-cert-auth=true\n (Critical: enables peer client certificate authentication)\n4. Save the file; the kubelet will automatically restart the etcd Pod\n5. Verify the Pod's container command includes --peer-client-cert-auth=true" + }, + "etcd_client_cert_auth": { + "checkTitle": "Etcd pod has client certificate authentication enabled (--client-cert-auth=true)", + "recommendation": "Enforce **mutual TLS** for etcd clients by requiring validated certificates (`--client-cert-auth=true`) issued by a trusted CA.\n\nRestrict network access to etcd to API servers, rotate keys regularly, and apply **least privilege** and **separation of duties** for certificate management.", + "recommendationUrl": "https://hub.prowler.com/check/etcd_client_cert_auth", + "cli": null, + "nativeIaC": null, + "terraform": "```hcl\n# Enable client certificate authentication on etcd\nresource \"kubernetes_pod\" \"\" {\n metadata {\n name = \"\"\n namespace = \"kube-system\"\n }\n spec {\n container {\n name = \"etcd\"\n image = \"gcr.io/etcd-development/etcd:v3.5.13\"\n command = [\n \"etcd\",\n \"--client-cert-auth=true\" # Critical: enables client cert auth to pass the check\n ]\n }\n }\n}\n```", + "other": "1. SSH to the control plane node that runs etcd\n2. Edit the static pod manifest: /etc/kubernetes/manifests/etcd.yaml\n3. Under spec.containers[0].command (or args), add:\n ```\n - --client-cert-auth=true # Critical: enables client certificate authentication\n ```\n4. Save the file; kubelet will restart the etcd pod automatically\n5. Repeat on each control-plane node hosting an etcd pod" + }, + "etcd_peer_tls_config": { + "checkTitle": "Etcd pod uses TLS for peer connections", + "recommendation": "Enforce **TLS** for etcd peer communication with unique certificates per member and mutual authentication. Apply strong cipher suites and modern protocol versions, rotate keys, and separate CAs for peers and clients. Limit network access to peer ports to trusted nodes, following **least privilege** and **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/etcd_peer_tls_config", + "cli": null, + "nativeIaC": null, + "terraform": "```hcl\nresource \"kubernetes_pod\" \"\" {\n metadata {\n name = \"\"\n }\n spec {\n container {\n name = \"etcd\"\n image = \"quay.io/coreos/etcd:latest\"\n command = [\n \"etcd\",\n \"--peer-cert-file=\", # Critical: enables TLS for peer connections\n \"--peer-key-file=\" # Critical: key for the peer TLS cert\n ]\n }\n }\n}\n```", + "other": "1. SSH to the control plane node running etcd\n2. Open /etc/kubernetes/manifests/etcd.yaml\n3. Under spec.containers[0].command add:\n - --peer-cert-file=\n - --peer-key-file=\n4. Save the file; kubelet will restart the etcd Pod automatically\n5. Verify the etcd container command includes both flags" + }, + "etcd_no_peer_auto_tls": { + "checkTitle": "Etcd pod does not use automatically generated self-signed certificates for peer TLS connections", + "recommendation": "Disable `--peer-auto-tls` and use **mTLS** with a trusted CA issuing unique per-member peer certificates. Enforce SAN validation and, *where supported*, peer certificate authentication. Apply **least privilege**, separate CAs for peers/clients, rotate keys, and monitor certificate expiry and peer membership.", + "recommendationUrl": "https://hub.prowler.com/check/etcd_no_peer_auto_tls", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. SSH to the control-plane node running etcd\n2. Open /etc/kubernetes/manifests/etcd.yaml\n3. In the etcd container args/command, remove any entry that starts with --peer-auto-tls\n4. Save the file; the kubelet will restart etcd automatically" + }, + "scheduler_profiling": { + "checkTitle": "Ensure that the --profiling argument is set to false", + "recommendation": "To minimize exposure to performance data and potential vulnerabilities, ensure the --profiling argument in the Kubernetes Scheduler is set to false.", + "recommendationUrl": "https://kubernetes.io/docs/admin/kube-scheduler/", + "cli": "--profiling=false", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-profiling-argument-is-set-to-false-2", + "terraform": null, + "other": null + }, + "scheduler_bind_address": { + "checkTitle": "Ensure that the --bind-address argument is set to 127.0.0.1 for the Scheduler", + "recommendation": "Bind the Scheduler to the loopback address for enhanced security.", + "recommendationUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-scheduler/", + "cli": "--bind-address=127.0.0.1", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-bind-address-argument-is-set-to-127001-1", + "terraform": null, + "other": null + }, + "controllermanager_service_account_private_key_file": { + "checkTitle": "Ensure that the --service-account-private-key-file argument is set as appropriate", + "recommendation": "Configure the Controller Manager with a private key file for service accounts to maintain security and enable token rotation.", + "recommendationUrl": "https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/#token-controller", + "cli": "--service-account-private-key-file=/path/to/sa-key-file", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-service-account-private-key-file-argument-is-set-as-appropriate", + "terraform": null, + "other": null + }, + "controllermanager_root_ca_file_set": { + "checkTitle": "Ensure that the --root-ca-file argument is set as appropriate", + "recommendation": "Configure the Controller Manager with a root CA file to enhance security for pods communicating with the API server.", + "recommendationUrl": "https://kubernetes.io/docs/setup/best-practices/certificates/#certificate-paths", + "cli": "--root-ca-file=/path/to/ca-file", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-root-ca-file-argument-is-set-as-appropriate", + "terraform": null, + "other": null + }, + "controllermanager_rotate_kubelet_server_cert": { + "checkTitle": "Ensure that the RotateKubeletServerCertificate argument is set to true", + "recommendation": "Enable kubelet server certificate rotation in the Controller Manager for automated certificate management.", + "recommendationUrl": "https://kubernetes.io/docs/tasks/tls/certificate-rotation/#understanding-the-certificate-rotation-configuration", + "cli": "--feature-gates='RotateKubeletServerCertificate=true'", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-rotatekubeletservercertificate-argument-is-set-to-true-for-controller-manager#kubernetes", + "terraform": null, + "other": null + }, + "controllermanager_bind_address": { + "checkTitle": "Ensure that the --bind-address argument is set to 127.0.0.1", + "recommendation": "Bind the Controller Manager to the loopback address for enhanced security.", + "recommendationUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-controller-manager/", + "cli": "--bind-address=127.0.0.1", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-bind-address-argument-is-set-to-127001", + "terraform": null, + "other": null + }, + "controllermanager_garbage_collection": { + "checkTitle": "Ensure that the --terminated-pod-gc-threshold argument is set as appropriate", + "recommendation": "Review and adjust the --terminated-pod-gc-threshold argument in the kube-controller-manager to ensure efficient garbage collection and optimal resource utilization.", + "recommendationUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-controller-manager/", + "cli": "--terminated-pod-gc-threshold=10", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-terminated-pod-gc-threshold-argument-is-set-as-appropriate", + "terraform": null, + "other": null + }, + "controllermanager_disable_profiling": { + "checkTitle": "Ensure that the --profiling argument is set to false", + "recommendation": "Disable profiling in the Kubernetes Controller Manager for enhanced security.", + "recommendationUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-controller-manager/#options", + "cli": "--profiling=false", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-profiling-argument-is-set-to-false", + "terraform": null, + "other": null + }, + "controllermanager_service_account_credentials": { + "checkTitle": "Ensure that the --use-service-account-credentials argument is set to true", + "recommendation": "Configure the Controller Manager to use individual service account credentials for enhanced security and role separation.", + "recommendationUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-controller-manager/#options", + "cli": "--use-service-account-credentials=true", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-use-service-account-credentials-argument-is-set-to-true", + "terraform": null, + "other": null + }, + "apiserver_kubelet_cert_auth": { + "checkTitle": "Ensure that the --kubelet-certificate-authority argument is set as appropriate", + "recommendation": "Enable TLS verification between the apiserver and kubelets by specifying the certificate authority in the kube-apiserver configuration.", + "recommendationUrl": "https://kubernetes.io/docs/setup/best-practices/certificates/#configure-certificates-manually", + "cli": "--kubelet-certificate-authority=/path/to/ca-file", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-kubelet-certificate-authority-argument-is-set-as-appropriate", + "terraform": null, + "other": null + }, + "apiserver_auth_mode_not_always_allow": { + "checkTitle": "Ensure that the --authorization-mode argument is not set to AlwaysAllow", + "recommendation": "Ensure the API server is using a secure authorization mode, such as RBAC, and not set to AlwaysAllow.", + "recommendationUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "cli": "--authorization-mode=RBAC", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-authorization-mode-argument-is-not-set-to-alwaysallow", + "terraform": null, + "other": null + }, + "apiserver_kubelet_tls_auth": { + "checkTitle": "Ensure that the --kubelet-client-certificate and --kubelet-client-key arguments are set as appropriate", + "recommendation": "Enable TLS authentication between the apiserver and kubelets by specifying the client certificate and key in the kube-apiserver configuration.", + "recommendationUrl": "https://kubernetes.io/docs/setup/best-practices/certificates/#configure-certificates-manually", + "cli": "--kubelet-client-certificate=/path/to/client-certificate-file --kubelet-client-key=/path/to/client-key-file", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-kubelet-client-certificate-and-kubelet-client-key-arguments-are-set-as-appropriate", + "terraform": null, + "other": null + }, + "apiserver_auth_mode_include_rbac": { + "checkTitle": "Ensure that the --authorization-mode argument includes RBAC", + "recommendation": "Ensure that the API server is configured with RBAC authorization mode for enhanced security and access control.", + "recommendationUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "cli": "--authorization-mode=Node,RBAC", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-authorization-mode-argument-includes-rbac", + "terraform": null, + "other": null + }, + "apiserver_service_account_key_file_set": { + "checkTitle": "Ensure that the --service-account-key-file argument is set as appropriate", + "recommendation": "Specify a separate public key file for verifying service account tokens in pod {pod.name}.", + "recommendationUrl": "https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#serviceaccount-token-volume-projection", + "cli": "--service-account-key-file=", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-service-account-key-file-argument-is-set-as-appropriate", + "terraform": null, + "other": null + }, + "apiserver_anonymous_requests": { + "checkTitle": "Ensure that the --anonymous-auth argument is set to false", + "recommendation": "Ensure the --anonymous-auth argument in the API server is set to false. This will reject all anonymous requests, enforcing authenticated access to the server.", + "recommendationUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "cli": "--anonymous-auth=false", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-anonymous-auth-argument-is-set-to-false-1#kubernetes", + "terraform": null, + "other": null + }, + "apiserver_etcd_cafile_set": { + "checkTitle": "Ensure that the --etcd-cafile argument is set as appropriate", + "recommendation": "Ensure etcd connections from the API server are secured using the appropriate CA file.", + "recommendationUrl": "https://kubernetes.io/docs/tasks/administer-cluster/configure-upgrade-etcd/#limiting-access-of-etcd-clusters", + "cli": "--etcd-cafile=", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-etcd-cafile-argument-is-set-as-appropriate-1", + "terraform": null, + "other": null + }, + "apiserver_audit_log_maxbackup_set": { + "checkTitle": "Ensure that the --audit-log-maxbackup argument is set to 10 or as appropriate", + "recommendation": "Configure the API server audit log backup retention to 10 or as per your organization's requirements.", + "recommendationUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "cli": "--audit-log-maxbackup=10", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-audit-log-maxbackup-argument-is-set-to-10-or-as-appropriate#kubernetes", + "terraform": null, + "other": null + }, + "apiserver_always_pull_images_plugin": { + "checkTitle": "Ensure that the admission control plugin AlwaysPullImages is set", + "recommendation": "Configure the API server to use the AlwaysPullImages admission control plugin to ensure image security and integrity.", + "recommendationUrl": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers", + "cli": "--enable-admission-plugins=...,AlwaysPullImages,...", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-admission-control-plugin-alwayspullimages-is-set#kubernetes", + "terraform": null, + "other": null + }, + "apiserver_node_restriction_plugin": { + "checkTitle": "Ensure that the admission control plugin NodeRestriction is set", + "recommendation": "Enable the NodeRestriction admission control plugin in the API server for enhanced node and pod security.", + "recommendationUrl": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#noderestriction", + "cli": "--enable-admission-plugins=...,NodeRestriction,...", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-admission-control-plugin-noderestriction-is-set", + "terraform": null, + "other": null + }, + "apiserver_audit_log_path_set": { + "checkTitle": "Ensure that the --audit-log-path argument is set", + "recommendation": "Enable audit logging in the API server by specifying a valid path for --audit-log-path to ensure comprehensive activity logging within the cluster.", + "recommendationUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "cli": "--audit-log-path=/var/log/apiserver/audit.log", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-audit-log-path-argument-is-set#kubernetes", + "terraform": null, + "other": null + }, + "apiserver_encryption_provider_config_set": { + "checkTitle": "Ensure that the --encryption-provider-config argument is set as appropriate", + "recommendation": "Configure and enable encryption for data at rest in etcd using a suitable EncryptionConfig file.", + "recommendationUrl": "https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/#determining-whether-encryption-at-rest-is-already-enabled", + "cli": "--encryption-provider-config=/path/to/EncryptionConfig/File", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "apiserver_namespace_lifecycle_plugin": { + "checkTitle": "Ensure that the admission control plugin NamespaceLifecycle is set", + "recommendation": "Enable the NamespaceLifecycle admission control plugin in the API server to enforce proper namespace management.", + "recommendationUrl": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#namespacelifecycle", + "cli": "--enable-admission-plugins=...,NamespaceLifecycle,...", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-admission-control-plugin-namespacelifecycle-is-set", + "terraform": null, + "other": null + }, + "apiserver_service_account_lookup_true": { + "checkTitle": "Ensure that the --service-account-lookup argument is set to true", + "recommendation": "Enable service account lookup in the API server to ensure that only existing service accounts are used for authentication.", + "recommendationUrl": "https://kubernetes.io/docs/reference/access-authn-authz/authentication/#service-account-tokens", + "cli": "--service-account-lookup=true", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-service-account-lookup-argument-is-set-to-true", + "terraform": null, + "other": null + }, + "apiserver_strong_ciphers_only": { + "checkTitle": "Ensure that the API Server only makes use of Strong Cryptographic Ciphers", + "recommendation": "Restrict the API server to only use strong cryptographic ciphers for enhanced security.", + "recommendationUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/#options", + "cli": "--tls-cipher-suites=TLS_AES_128_GCM_SHA256,...", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-kubelet-only-makes-use-of-strong-cryptographic-ciphers#kubernetes", + "terraform": null, + "other": null + }, + "apiserver_event_rate_limit": { + "checkTitle": "Ensure that the admission control plugin EventRateLimit is set", + "recommendation": "Configure EventRateLimit as an admission control plugin for the API server to manage the rate of incoming events effectively.", + "recommendationUrl": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#eventratelimit", + "cli": "--enable-admission-plugins=...,EventRateLimit,... --admission-control-config-file=/path/to/configuration/file", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-admission-control-plugin-eventratelimit-is-set", + "terraform": null, + "other": null + }, + "apiserver_auth_mode_include_node": { + "checkTitle": "Ensure that the --authorization-mode argument includes Node", + "recommendation": "Configure the API server to use Node authorization mode along with other modes like RBAC to restrict kubelet access to the necessary resources.", + "recommendationUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "cli": "--authorization-mode=Node,RBAC", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-authorization-mode-argument-includes-node", + "terraform": null, + "other": null + }, + "apiserver_etcd_tls_config": { + "checkTitle": "Ensure that the --etcd-certfile and --etcd-keyfile arguments are set as appropriate", + "recommendation": "Enable TLS encryption for etcd client connections to secure sensitive data.", + "recommendationUrl": "https://kubernetes.io/docs/tasks/administer-cluster/configure-upgrade-etcd/#limiting-access-of-etcd-clusters", + "cli": "--etcd-certfile= --etcd-keyfile=", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-etcd-certfile-and-etcd-keyfile-arguments-are-set-as-appropriate", + "terraform": null, + "other": null + }, + "apiserver_deny_service_external_ips": { + "checkTitle": "Ensure that the DenyServiceExternalIPs is set", + "recommendation": "Enable the DenyServiceExternalIPs admission controller by setting the '--disable-admission-plugins' argument in the kube-apiserver configuration.", + "recommendationUrl": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#how-do-i-turn-off-an-admission-controller", + "cli": "--disable-admission-plugins=DenyServiceExternalIPs", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "apiserver_request_timeout_set": { + "checkTitle": "Ensure that the --request-timeout argument is set as appropriate", + "recommendation": "Set the API server request timeout to a value that balances resource usage efficiency and the needs of your environment, considering connection speeds and data volumes.", + "recommendationUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/#options", + "cli": "--request-timeout=300s", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-request-timeout-argument-is-set-as-appropriate", + "terraform": null, + "other": null + }, + "apiserver_audit_log_maxsize_set": { + "checkTitle": "Ensure that the --audit-log-maxsize argument is set to 100 or as appropriate", + "recommendation": "Configure the API server audit log file size limit to 100 MB or as per your organization's requirements.", + "recommendationUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "cli": "--audit-log-maxsize=100", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-audit-log-maxsize-argument-is-set-to-100-or-as-appropriate#kubernetes", + "terraform": null, + "other": null + }, + "apiserver_no_always_admit_plugin": { + "checkTitle": "Ensure that the admission control plugin AlwaysAdmit is not set", + "recommendation": "Ensure the API server does not use the AlwaysAdmit admission control plugin to maintain proper security checks for all requests.", + "recommendationUrl": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#alwaysadmit", + "cli": "--disable-admission-plugins=...,AlwaysAdmit,...", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-admission-control-plugin-alwaysadmit-is-not-set", + "terraform": null, + "other": null + }, + "apiserver_disable_profiling": { + "checkTitle": "Ensure that the --profiling argument is set to false", + "recommendation": "Disable profiling in the API server unless it is necessary for troubleshooting performance bottlenecks.", + "recommendationUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "cli": "--profiling=false", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-profiling-argument-is-set-to-false-2", + "terraform": null, + "other": null + }, + "apiserver_audit_log_maxage_set": { + "checkTitle": "Ensure that the --audit-log-maxage argument is set to 30 or as appropriate", + "recommendation": "Configure the API server audit log retention period to retain logs for at least 30 days or as per your organization's requirements.", + "recommendationUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "cli": "--audit-log-maxage=30", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-audit-log-maxage-argument-is-set-to-30-or-as-appropriate#kubernetes", + "terraform": null, + "other": null + }, + "apiserver_service_account_plugin": { + "checkTitle": "Ensure that the admission control plugin ServiceAccount is set", + "recommendation": "Enable the ServiceAccount admission control plugin in the API server to manage service accounts and tokens securely.", + "recommendationUrl": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#serviceaccount", + "cli": "--enable-admission-plugins=...,ServiceAccount,...", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-admission-control-plugin-serviceaccount-is-set", + "terraform": null, + "other": null + }, + "apiserver_client_ca_file_set": { + "checkTitle": "Ensure that the --client-ca-file argument is set as appropriate", + "recommendation": "Ensure the API server is configured with a client CA file for secure client authentication.", + "recommendationUrl": "https://kubernetes.io/docs/setup/best-practices/certificates/#certificate-paths", + "cli": "--client-ca-file=", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-client-ca-file-argument-is-set-as-appropriate-scored", + "terraform": null, + "other": null + }, + "apiserver_no_token_auth_file": { + "checkTitle": "Ensure that the --token-auth-file parameter is not set", + "recommendation": "Replace token-based authentication with more secure mechanisms like client certificate authentication. Ensure the --token-auth-file argument is not used in the API server configuration.", + "recommendationUrl": "https://kubernetes.io/docs/reference/access-authn-authz/authentication/#static-token-file", + "cli": null, + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-token-auth-file-parameter-is-not-set", + "terraform": null, + "other": null + }, + "apiserver_tls_config": { + "checkTitle": "Ensure that the --tls-cert-file and --tls-private-key-file arguments are set as appropriate", + "recommendation": "Ensure TLS is enabled and properly configured for the API server to secure communications.", + "recommendationUrl": "https://kubernetes.io/docs/setup/best-practices/certificates/#certificate-paths", + "cli": "--tls-cert-file= --tls-private-key-file=", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-tls-cert-file-and-tls-private-key-file-arguments-are-set-as-appropriate", + "terraform": null, + "other": null + }, + "apiserver_security_context_deny_plugin": { + "checkTitle": "Ensure that the admission control plugin SecurityContextDeny is set if PodSecurityPolicy is not used", + "recommendation": "Use SecurityContextDeny as an admission control plugin in the API server to enhance security, especially in the absence of PodSecurityPolicy.", + "recommendationUrl": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#securitycontextdeny", + "cli": "--enable-admission-plugins=...,SecurityContextDeny,...", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-admission-control-plugin-securitycontextdeny-is-set-if-podsecuritypolicy-is-not-used", + "terraform": null, + "other": null + }, + "rbac_minimize_csr_approval_access": { + "checkTitle": "Minimize access to the approval sub-resource of certificatesigningrequests objects", + "recommendation": "Restrict access to the approval sub-resource of CSR objects in the cluster.", + "recommendationUrl": "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#csrs-and-certificate-issuing", + "cli": null, + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-clusterroles-that-grant-permissions-to-approve-certificatesigningrequests-are-minimized", + "terraform": null, + "other": null + }, + "rbac_minimize_pv_creation_access": { + "checkTitle": "Minimize access to create persistent volumes", + "recommendation": "Restrict access to create persistent volumes in the cluster.", + "recommendationUrl": "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#persistent-volume-creation", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "rbac_minimize_webhook_config_access": { + "checkTitle": "Minimize access to webhook configuration objects", + "recommendation": "Restrict access to webhook configuration objects in the cluster.", + "recommendationUrl": "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#control-admission-webhooks", + "cli": null, + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-clusterroles-that-grant-control-over-validating-or-mutating-admission-webhook-configurations-are-minimized", + "terraform": null, + "other": null + }, + "rbac_minimize_pod_creation_access": { + "checkTitle": "Minimize access to create pods", + "recommendation": "Restrict pod creation access to minimize security risks.", + "recommendationUrl": "https://kubernetes.io/docs/reference/access-authn-authz/rbac/#role-and-clusterrole", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "rbac_minimize_wildcard_use_roles": { + "checkTitle": "Minimize wildcard use in Roles and ClusterRoles", + "recommendation": "Replace wildcards in roles and clusterroles with specific permissions.", + "recommendationUrl": "https://kubernetes.io/docs/reference/access-authn-authz/rbac/#referring-to-resources", + "cli": null, + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-minimized-wildcard-use-in-roles-and-clusterroles", + "terraform": null, + "other": null + }, + "rbac_minimize_service_account_token_creation": { + "checkTitle": "Minimize access to the service account token creation", + "recommendation": "Restrict access to service account token creation in the cluster.", + "recommendationUrl": "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#token-request", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "rbac_minimize_secret_access": { + "checkTitle": "Minimize access to secrets", + "recommendation": "Restrict access to Kubernetes secrets to the smallest possible set of users.", + "recommendationUrl": "https://kubernetes.io/docs/reference/access-authn-authz/rbac/", + "cli": null, + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/no-serviceaccountnode-should-be-able-to-read-all-secrets", + "terraform": null, + "other": null + }, + "rbac_minimize_node_proxy_subresource_access": { + "checkTitle": "Minimize access to the proxy sub-resource of nodes", + "recommendation": "Restrict access to the proxy sub-resource of node objects in the cluster.", + "recommendationUrl": "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#access-to-proxy-subresource-of-nodes", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "rbac_cluster_admin_usage": { + "checkTitle": "Ensure that the cluster-admin role is only used where required", + "recommendation": "Audit and assess the use of 'cluster-admin' role in all ClusterRoleBindings. Ensure it is assigned only to subjects that require such extensive privileges. Consider using more restrictive roles wherever possible.", + "recommendationUrl": "https://kubernetes.io/docs/reference/access-authn-authz/rbac/#clusterrolebinding-example", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "kubelet_service_file_permissions": { + "checkTitle": "Ensure that the kubelet service file permissions are set to 600 or more restrictive", + "recommendation": "Ensure the kubelet service file is securely configured with restrictive permissions.", + "recommendationUrl": "https://kubernetes.io/docs/setup/independent/create-cluster-kubeadm/#44-joining-your-nodes", + "cli": "chmod 600 /etc/systemd/system/kubelet.service.d/kubeadm.conf", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "kubelet_client_ca_file_set": { + "checkTitle": "Ensure that the kubelet --client-ca-file argument is set as appropriate", + "recommendation": "Configure Kubelet with a client CA file for secure authentication.", + "recommendationUrl": "https://kubernetes.io/docs/reference/access-authn-authz/kubelet-authn-authz/#kubelet-authorization", + "cli": "--client-ca-file=/path/to/ca-file", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "kubelet_event_record_qps": { + "checkTitle": "Ensure that the kubelet eventRecordQPS argument is set to an appropriate level", + "recommendation": "Configure kubelet with a balanced eventRecordQPS setting for effective event capture without causing DoS conditions.", + "recommendationUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/#options", + "cli": "--event-qps=", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-event-qps-argument-is-set-to-0-or-a-level-which-ensures-appropriate-event-capture", + "terraform": null, + "other": null + }, + "kubelet_disable_read_only_port": { + "checkTitle": "Verify that the kubelet --read-only-port argument is set to 0", + "recommendation": "Disable the read-only port in the kubelet for enhanced cluster security.", + "recommendationUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/#options", + "cli": "--read-only-port=0", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-read-only-port-argument-is-set-to-0", + "terraform": null, + "other": null + }, + "kubelet_config_yaml_ownership": { + "checkTitle": "Validate kubelet config.yaml File Ownership", + "recommendation": "Secure the kubelet configuration by enforcing strict file ownership.", + "recommendationUrl": "https://kubernetes.io/docs/tasks/administer-cluster/kubelet-config-file/", + "cli": "chown root:root /var/lib/kubelet/config.yaml", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "kubelet_streaming_connection_timeout": { + "checkTitle": "Ensure that the kubelet --streaming-connection-idle-timeout argument is not set to 0", + "recommendation": "Configure a non-zero timeout for streaming connections in kubelet to enhance node security.", + "recommendationUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/#options", + "cli": "--streaming-connection-idle-timeout=", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-streaming-connection-idle-timeout-argument-is-not-set-to-0", + "terraform": null, + "other": null + }, + "kubelet_service_file_ownership_root": { + "checkTitle": "Ensure that the kubelet service file ownership is set to root:root", + "recommendation": "Set the kubelet service file ownership to root:root to maintain its integrity.", + "recommendationUrl": "https://kubernetes.io/docs/reference/setup-tools/kubeadm/kubeadm-config/", + "cli": "chown root:root /etc/systemd/system/kubelet.service.d/kubeadm.conf", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "kubelet_config_yaml_permissions": { + "checkTitle": "Validate kubelet config.yaml File Permissions", + "recommendation": "Secure the kubelet configuration by enforcing strict file permissions.", + "recommendationUrl": "https://kubernetes.io/docs/tasks/administer-cluster/kubelet-config-file/", + "cli": "chmod 600 /var/lib/kubelet/config.yaml", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "kubelet_strong_ciphers_only": { + "checkTitle": "Ensure that the Kubelet only makes use of Strong Cryptographic Ciphers", + "recommendation": "Restrict the kubelet to only use strong cryptographic ciphers for enhanced security.", + "recommendationUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/#options", + "cli": "--tls-cipher-suites=TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,...", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-kubelet-only-makes-use-of-strong-cryptographic-ciphers", + "terraform": null, + "other": null + }, + "kubelet_authorization_mode": { + "checkTitle": "Ensure that the kubelet --authorization-mode argument is not set to AlwaysAllow", + "recommendation": "Ensure kubelet is configured with an authorization mode other than AlwaysAllow.", + "recommendationUrl": "https://kubernetes.io/docs/reference/access-authn-authz/kubelet-authn-authz/#kubelet-authorization", + "cli": "--authorization-mode=Webhook", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "kubelet_conf_file_permissions": { + "checkTitle": "Ensure kubelet.conf file permissions are set to 600 or more restrictive", + "recommendation": "Ensure kubelet.conf file permissions are correctly set to protect the node's configuration.", + "recommendationUrl": "https://kubernetes.io/docs/tasks/administer-cluster/kubelet-config-file/", + "cli": "chmod 600 /etc/kubernetes/kubelet.conf", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "kubelet_rotate_certificates": { + "checkTitle": "Ensure that the kubelet client certificate rotation is enabled", + "recommendation": "Enable kubelet client certificate rotation for automated renewal of credentials.", + "recommendationUrl": "https://kubernetes.io/docs/reference/access-authn-authz/kubelet-tls-bootstrapping/#certificate-rotation", + "cli": "--rotate-certificates=true", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-rotate-certificates-argument-is-not-set-to-false", + "terraform": null, + "other": null + }, + "kubelet_manage_iptables": { + "checkTitle": "Ensure that the kubelet --make-iptables-util-chains argument is set to true", + "recommendation": "Enable kubelet management of iptables for consistent network configuration.", + "recommendationUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/#options", + "cli": "--make-iptables-util-chains=true", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-make-iptables-util-chains-argument-is-set-to-true", + "terraform": null, + "other": null + }, + "kubelet_conf_file_ownership": { + "checkTitle": "Ensure kubelet.conf file ownership is set to root:root", + "recommendation": "Ensure kubelet.conf file ownership is correctly set to protect the node's configuration.", + "recommendationUrl": "https://kubernetes.io/docs/tasks/administer-cluster/kubelet-config-file/", + "cli": "chown root:root /etc/kubernetes/kubelet.conf", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "kubelet_tls_cert_and_key": { + "checkTitle": "Ensure that the kubelet TLS certificate and private key are set appropriately", + "recommendation": "Configure each kubelet with its own TLS certificate and private key for secure connections.", + "recommendationUrl": "https://kubernetes.io/docs/reference/access-authn-authz/kubelet-tls-bootstrapping/#client-and-serving-certificates", + "cli": "--tls-cert-file= --tls-private-key-file=", + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-tls-cert-file-and-tls-private-key-file-arguments-are-set-as-appropriate-for-kubelet", + "terraform": null, + "other": null + }, + "kubelet_disable_anonymous_auth": { + "checkTitle": "Ensure that the --anonymous-auth argument is set to false", + "recommendation": "Ensure that anonymous requests to the Kubelet server are disabled for enhanced cluster security.", + "recommendationUrl": "https://kubernetes.io/docs/reference/access-authn-authz/kubelet-authn-authz/", + "cli": "--anonymous-auth=false", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "core_minimize_containers_capabilities_assigned": { + "checkTitle": "Minimize the admission of containers with capabilities assigned", + "recommendation": "Restrict the assignment of Linux capabilities to containers unless essential for their operation.", + "recommendationUrl": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "cli": null, + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/bc_k8s_34#kubernetes", + "terraform": null, + "other": null + }, + "core_minimize_containers_added_capabilities": { + "checkTitle": "Minimize the admission of containers with added capabilities", + "recommendation": "Restrict the addition of extra capabilities to containers through admission control policies.", + "recommendationUrl": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "core_minimize_admission_hostport_containers": { + "checkTitle": "Minimize the admission of containers which use HostPorts", + "recommendation": "Limit the use of HostPorts in Kubernetes containers to maintain network security.", + "recommendationUrl": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "cli": null, + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/bc_k8s_25#kubernetes", + "terraform": null, + "other": null + }, + "core_minimize_root_containers_admission": { + "checkTitle": "Minimize the admission of root containers", + "recommendation": "Restrict the use of root containers through admission control policies.", + "recommendationUrl": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "cli": null, + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/bc_k8s_5#kubernetes", + "terraform": null, + "other": null + }, + "core_seccomp_profile_docker_default": { + "checkTitle": "Ensure that the seccomp profile is set to docker/default in your pod definitions", + "recommendation": "Implement the docker/default seccomp profile in pod definitions for enhanced container security.", + "recommendationUrl": "https://docs.docker.com/engine/security/seccomp/", + "cli": null, + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/bc_k8s_30#kubernetes", + "terraform": null, + "other": null + }, + "core_minimize_hostNetwork_containers": { + "checkTitle": "Minimize the admission of containers wishing to share the host network namespace", + "recommendation": "Restrict the use of hostNetwork in containers through admission control policies.", + "recommendationUrl": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "cli": null, + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/bc_k8s_4#kubernetes", + "terraform": null, + "other": null + }, + "core_minimize_privileged_containers": { + "checkTitle": "Minimize the admission of privileged containers", + "recommendation": "Restrict the use of privileged containers through admission control policies.", + "recommendationUrl": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "cli": null, + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/bc_k8s_2#kubernetes", + "terraform": null, + "other": null + }, + "core_minimize_hostIPC_containers": { + "checkTitle": "Minimize the admission of containers wishing to share the host IPC namespace", + "recommendation": "Restrict the use of hostIPC in containers through admission control policies.", + "recommendationUrl": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "cli": null, + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/bc_k8s_3#kubernetes", + "terraform": null, + "other": null + }, + "core_minimize_allowPrivilegeEscalation_containers": { + "checkTitle": "Minimize the admission of containers with allowPrivilegeEscalation", + "recommendation": "Restrict the use of allowPrivilegeEscalation in containers through admission control policies.", + "recommendationUrl": "https://kubernetes.io/docs/tasks/configure-pod-container/security-context/", + "cli": null, + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/bc_k8s_19#kubernetes", + "terraform": null, + "other": null + }, + "core_minimize_admission_windows_hostprocess_containers": { + "checkTitle": "Minimize the admission of Windows HostProcess Containers", + "recommendation": "Restrict the use of Windows HostProcess containers unless essential for their operation.", + "recommendationUrl": "https://kubernetes.io/docs/tasks/configure-pod-container/create-hostprocess-pod/", + "cli": null, + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/bc_k8s_1#kubernetes", + "terraform": null, + "other": null + }, + "core_no_secrets_envs": { + "checkTitle": "Prefer using secrets as files over secrets as environment variables", + "recommendation": "Minimize the use of environment variable secrets and prefer mounting secrets as files for enhanced security.", + "recommendationUrl": "https://kubernetes.io/docs/concepts/configuration/secret/#using-secrets-as-files-over-environment-variables", + "cli": null, + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/bc_k8s_33#kubernetes", + "terraform": null, + "other": null + }, + "core_minimize_hostPID_containers": { + "checkTitle": "Minimize the admission of containers wishing to share the host process ID namespace", + "recommendation": "Restrict the use of hostPID in containers through admission control policies.", + "recommendationUrl": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "cli": null, + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/bc_k8s_1#kubernetes", + "terraform": null, + "other": null + }, + "core_minimize_net_raw_capability_admission": { + "checkTitle": "Minimize the admission of containers with the NET_RAW capability", + "recommendation": "Restrict the use of NET_RAW capability through admission control policies.", + "recommendationUrl": "https://kubernetes.io/docs/tasks/configure-pod-container/security-context/#set-capabilities-for-a-container", + "cli": null, + "nativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/bc_k8s_6#kubernetes", + "terraform": null, + "other": null + }, + "audit_log_retention_period_365_days": { + "checkTitle": "Tenancy audit log retention period is 365 days or greater", + "recommendation": "Set audit retention to `>= 365` days at the tenancy level and protect the setting with **least privilege** and **separation of duties**.\n\nAdopt **defense in depth**: export audit logs to centralized, immutable storage or a SIEM for extended retention, integrity, and continuous monitoring.", + "recommendationUrl": "https://hub.prowler.com/check/audit_log_retention_period_365_days", + "cli": "oci audit configuration update --compartment-id --retention-period-days 365", + "nativeIaC": null, + "terraform": "```hcl\nresource \"oci_audit_configuration\" \"\" {\n compartment_id = var.tenancy_ocid\n retention_period_days = 365 # Critical: sets audit log retention to 365 days to pass the check\n}\n```", + "other": "1. Open the OCI Console and go to Governance & Administration > Audit\n2. Click Configuration\n3. Set Retention period (days) to 365\n4. Click Save" + }, + "analytics_instance_access_restricted": { + "checkTitle": "Oracle Analytics Cloud instance is deployed within a Virtual Cloud Network or restricts public access to allowed sources", + "recommendation": "Prefer **private deployment in a VCN** and apply **least privilege** network access. *If public is required*, enforce **allowlists** to specific IPs/CIDRs and never include `0.0.0.0/0`. Use **private access channels/service gateways**, require **MFA/SSO**, and apply **defense in depth** (WAF, audit monitoring) to reduce exposure.", + "recommendationUrl": "https://hub.prowler.com/check/analytics_instance_access_restricted", + "cli": null, + "nativeIaC": null, + "terraform": "```hcl\nresource \"oci_analytics_analytics_instance\" \"example\" {\n compartment_id = \"\"\n name = \"\"\n feature_set = \"ENTERPRISE_ANALYTICS\"\n license_type = \"LICENSE_INCLUDED\"\n idcs_access_token = \"\"\n\n capacity {\n capacity_type = \"OLPU_COUNT\"\n capacity_value = 1\n }\n\n network_endpoint_details {\n network_endpoint_type = \"PUBLIC\"\n whitelisted_ips = [\"\"] # Critical: restrict to specific allowed CIDR; not 0.0.0.0/0\n }\n}\n```", + "other": "1. In OCI Console, go to Analytics & AI > Analytics Cloud and select your instance\n2. On Instance Details, under Network Access, click Edit next to Access Control\n3. Remove any 0.0.0.0/0 entry (if present)\n4. Add an access rule with the specific allowed public IP or CIDR\n5. Click Save" + }, + "filestorage_file_system_encrypted_with_cmk": { + "checkTitle": "Ensure File Storage Systems are encrypted with Customer Managed Keys", + "recommendation": "Ensure File Storage Systems are encrypted with Customer Managed Keys", + "recommendationUrl": "https://hub.prowler.com/check/oci/filestorage_file_system_encrypted_with_cmk", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-FileStorage/file-storage-systems-encrypted-with-cmks.html" + }, + "events_rule_cloudguard_problems": { + "checkTitle": "Ensure a notification is configured for Oracle Cloud Guard problems detected", + "recommendation": "Ensure a notification is configured for Oracle Cloud Guard problems detected", + "recommendationUrl": "https://hub.prowler.com/check/oci/cloudguard_notification_configured", + "cli": "oci cloud-guard configuration update --compartment-id --status ENABLED --reporting-region ", + "nativeIaC": null, + "terraform": "resource \"oci_cloud_guard_cloud_guard_configuration\" \"example\" {\n compartment_id = var.tenancy_ocid\n reporting_region = var.region\n status = \"ENABLED\"\n}", + "other": "1. Navigate to Security > Cloud Guard\n2. Enable Cloud Guard\n3. Select reporting region\n4. Configure detectors and responders" + }, + "events_rule_iam_policy_changes": { + "checkTitle": "Ensure a notification is configured for IAM policy changes", + "recommendation": "Ensure a notification is configured for IAM policy changes", + "recommendationUrl": "https://hub.prowler.com/check/oci/events_rule_iam_policy_changes", + "cli": "oci events rule create --display-name --condition --actions ", + "nativeIaC": null, + "terraform": "resource \"oci_events_rule\" \"example\" {\n display_name = \"rule\"\n is_enabled = true\n condition = jsonencode({\n eventType = [\"com.oraclecloud.*\"]\n })\n actions {\n actions {\n action_type = \"ONS\"\n topic_id = var.topic_id\n }\n }\n}", + "other": "1. Navigate to Observability & Management > Events Service\n2. Create a new rule\n3. Configure the event condition\n4. Add notification action\n5. Save the rule" + }, + "events_rule_network_gateway_changes": { + "checkTitle": "Ensure a notification is configured for changes to network gateways", + "recommendation": "Ensure a notification is configured for changes to network gateways", + "recommendationUrl": "https://hub.prowler.com/check/oci/events_rule_network_gateway_changes", + "cli": "oci events rule create --display-name --condition --actions ", + "nativeIaC": null, + "terraform": "resource \"oci_events_rule\" \"example\" {\n display_name = \"rule\"\n is_enabled = true\n condition = jsonencode({\n eventType = [\"com.oraclecloud.*\"]\n })\n actions {\n actions {\n action_type = \"ONS\"\n topic_id = var.topic_id\n }\n }\n}", + "other": "1. Navigate to Observability & Management > Events Service\n2. Create a new rule\n3. Configure the event condition\n4. Add notification action\n5. Save the rule" + }, + "events_rule_route_table_changes": { + "checkTitle": "Ensure a notification is configured for changes to route tables", + "recommendation": "Ensure a notification is configured for changes to route tables", + "recommendationUrl": "https://hub.prowler.com/check/oci/events_rule_route_table_changes", + "cli": "oci events rule create --display-name --condition --actions ", + "nativeIaC": null, + "terraform": "resource \"oci_events_rule\" \"example\" {\n display_name = \"rule\"\n is_enabled = true\n condition = jsonencode({\n eventType = [\"com.oraclecloud.*\"]\n })\n actions {\n actions {\n action_type = \"ONS\"\n topic_id = var.topic_id\n }\n }\n}", + "other": "1. Navigate to Observability & Management > Events Service\n2. Create a new rule\n3. Configure the event condition\n4. Add notification action\n5. Save the rule" + }, + "events_rule_iam_group_changes": { + "checkTitle": "Ensure a notification is configured for IAM group changes", + "recommendation": "Ensure a notification is configured for IAM group changes", + "recommendationUrl": "https://hub.prowler.com/check/oci/events_rule_iam_group_changes", + "cli": "oci events rule create --display-name --condition --actions ", + "nativeIaC": null, + "terraform": "resource \"oci_events_rule\" \"example\" {\n display_name = \"rule\"\n is_enabled = true\n condition = jsonencode({\n eventType = [\"com.oraclecloud.*\"]\n })\n actions {\n actions {\n action_type = \"ONS\"\n topic_id = var.topic_id\n }\n }\n}", + "other": "1. Navigate to Observability & Management > Events Service\n2. Create a new rule\n3. Configure the event condition\n4. Add notification action\n5. Save the rule" + }, + "events_notification_topic_and_subscription_exists": { + "checkTitle": "Create at least one notification topic and subscription to receive monitoring alerts", + "recommendation": "Create at least one notification topic and subscription to receive monitoring alerts", + "recommendationUrl": "https://hub.prowler.com/check/oci/events_notification_topic_and_subscription_exists", + "cli": "oci events rule create --display-name --condition --actions ", + "nativeIaC": null, + "terraform": "resource \"oci_events_rule\" \"example\" {\n display_name = \"rule\"\n is_enabled = true\n condition = jsonencode({\n eventType = [\"com.oraclecloud.*\"]\n })\n actions {\n actions {\n action_type = \"ONS\"\n topic_id = var.topic_id\n }\n }\n}", + "other": "1. Navigate to Observability & Management > Events Service\n2. Create a new rule\n3. Configure the event condition\n4. Add notification action\n5. Save the rule" + }, + "events_rule_identity_provider_changes": { + "checkTitle": "Ensure a notification is configured for Identity Provider changes", + "recommendation": "Ensure a notification is configured for Identity Provider changes", + "recommendationUrl": "https://hub.prowler.com/check/oci/events_rule_identity_provider_changes", + "cli": "oci events rule create --display-name --condition --actions ", + "nativeIaC": null, + "terraform": "resource \"oci_events_rule\" \"example\" {\n display_name = \"rule\"\n is_enabled = true\n condition = jsonencode({\n eventType = [\"com.oraclecloud.*\"]\n })\n actions {\n actions {\n action_type = \"ONS\"\n topic_id = var.topic_id\n }\n }\n}", + "other": "1. Navigate to Observability & Management > Events Service\n2. Create a new rule\n3. Configure the event condition\n4. Add notification action\n5. Save the rule" + }, + "events_rule_idp_group_mapping_changes": { + "checkTitle": "Ensure a notification is configured for IdP group mapping changes", + "recommendation": "Ensure a notification is configured for IdP group mapping changes", + "recommendationUrl": "https://hub.prowler.com/check/oci/events_rule_idp_group_mapping_changes", + "cli": "oci events rule create --display-name --condition --actions ", + "nativeIaC": null, + "terraform": "resource \"oci_events_rule\" \"example\" {\n display_name = \"rule\"\n is_enabled = true\n condition = jsonencode({\n eventType = [\"com.oraclecloud.*\"]\n })\n actions {\n actions {\n action_type = \"ONS\"\n topic_id = var.topic_id\n }\n }\n}", + "other": "1. Navigate to Observability & Management > Events Service\n2. Create a new rule\n3. Configure the event condition\n4. Add notification action\n5. Save the rule" + }, + "events_rule_vcn_changes": { + "checkTitle": "Ensure a notification is configured for VCN changes", + "recommendation": "Ensure a notification is configured for VCN changes", + "recommendationUrl": "https://hub.prowler.com/check/oci/events_rule_vcn_changes", + "cli": "oci events rule create --display-name --condition --actions ", + "nativeIaC": null, + "terraform": "resource \"oci_events_rule\" \"example\" {\n display_name = \"rule\"\n is_enabled = true\n condition = jsonencode({\n eventType = [\"com.oraclecloud.*\"]\n })\n actions {\n actions {\n action_type = \"ONS\"\n topic_id = var.topic_id\n }\n }\n}", + "other": "1. Navigate to Observability & Management > Events Service\n2. Create a new rule\n3. Configure the event condition\n4. Add notification action\n5. Save the rule" + }, + "events_rule_security_list_changes": { + "checkTitle": "Ensure a notification is configured for security list changes", + "recommendation": "Ensure a notification is configured for security list changes", + "recommendationUrl": "https://hub.prowler.com/check/oci/events_rule_security_list_changes", + "cli": "oci events rule create --display-name --condition --actions ", + "nativeIaC": null, + "terraform": "resource \"oci_events_rule\" \"example\" {\n display_name = \"rule\"\n is_enabled = true\n condition = jsonencode({\n eventType = [\"com.oraclecloud.*\"]\n })\n actions {\n actions {\n action_type = \"ONS\"\n topic_id = var.topic_id\n }\n }\n}", + "other": "1. Navigate to Observability & Management > Events Service\n2. Create a new rule\n3. Configure the event condition\n4. Add notification action\n5. Save the rule" + }, + "events_rule_local_user_authentication": { + "checkTitle": "Ensure a notification is configured for Local OCI User Authentication", + "recommendation": "Create an Event Rule with notifications configured to monitor local OCI user authentication events (com.oraclecloud.identitysignon.interactivelogin)", + "recommendationUrl": "https://hub.prowler.com/check/oci/events_rule_local_user_authentication", + "cli": "oci events rule create --display-name user-authentication-rule --is-enabled true --condition '{\"eventType\":[\"com.oraclecloud.identitysignon.interactivelogin\"]}' --compartment-id --actions '{\"actions\":[{\"actionType\":\"ONS\",\"isEnabled\":true,\"topicId\":\"\"}]}'", + "nativeIaC": null, + "terraform": "resource \"oci_events_rule\" \"user_auth_rule\" {\n display_name = \"user-authentication-events\"\n is_enabled = true\n compartment_id = var.tenancy_ocid\n condition = \"{\\\"eventType\\\":[\\\"com.oraclecloud.identitysignon.interactivelogin\\\"]}\"\n actions {\n actions {\n action_type = \"ONS\"\n is_enabled = true\n topic_id = oci_ons_notification_topic.topic.id\n }\n }\n}", + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-Events/detect-oci-local-authentication.html" + }, + "events_rule_user_changes": { + "checkTitle": "Ensure a notification is configured for user changes", + "recommendation": "Ensure a notification is configured for user changes", + "recommendationUrl": "https://hub.prowler.com/check/oci/events_rule_user_changes", + "cli": "oci events rule create --display-name --condition --actions ", + "nativeIaC": null, + "terraform": "resource \"oci_events_rule\" \"example\" {\n display_name = \"rule\"\n is_enabled = true\n condition = jsonencode({\n eventType = [\"com.oraclecloud.*\"]\n })\n actions {\n actions {\n action_type = \"ONS\"\n topic_id = var.topic_id\n }\n }\n}", + "other": "1. Navigate to Observability & Management > Events Service\n2. Create a new rule\n3. Configure the event condition\n4. Add notification action\n5. Save the rule" + }, + "events_rule_network_security_group_changes": { + "checkTitle": "Ensure a notification is configured for network security group changes", + "recommendation": "Ensure a notification is configured for network security group changes", + "recommendationUrl": "https://hub.prowler.com/check/oci/events_rule_network_security_group_changes", + "cli": "oci events rule create --display-name --condition --actions ", + "nativeIaC": null, + "terraform": "resource \"oci_events_rule\" \"example\" {\n display_name = \"rule\"\n is_enabled = true\n condition = jsonencode({\n eventType = [\"com.oraclecloud.*\"]\n })\n actions {\n actions {\n action_type = \"ONS\"\n topic_id = var.topic_id\n }\n }\n}", + "other": "1. Navigate to Observability & Management > Events Service\n2. Create a new rule\n3. Configure the event condition\n4. Add notification action\n5. Save the rule" + }, + "cloudguard_enabled": { + "checkTitle": "Cloud Guard is enabled in the root compartment of the tenancy", + "recommendation": "Enable **Cloud Guard** at the tenancy root to centralize monitoring and automated response. Apply **defense in depth** by using detectors/responders, integrate alerts with monitoring, and enforce **least privilege** for its roles. Regularly tune policies and review findings to prevent blind spots.", + "recommendationUrl": "https://hub.prowler.com/check/cloudguard_enabled", + "cli": "oci cloud-guard cloud-guard-configuration update --compartment-id --status ENABLED --reporting-region ", + "nativeIaC": null, + "terraform": "```hcl\nresource \"oci_cloud_guard_cloud_guard_configuration\" \"\" {\n compartment_id = var.tenancy_ocid\n reporting_region = var.region\n status = \"ENABLED\" # Critical: Turns on Cloud Guard in the root compartment\n}\n```", + "other": "1. In the OCI Console, go to Security > Cloud Guard\n2. Ensure the root compartment is selected\n3. Click Enable Cloud Guard\n4. Choose a Reporting region\n5. Click Enable" + }, + "blockstorage_boot_volume_encrypted_with_cmk": { + "checkTitle": "Boot volume is encrypted with Customer Managed Key", + "recommendation": "Encrypt boot volumes with **customer-managed keys** and enforce **least privilege** on key usage. Define a key lifecycle (new keys for rotation), monitor and audit key access, and restrict key scope to required compartments and services to achieve **defense in depth** and rapid revocation when needed.", + "recommendationUrl": "https://hub.prowler.com/check/blockstorage_boot_volume_encrypted_with_cmk", + "cli": "oci bv boot-volume update --boot-volume-id --kms-key-id ", + "nativeIaC": null, + "terraform": "```hcl\nresource \"oci_core_boot_volume_kms_key\" \"\" {\n boot_volume_id = \"\" # Critical: target boot volume to update\n kms_key_id = \"\" # Critical: assigns a Customer Managed Key (CMK) to the boot volume\n}\n```", + "other": "1. In the OCI Console, go to Storage > Block Storage > Boot Volumes\n2. Click the boot volume name\n3. Click Edit (or Assign master encryption key)\n4. Select a Customer-managed key from Vault\n5. Click Save" + }, + "blockstorage_block_volume_encrypted_with_cmk": { + "checkTitle": "Block volume is encrypted with a Customer Managed Key (CMK)", + "recommendation": "Use **Customer-Managed Keys** in Vault for all block volumes.\n- Enforce least privilege and separation of duties on key usage\n- Rotate keys regularly and monitor KMS events\n- Validate that key disable/deny revokes data access\nApply the same controls to snapshots and backups.", + "recommendationUrl": "https://hub.prowler.com/check/blockstorage_block_volume_encrypted_with_cmk", + "cli": "oci bv volume update --volume-id --kms-key-id ", + "nativeIaC": null, + "terraform": "```hcl\nresource \"oci_core_volume\" \"\" {\n compartment_id = \"\"\n availability_domain = \"\"\n size_in_gbs = 50\n\n kms_key_id = \"\" # Critical: uses a Customer Managed Key to encrypt the volume\n}\n```", + "other": "1. In the OCI Console, go to Block Storage > Block Volumes\n2. Open the failing volume\n3. Click Edit\n4. Under Encryption, select \"Encrypt using customer-managed keys\" and choose the vault key\n5. Click Save changes" + }, + "network_security_group_ingress_from_internet_to_ssh_port": { + "checkTitle": "Ensure no network security groups allow ingress from 0.0.0.0/0 to port 22", + "recommendation": "Update network security groups to remove ingress rules allowing access from 0.0.0.0/0 to port 22. Restrict SSH access to known IP addresses.", + "recommendationUrl": "https://docs.oracle.com/en-us/iaas/Content/Network/Concepts/networksecuritygroups.htm", + "cli": "oci network nsg rules update --nsg-id --security-rules ", + "nativeIaC": null, + "terraform": null, + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-Networking/unrestricted-ssh-access-via-nsgs.html" + }, + "network_security_group_ingress_from_internet_to_rdp_port": { + "checkTitle": "Ensure no network security groups allow ingress from 0.0.0.0/0 to port 3389", + "recommendation": "Ensure no network security groups allow ingress from 0.0.0.0/0 to port 3389", + "recommendationUrl": "https://hub.prowler.com/check/oci/network_security_group_ingress_from_internet_to_rdp_port", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-Networking/unrestricted-rdp-access-via-nsgs.html" + }, + "network_security_list_ingress_from_internet_to_ssh_port": { + "checkTitle": "Ensure no security lists allow ingress from 0.0.0.0/0 to port 22", + "recommendation": "Update security lists to remove ingress rules allowing access from 0.0.0.0/0 to port 22. Restrict SSH access to known IP addresses.", + "recommendationUrl": "https://docs.oracle.com/en-us/iaas/Content/Network/Concepts/securitylists.htm", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-Networking/unrestricted-ssh-access.html" + }, + "network_default_security_list_restricts_traffic": { + "checkTitle": "Ensure the default security list of every VCN restricts all traffic except ICMP", + "recommendation": "Configure the default security list to restrict all traffic except ICMP within the VCN. Create custom security lists for your resources.", + "recommendationUrl": "https://docs.oracle.com/en-us/iaas/Content/Network/Concepts/securitylists.htm", + "cli": "oci network security-list update --security-list-id --ingress-security-rules --egress-security-rules ", + "nativeIaC": null, + "terraform": null, + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-Networking/restrict-traffic-for-default-security-lists.html" + }, + "network_vcn_subnet_flow_logs_enabled": { + "checkTitle": "Ensure VCN flow logging is enabled for all subnets", + "recommendation": "Ensure VCN flow logging is enabled for all subnets", + "recommendationUrl": "https://hub.prowler.com/check/oci/network_vcn_subnet_flow_logs_enabled", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-Networking/enable-flow-logging.html" + }, + "network_security_list_ingress_from_internet_to_rdp_port": { + "checkTitle": "Ensure no security lists allow ingress from 0.0.0.0/0 to port 3389", + "recommendation": "Ensure no security lists allow ingress from 0.0.0.0/0 to port 3389", + "recommendationUrl": "https://hub.prowler.com/check/oci/network_security_list_ingress_from_internet_to_rdp_port", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-Networking/unrestricted-rdp-access.html" + }, + "objectstorage_bucket_versioning_enabled": { + "checkTitle": "Ensure Versioning is Enabled for Object Storage Buckets", + "recommendation": "Ensure Versioning is Enabled for Object Storage Buckets", + "recommendationUrl": "https://hub.prowler.com/check/oci/objectstorage_bucket_versioning_enabled", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-ObjectStorage/enable-versioning.html" + }, + "objectstorage_bucket_logging_enabled": { + "checkTitle": "Ensure write level Object Storage logging is enabled for all buckets", + "recommendation": "Enable write-level logging for all Object Storage buckets to maintain audit trails of data modifications.", + "recommendationUrl": "https://docs.prowler.com/checks/oci/oci-logging/objectstorage_bucket_logging_enabled", + "cli": "oci logging log create --log-group-id --display-name 'ObjectStorage-Write-Logs' --log-type SERVICE --configuration '{\"compartmentId\":\"\",\"source\":{\"service\":\"objectstorage\",\"resource\":\"\",\"category\":\"write\",\"sourceType\":\"OCISERVICE\"}}'", + "nativeIaC": null, + "terraform": "resource \"oci_logging_log\" \"objectstorage_write_log\" {\n display_name = \"ObjectStorage-Write-Logs\"\n log_group_id = oci_logging_log_group.log_group.id\n log_type = \"SERVICE\"\n configuration {\n source {\n category = \"write\"\n resource = oci_objectstorage_bucket.bucket.name\n service = \"objectstorage\"\n source_type = \"OCISERVICE\"\n }\n compartment_id = var.compartment_id\n }\n is_enabled = true\n}", + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-ObjectStorage/enable-write-level-logging.html" + }, + "objectstorage_bucket_encrypted_with_cmk": { + "checkTitle": "Ensure Object Storage Buckets are encrypted with a Customer Managed Key (CMK)", + "recommendation": "Ensure Object Storage Buckets are encrypted with a Customer Managed Key (CMK)", + "recommendationUrl": "https://hub.prowler.com/check/oci/objectstorage_bucket_encrypted_with_cmk", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-ObjectStorage/buckets-encrypted-with-cmks.html" + }, + "objectstorage_bucket_not_publicly_accessible": { + "checkTitle": "Ensure no Object Storage buckets are publicly visible", + "recommendation": "Update the bucket's public access type to 'NoPublicAccess' to prevent unauthorized access.", + "recommendationUrl": "https://docs.oracle.com/en-us/iaas/Content/Object/Tasks/managingbuckets.htm", + "cli": "oci os bucket update --namespace --bucket-name --public-access-type NoPublicAccess", + "nativeIaC": null, + "terraform": null, + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-ObjectStorage/publicly-accessible-buckets.html" + }, + "kms_key_rotation_enabled": { + "checkTitle": "Ensure KMS keys are rotated within a period of 90 days", + "recommendation": "After a successful key rotation, the older key version is required in order to decrypt the data encrypted by that previous key version.", + "recommendationUrl": "https://cloud.google.com/iam/docs/manage-access-service-accounts", + "cli": "gcloud kms keys update new --keyring= --location= --nextrotation-time= --rotation-period=", + "nativeIaC": null, + "terraform": "https://docs.prowler.com/checks/gcp/google-cloud-general-policies/bc_gcp_general_4#terraform", + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/gcp/CloudKMS/rotate-kms-encryption-keys.html" + }, + "integration_instance_access_restricted": { + "checkTitle": "Ensure Oracle Integration Cloud (OIC) access is restricted to allowed sources", + "recommendation": "Ensure Oracle Integration Cloud (OIC) access is restricted to allowed sources", + "recommendationUrl": "https://hub.prowler.com/check/oci/integration_instance_access_restricted", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "database_autonomous_database_access_restricted": { + "checkTitle": "Ensure Oracle Autonomous Shared Database (ADB) access is restricted or deployed within a VCN", + "recommendation": "Deploy Autonomous Databases within a VCN using private endpoints or configure strict IP whitelisting to restrict access.", + "recommendationUrl": "https://hub.prowler.com/check/oci/database_autonomous_database_access_restricted", + "cli": "oci db autonomous-database create-private-endpoint --autonomous-database-id --subnet-id ", + "nativeIaC": null, + "terraform": "resource \"oci_database_autonomous_database\" \"adb\" {\n compartment_id = var.compartment_id\n db_name = \"MyADB\"\n display_name = \"My Autonomous Database\"\n is_free_tier = false\n db_workload = \"OLTP\"\n whitelisted_ips = [\"10.0.0.0/24\"]\n nsg_ids = [oci_core_network_security_group.adb_nsg.id]\n subnet_id = oci_core_subnet.private_subnet.id\n}", + "other": "1. Navigate to Autonomous Database\n2. Select the database instance\n3. Click 'More Actions' → 'Update'\n4. Under Network Access, select 'Private endpoint access only'\n5. Configure VCN and subnet for private endpoint\n6. Alternatively, configure Access Control List (ACL) with specific IP addresses" + }, + "compute_instance_in_transit_encryption_enabled": { + "checkTitle": "Ensure In-transit Encryption is enabled on Compute Instance", + "recommendation": "Enable the Oracle Cloud Agent management plugin on all compute instances to enable in-transit encryption for block volume attachments.", + "recommendationUrl": "https://hub.prowler.com/check/oci/compute_instance_in_transit_encryption_enabled", + "cli": "oci compute instance update --instance-id --agent-config '{\"isManagementDisabled\": false}'", + "nativeIaC": null, + "terraform": "resource \"oci_core_instance\" \"example\" {\n # ... other configuration ...\n agent_config {\n is_management_disabled = false\n }\n}", + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-Compute/enable-encryption-in-transit.html" + }, + "compute_instance_secure_boot_enabled": { + "checkTitle": "Ensure Secure Boot is enabled on Compute Instance", + "recommendation": "Enable Secure Boot on all compute instances to protect against boot-level malware and ensure system integrity.", + "recommendationUrl": "https://hub.prowler.com/check/oci/compute_instance_secure_boot_enabled", + "cli": "oci compute instance update --instance-id --platform-config '{\"isSecureBootEnabled\": true}'", + "nativeIaC": null, + "terraform": "resource \"oci_core_instance\" \"example\" {\n # ... other configuration ...\n platform_config {\n type = \"AMD_MILAN_BM\" # or appropriate platform\n is_secure_boot_enabled = true\n }\n}", + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-Compute/enable-secure-boot.html" + }, + "compute_instance_legacy_metadata_endpoint_disabled": { + "checkTitle": "Ensure Compute Instance Legacy Metadata service endpoint is disabled", + "recommendation": "Disable legacy metadata service endpoints on all compute instances to enforce session-based authentication.", + "recommendationUrl": "https://hub.prowler.com/check/oci/compute_instance_legacy_metadata_endpoint_disabled", + "cli": "oci compute instance update --instance-id --instance-options '{\"areLegacyImdsEndpointsDisabled\": true}'", + "nativeIaC": null, + "terraform": "resource \"oci_core_instance\" \"example\" {\n # ... other configuration ...\n instance_options {\n are_legacy_imds_endpoints_disabled = true\n }\n}", + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-Compute/enforce-imds-v2.html" + }, + "identity_user_api_keys_rotated_90_days": { + "checkTitle": "Ensure user API keys rotate within 90 days or less", + "recommendation": "Rotate API keys that are older than 90 days by creating a new key and deleting the old one.", + "recommendationUrl": "https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/managingcredentials.htm", + "cli": "oci iam api-key upload --user-id --key-file && oci iam api-key delete --user-id --fingerprint ", + "nativeIaC": null, + "terraform": null, + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-IAM/rotate-user-api-keys.html" + }, + "identity_non_root_compartment_exists": { + "checkTitle": "Create at least one non-root compartment in your tenancy to store cloud resources", + "recommendation": "Create at least one compartment to organize your cloud resources. From OCI Console: 1. Navigate to Identity & Security -> Compartments. 2. Click 'Create Compartment'. 3. Enter a name and description. 4. Select the parent compartment (typically the root). 5. Click 'Create Compartment'.", + "recommendationUrl": "https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/managingcompartments.htm", + "cli": "oci iam compartment create --compartment-id --name --description ''", + "nativeIaC": null, + "terraform": null, + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-IAM/create-non-root-compartment.html" + }, + "identity_instance_principal_used": { + "checkTitle": "Ensure Instance Principal authentication is used for OCI instances, OCI Cloud Databases and OCI Functions to access OCI resources", + "recommendation": "Ensure Instance Principal authentication is used for OCI instances, OCI Cloud Databases and OCI Functions to access OCI resources", + "recommendationUrl": "https://hub.prowler.com/check/oci/identity_instance_principal_used", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "identity_user_db_passwords_rotated_90_days": { + "checkTitle": "Ensure user IAM Database Passwords rotate within 90 days", + "recommendation": "Ensure user IAM Database Passwords rotate within 90 days", + "recommendationUrl": "https://hub.prowler.com/check/oci/identity_user_db_passwords_rotated_90_days", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "identity_service_level_admins_exist": { + "checkTitle": "Ensure service level admins are created to manage resources of particular service", + "recommendation": "Create service-level administrators with limited permissions to specific services within compartments.", + "recommendationUrl": "https://docs.prowler.com/checks/oci/oci-iam-policies/identity_service_level_admins_exist", + "cli": "oci iam policy create --compartment-id --name --description '' --statements '[\"Allow group to manage -family in compartment \"]'", + "nativeIaC": null, + "terraform": "resource \"oci_identity_policy\" \"service_admin_policy\" {\n compartment_id = var.compartment_id\n name = \"ServiceLevelAdminPolicy\"\n description = \"Service-level admin policy\"\n statements = [\n \"Allow group VolumeAdmins to manage volume-family in compartment Production\"\n ]\n}", + "other": "1. Navigate to Identity → Policies\n2. Click 'Create Policy'\n3. Create policies granting service-level admin permissions to specific groups in specific compartments\n4. Example: 'Allow group VolumeAdmins to manage volume-family in compartment Production'" + }, + "identity_user_valid_email_address": { + "checkTitle": "Ensure all OCI IAM user accounts have a valid and current email address", + "recommendation": "Ensure all OCI IAM user accounts have a valid and current email address", + "recommendationUrl": "https://hub.prowler.com/check/oci/identity_user_valid_email_address", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "identity_password_policy_expires_within_365_days": { + "checkTitle": "Ensure IAM password policy expires passwords within 365 days", + "recommendation": "Ensure IAM password policy expires passwords within 365 days", + "recommendationUrl": "https://hub.prowler.com/check/oci/identity_password_policy_expires_within_365_days", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "identity_tenancy_admin_users_no_api_keys": { + "checkTitle": "Ensure API keys are not created for tenancy administrator users", + "recommendation": "Ensure API keys are not created for tenancy administrator users", + "recommendationUrl": "https://hub.prowler.com/check/oci/identity_tenancy_admin_users_no_api_keys", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "identity_user_auth_tokens_rotated_90_days": { + "checkTitle": "Ensure user auth tokens rotate within 90 days or less", + "recommendation": "Ensure user auth tokens rotate within 90 days or less", + "recommendationUrl": "https://hub.prowler.com/check/oci/identity_user_auth_tokens_rotated_90_days", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-IAM/rotate-user-auth-tokens.html" + }, + "identity_no_resources_in_root_compartment": { + "checkTitle": "Ensure no resources are created in the root compartment", + "recommendation": "Move all resources from the root compartment to appropriate child compartments. From OCI Console: 1. Identify resources in the root compartment. 2. Create or select appropriate child compartments. 3. Move resources to child compartments using the 'Move Resource' option available for most resource types. 4. Update any policies or automation that reference root compartment resources.", + "recommendationUrl": "https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/managingcompartments.htm", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-IAM/check-for-root-compartment-resources.html" + }, + "identity_password_policy_prevents_reuse": { + "checkTitle": "Ensure IAM password policy prevents password reuse", + "recommendation": "Ensure IAM password policy prevents password reuse", + "recommendationUrl": "https://hub.prowler.com/check/oci/identity_password_policy_prevents_reuse", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "identity_user_customer_secret_keys_rotated_90_days": { + "checkTitle": "Ensure user customer secret keys rotate within 90 days or less", + "recommendation": "Ensure user customer secret keys rotate within 90 days or less", + "recommendationUrl": "https://hub.prowler.com/check/oci/identity_user_customer_secret_keys_rotated_90_days", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-IAM/rotate-customer-secret-keys.html" + }, + "identity_tenancy_admin_permissions_limited": { + "checkTitle": "Ensure permissions on all resources are given only to the tenancy administrator group", + "recommendation": "Ensure permissions on all resources are given only to the tenancy administrator group", + "recommendationUrl": "https://hub.prowler.com/check/oci/identity_tenancy_admin_permissions_limited", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-IAM/tenancy-administrator-group-access.html" + }, + "identity_password_policy_minimum_length_14": { + "checkTitle": "Ensure IAM password policy requires minimum length of 14 or greater", + "recommendation": "Make sure IAM password policy requires a minimum password length of 14 or more characters.", + "recommendationUrl": "https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/managingcredentials.htm", + "cli": "oci iam authentication-policy update --compartment-id --password-policy '{\"minimumPasswordLength\": 14}'", + "nativeIaC": null, + "terraform": null, + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-IAM/require-14-characters-password-policy.html" + }, + "identity_iam_admins_cannot_update_tenancy_admins": { + "checkTitle": "Ensure IAM administrators cannot update tenancy Administrators group", + "recommendation": "Ensure IAM administrators cannot update tenancy Administrators group", + "recommendationUrl": "https://hub.prowler.com/check/oci/identity_iam_admins_cannot_update_tenancy_admins", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-IAM/protect-administrators-group-with-access-policies.html" + }, + "identity_user_mfa_enabled_console_access": { + "checkTitle": "Ensure MFA is enabled for all users with a console password", + "recommendation": "Enable MFA for all users with console password access.", + "recommendationUrl": "https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/usingmfa.htm", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-IAM/enable-mfa-for-user-accounts.html" + }, + "actiontrail_multi_region_enabled": { + "checkTitle": "ActionTrail are configured to export copies of all Log entries", + "recommendation": "1. Log on to the **ActionTrail Console**\n2. Click on **Trails** in the left navigation pane\n3. Click **Add new trail**\n4. Enter a trail name in the `Trail name` box\n5. Set **Yes** for `Apply Trail to All Regions`\n6. Specify an OSS bucket name in the `OSS bucket` box\n7. Specify an SLS project name in the `SLS project` box\n8. Click **Create**", + "recommendationUrl": "https://hub.prowler.com/check/actiontrail_multi_region_enabled", + "cli": "aliyun actiontrail CreateTrail --Name --OssBucketName --RoleName aliyunactiontraildefaultrole --SlsProjectArn --SlsWriteRoleArn --EventRW ", + "nativeIaC": null, + "terraform": "resource \"alicloud_actiontrail_trail\" \"example\" {\n trail_name = \"multi-region-trail\"\n trail_region = \"All\"\n sls_project_arn = \"acs:log:cn-hangzhou:123456789:project/actiontrail-project\"\n sls_write_role_arn = data.alicloud_ram_roles.actiontrail.roles.0.arn\n}", + "other": null + }, + "actiontrail_oss_bucket_not_publicly_accessible": { + "checkTitle": "The OSS used to store ActionTrail logs is not publicly accessible", + "recommendation": "1. Log on to the **OSS Console**\n2. Right-click on the bucket and select **Basic Settings**\n3. In the Access Control List pane, click **Configure**\n4. The Bucket ACL tab shows three types of grants: `Private`, `Public Read`, `Public Read/Write`\n5. Ensure **Private** is set for the bucket\n6. Click **Save** to save the ACL", + "recommendationUrl": "https://hub.prowler.com/check/actiontrail_oss_bucket_not_publicly_accessible", + "cli": "ossutil set-acl oss:// private -b", + "nativeIaC": null, + "terraform": "resource \"alicloud_oss_bucket_public_access_block\" \"actiontrail\" {\n bucket = alicloud_oss_bucket.actiontrail.bucket\n block_public_access = true\n}", + "other": null + }, + "rds_instance_postgresql_log_disconnections_enabled": { + "checkTitle": "Server parameter log_disconnections is set to ON for PostgreSQL Database Server", + "recommendation": "1. Log on to the **RDS Console**\n2. Select the region and target instance\n3. In the left-side navigation pane, select **Parameters**\n4. Find the `log_disconnections` parameter and set it to `on`\n5. Click **Apply Changes**", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_postgresql_log_disconnections_enabled", + "cli": "aliyun rds ModifyParameter --DBInstanceId --Parameters \"{\\\"log_disconnections\\\":\\\"on\\\"}\"", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "rds_instance_postgresql_log_connections_enabled": { + "checkTitle": "Parameter log_connections is set to ON for PostgreSQL Database", + "recommendation": "1. Log on to the **RDS Console**\n2. Select the region and target instance\n3. In the left-side navigation pane, select **Parameters**\n4. Find the `log_connections` parameter and set it to `on`\n5. Click **Apply Changes**", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_postgresql_log_connections_enabled", + "cli": "aliyun rds ModifyParameter --DBInstanceId --Parameters \"{\\\"log_connections\\\":\\\"on\\\"}\"", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "rds_instance_tde_enabled": { + "checkTitle": "TDE is set to Enabled on for applicable database instance", + "recommendation": "1. Log on to the **RDS Console**\n2. Go to **Data Security** > **TDE** tab\n3. Find TDE Status and click the switch next to **Disabled**\n4. Choose automatically generated key or custom key\n5. Click **Confirm**", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_tde_enabled", + "cli": "aliyun rds ModifyDBInstanceTDE --DBInstanceId --TDEStatus Enabled", + "nativeIaC": null, + "terraform": "resource \"alicloud_db_instance\" \"example\" {\n engine = \"MySQL\"\n engine_version = \"8.0\"\n instance_type = \"rds.mysql.s1.small\"\n instance_storage = 20\n tde_status = \"Enabled\"\n}", + "other": null + }, + "rds_instance_sql_audit_enabled": { + "checkTitle": "Auditing is set to On for applicable database instances", + "recommendation": "1. Log on to the **RDS Console**\n2. In the left-side navigation pane, select **SQL Explorer**\n3. Click **Activate Now**\n4. Specify the SQL log storage duration\n5. Click **Activate**", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_sql_audit_enabled", + "cli": "aliyun rds ModifySQLCollectorPolicy --DBInstanceId --SQLCollectorStatus Enable --StoragePeriod ", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "rds_instance_tde_key_custom": { + "checkTitle": "RDS instance TDE protector is encrypted with BYOK (Use your own key)", + "recommendation": "1. Log on to the **RDS Console**\n2. Go to **Data Security** > **TDE** tab\n3. Click the switch next to **Disabled**\n4. In the displayed dialog box, choose **custom key**\n5. Click **Confirm**", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_tde_key_custom", + "cli": "aliyun rds ModifyDBInstanceTDE --DBInstanceId --TDEStatus Enabled --EncryptionKey ", + "nativeIaC": null, + "terraform": "resource \"alicloud_db_instance\" \"example\" {\n engine = \"MySQL\"\n engine_version = \"8.0\"\n instance_type = \"rds.mysql.s1.small\"\n instance_storage = 20\n tde_status = \"Enabled\"\n encryption_key = alicloud_kms_key.example.id\n}", + "other": null + }, + "rds_instance_postgresql_log_duration_enabled": { + "checkTitle": "Server parameter log_duration is set to ON for PostgreSQL Database Server", + "recommendation": "1. Log on to the **RDS Console**\n2. Select the region and target instance\n3. In the left-side navigation pane, select **Parameters**\n4. Find the `log_duration` parameter and set it to `on`\n5. Click **Apply Changes**", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_postgresql_log_duration_enabled", + "cli": "aliyun rds ModifyParameter --DBInstanceId --Parameters \"{\\\"log_duration\\\":\\\"on\\\"}\"", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "rds_instance_ssl_enabled": { + "checkTitle": "RDS instance requires all incoming connections to use SSL", + "recommendation": "1. Log on to the **RDS Console**\n2. Select the region and target instance\n3. In the left-side navigation pane, click **Data Security**\n4. Click the **SSL Encryption** tab\n5. Click the switch next to **Disabled** in the SSL Encryption parameter to enable it", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_ssl_enabled", + "cli": "aliyun rds ModifyDBInstanceSSL --DBInstanceId --SSLEnabled 1", + "nativeIaC": null, + "terraform": "resource \"alicloud_db_instance\" \"example\" {\n engine = \"MySQL\"\n engine_version = \"8.0\"\n instance_type = \"rds.mysql.s1.small\"\n instance_storage = 20\n ssl_action = \"Open\"\n}", + "other": null + }, + "rds_instance_sql_audit_retention": { + "checkTitle": "Auditing Retention is greater than the configured period", + "recommendation": "1. Log on to the **RDS Console**\n2. Select **SQL Explorer**\n3. Click **Service Setting**\n4. Enable `Activate SQL Explorer`\n5. Set the storage duration to `6 months` or longer", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_sql_audit_retention", + "cli": "aliyun rds ModifySQLCollectorPolicy --DBInstanceId --SQLCollectorStatus Enable --StoragePeriod 180", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "rds_instance_no_public_access_whitelist": { + "checkTitle": "RDS Instances are not open to the world", + "recommendation": "1. Log on to the **RDS Console**\n2. Go to **Data Security** > **Whitelist Settings** tab\n3. Remove any `0.0.0.0` or `/0` entries\n4. Only add the IP addresses that need to access the instance", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_no_public_access_whitelist", + "cli": "aliyun rds ModifySecurityIps --DBInstanceId --SecurityIps ", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "sls_oss_permission_changes_alert_enabled": { + "checkTitle": "Log monitoring and alerts are set up for OSS permission changes", + "recommendation": "1. Log on to the **SLS Console**\n2. Ensure **OSS logging** is enabled\n3. Select **Alerts**\n4. Ensure alert rule has been enabled for OSS permission changes", + "recommendationUrl": "https://hub.prowler.com/check/sls_oss_permission_changes_alert_enabled", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "sls_management_console_signin_without_mfa_alert_enabled": { + "checkTitle": "A log monitoring and alerts are set up for Management Console sign-in without MFA", + "recommendation": "1. Log on to the **SLS Console**\n2. Ensure **ActionTrail** is enabled\n3. Select **Alerts**\n4. Ensure alert rule has been enabled for Management Console sign-in without MFA", + "recommendationUrl": "https://hub.prowler.com/check/sls_management_console_signin_without_mfa_alert_enabled", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "sls_vpc_network_route_changes_alert_enabled": { + "checkTitle": "Log monitoring and alerts are set up for VPC network route changes", + "recommendation": "1. Log on to the **SLS Console**\n2. Ensure **ActionTrail** is enabled\n3. Select **Alerts**\n4. Ensure alert rule has been enabled for VPC network route changes", + "recommendationUrl": "https://hub.prowler.com/check/sls_vpc_network_route_changes_alert_enabled", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "sls_logstore_retention_period": { + "checkTitle": "Logstore data retention period is set to the recommended period (default 365 days)", + "recommendation": "1. Log on to the **SLS Console**\n2. Find the project in the Projects section\n3. Click **Modify** icon next to the Logstore\n4. Modify the `Data Retention Period` to `365` or greater", + "recommendationUrl": "https://hub.prowler.com/check/sls_logstore_retention_period", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "sls_management_console_authentication_failures_alert_enabled": { + "checkTitle": "A log monitoring and alerts are set up for Management Console authentication failures", + "recommendation": "1. Log on to the **SLS Console**\n2. Ensure **ActionTrail** is enabled\n3. Select **Alerts**\n4. Ensure alert rule has been enabled for Management Console authentication failures", + "recommendationUrl": "https://hub.prowler.com/check/sls_management_console_authentication_failures_alert_enabled", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "sls_cloud_firewall_changes_alert_enabled": { + "checkTitle": "Log monitoring and alerts are set up for Cloud Firewall changes", + "recommendation": "1. Log on to the **SLS Console**\n2. Ensure **ActionTrail** is enabled\n3. Select **Alerts**\n4. Ensure alert rule has been enabled for Cloud Firewall changes", + "recommendationUrl": "https://hub.prowler.com/check/sls_cloud_firewall_changes_alert_enabled", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "sls_unauthorized_api_calls_alert_enabled": { + "checkTitle": "A log monitoring and alerts are set up for unauthorized API calls", + "recommendation": "1. Log on to the **SLS Console**\n2. Ensure **ActionTrail** is enabled\n3. Select **Alerts**\n4. Ensure alert rule has been enabled for unauthorized API calls", + "recommendationUrl": "https://hub.prowler.com/check/sls_unauthorized_api_calls_alert_enabled", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "sls_root_account_usage_alert_enabled": { + "checkTitle": "A log monitoring and alerts are set up for usage of root account", + "recommendation": "1. Log on to the **SLS Console**\n2. Ensure **ActionTrail** is enabled\n3. Select **Alerts**\n4. Ensure alert rule has been enabled for root account usage", + "recommendationUrl": "https://hub.prowler.com/check/sls_root_account_usage_alert_enabled", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "sls_security_group_changes_alert_enabled": { + "checkTitle": "A log monitoring and alerts are set up for security group changes", + "recommendation": "1. Log on to the **SLS Console**\n2. Ensure **ActionTrail** is enabled\n3. Select **Alerts**\n4. Ensure alert rule has been enabled for security group changes", + "recommendationUrl": "https://hub.prowler.com/check/sls_security_group_changes_alert_enabled", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "sls_vpc_changes_alert_enabled": { + "checkTitle": "Log monitoring and alerts are set up for VPC changes", + "recommendation": "1. Log on to the **SLS Console**\n2. Ensure **ActionTrail** is enabled\n3. Select **Alerts**\n4. Ensure alert rule has been enabled for VPC changes", + "recommendationUrl": "https://hub.prowler.com/check/sls_vpc_changes_alert_enabled", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "sls_customer_created_cmk_changes_alert_enabled": { + "checkTitle": "A log monitoring and alerts are set up for disabling or deletion of customer created CMKs", + "recommendation": "1. Log on to the **SLS Console**\n2. Ensure **ActionTrail** is enabled\n3. Select **Alerts**\n4. Ensure alert rule has been enabled for disabling or deletion of customer-created CMKs", + "recommendationUrl": "https://hub.prowler.com/check/sls_customer_created_cmk_changes_alert_enabled", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "sls_ram_role_changes_alert_enabled": { + "checkTitle": "Log monitoring and alerts are set up for RAM Role changes", + "recommendation": "1. Log on to the **SLS Console**\n2. Ensure **ActionTrail** is enabled\n3. Select **Alerts**\n4. Ensure alert rule has been enabled for RAM/ResourceManager policy changes", + "recommendationUrl": "https://hub.prowler.com/check/sls_ram_role_changes_alert_enabled", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "sls_oss_bucket_policy_changes_alert_enabled": { + "checkTitle": "A log monitoring and alerts are set up for OSS bucket policy changes", + "recommendation": "1. Log on to the **SLS Console**\n2. Ensure **ActionTrail** is enabled\n3. Select **Alerts**\n4. Ensure alert rule has been enabled for OSS bucket policy changes", + "recommendationUrl": "https://hub.prowler.com/check/sls_oss_bucket_policy_changes_alert_enabled", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "sls_rds_instance_configuration_changes_alert_enabled": { + "checkTitle": "Log monitoring and alerts are set up for RDS instance configuration changes", + "recommendation": "1. Log on to the **SLS Console**\n2. Ensure **ActionTrail** is enabled\n3. Select **Alerts**\n4. Ensure alert rule has been enabled for RDS instance configuration changes", + "recommendationUrl": "https://hub.prowler.com/check/sls_rds_instance_configuration_changes_alert_enabled", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "cs_kubernetes_log_service_enabled": { + "checkTitle": "Log Service is set to Enabled on Kubernetes Engine Clusters", + "recommendation": "1. Log on to the **ACK Console**\n2. Select the target cluster and click its name to open the cluster detail page\n3. Select **Cluster Auditing** on the left column and check if the audit page is shown\n4. To enable: When creating a new cluster, set `Enable Log Service` to **Enabled**", + "recommendationUrl": "https://hub.prowler.com/check/cs_kubernetes_log_service_enabled", + "cli": "aliyun cs GET /clusters/[cluster_id] to verify AuditProjectName is set. When creating a new cluster, set Enable Log Service to Enabled.", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "cs_kubernetes_eni_multiple_ip_enabled": { + "checkTitle": "ENI multiple IP mode support for Kubernetes Cluster", + "recommendation": "When creating a new cluster, select **Terway** in the `Network Plugin` option to enable ENI multiple IP mode support.\n\n**Note:** Existing clusters using Flannel cannot be migrated to Terway.", + "recommendationUrl": "https://hub.prowler.com/check/cs_kubernetes_eni_multiple_ip_enabled", + "cli": "Terway network plugin must be selected during cluster creation to support ENI multiple IP mode.", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "cs_kubernetes_private_cluster_enabled": { + "checkTitle": "Kubernetes Cluster is created with Private cluster enabled", + "recommendation": "1. Log on to the **ACK Console**\n2. Select the target cluster name and go to the cluster detail page\n3. Check if there is no `API Server Public Network Endpoint` under Cluster Information\n4. When creating a new cluster, make sure **Public Access** is not enabled", + "recommendationUrl": "https://hub.prowler.com/check/cs_kubernetes_private_cluster_enabled", + "cli": "Public access settings cannot be easily changed for existing clusters. Ensure Public Access is disabled during creation.", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "cs_kubernetes_cluster_check_weekly": { + "checkTitle": "Cluster Check triggered at least once per week for Kubernetes Clusters", + "recommendation": "1. Log on to the **ACK Console**\n2. Select the target cluster and open the **More** pop-menu for advanced options\n3. Select **Global Check** and click the **Start** button to trigger the checking\n4. Verify the checking time and details in Global Check\n5. It is recommended to trigger cluster checks at least once per week", + "recommendationUrl": "https://hub.prowler.com/check/cs_kubernetes_cluster_check_weekly", + "cli": "aliyun cs GET /clusters/[cluster_id]/checks to verify cluster checks are being run regularly. Trigger a check if needed.", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "cs_kubernetes_cluster_check_recent": { + "checkTitle": "Cluster Check triggered within configured period for Kubernetes Clusters", + "recommendation": "1. Log on to the **ACK Console**\n2. Select the target cluster and open the **More** pop-menu for advanced options\n3. Select **Global Check** and click the **Start** button to trigger the checking\n4. Verify the checking time and details in Global Check\n5. It is recommended to trigger cluster checks at least once within the configured period (default: weekly)", + "recommendationUrl": "https://hub.prowler.com/check/cs_kubernetes_cluster_check_recent", + "cli": "aliyun cs GET /clusters/[cluster_id]/checks to verify cluster checks are being run regularly. Trigger a check if needed.", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "cs_kubernetes_cloudmonitor_enabled": { + "checkTitle": "CloudMonitor is set to Enabled on Kubernetes Engine Clusters", + "recommendation": "1. Log on to the **ACK Console**\n2. Select the target cluster and click its name to open the cluster detail page\n3. Select **Nodes** on the left column and click the **Monitor** link on the Actions column of the selected node\n4. Verify that OS Metrics data exists in the CloudMonitor page\n5. To enable: Click **Create Kubernetes Cluster** and set `CloudMonitor Agent` to **Enabled** under creation options", + "recommendationUrl": "https://hub.prowler.com/check/cs_kubernetes_cloudmonitor_enabled", + "cli": "aliyun cs GET /clusters/[cluster_id]/nodepools to verify nodepools.kubernetes_config.cms_enabled is set to true for all node pools.", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "cs_kubernetes_rbac_enabled": { + "checkTitle": "Role-based access control (RBAC) authorization is Enabled on Kubernetes Engine Clusters", + "recommendation": "1. Log on to the **ACK Console**\n2. Navigate to **Clusters** -> **Authorizations** page\n3. Select the target RAM sub-account and configure the RBAC roles on specific clusters or namespaces\n4. Ensure **RBAC** is enabled and legacy ABAC authorization is disabled", + "recommendationUrl": "https://hub.prowler.com/check/cs_kubernetes_rbac_enabled", + "cli": "RBAC is enabled by default on new ACK clusters. Verify cluster authorization configuration.", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "cs_kubernetes_network_policy_enabled": { + "checkTitle": "Network policy is enabled on Kubernetes Engine Clusters", + "recommendation": "Only the **Terway** network plugin supports the Network Policy feature. When creating a new cluster, select **Terway** in the `Network Plugin` option.\n\n**Note:** Existing clusters using Flannel cannot be migrated to Terway.", + "recommendationUrl": "https://hub.prowler.com/check/cs_kubernetes_network_policy_enabled", + "cli": "Network Policy support (Terway) must be selected during cluster creation.", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "cs_kubernetes_dashboard_disabled": { + "checkTitle": "Kubernetes web UI / Dashboard is not enabled", + "recommendation": "1. Log on to the **ACK Console**\n2. Select the target cluster and select the `kube-system` namespace in the Namespace pop-menu\n3. Input `dashboard` in the deploy filter bar\n4. Make sure there is no result after the filter\n5. If dashboard exists, delete the deployment by selecting **Delete** in the More pop-menu", + "recommendationUrl": "https://hub.prowler.com/check/cs_kubernetes_dashboard_disabled", + "cli": "Use kubectl to delete the dashboard deployment: kubectl delete deployment kubernetes-dashboard -n kube-system", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "securitycenter_advanced_or_enterprise_edition": { + "checkTitle": "Security Center is Advanced or Enterprise Edition", + "recommendation": "1. Log on to the **Security Center Console**\n2. Select **Overview**\n3. Click **Upgrade**\n4. Select **Advanced** or **Enterprise Edition**\n5. Finish order placement", + "recommendationUrl": "https://hub.prowler.com/check/securitycenter_advanced_or_enterprise_edition", + "cli": "Logon to Security Center Console > Select Overview > Click Upgrade > Select Advanced or Enterprise Edition > Finish order placement", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "securitycenter_all_assets_agent_installed": { + "checkTitle": "All assets are installed with security agent", + "recommendation": "1. Log on to the **Security Center Console**\n2. Select **Settings**\n3. Click **Agent**\n4. On the `Client to be installed` tab, select all items on the list\n5. Click **One-click installation** to install the agent on all assets", + "recommendationUrl": "https://hub.prowler.com/check/securitycenter_all_assets_agent_installed", + "cli": "aliyun sas InstallUninstallAegis --InstanceIds ,", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "securitycenter_vulnerability_scan_enabled": { + "checkTitle": "Scheduled vulnerability scan is enabled on all servers", + "recommendation": "1. Log on to the **Security Center Console**\n2. Select **Vulnerabilities**\n3. Click **Settings**\n4. Apply all types of vulnerabilities (`yum`, `cve`, `sys`, `cms`, `emg`)\n5. Enable **High** (asap) and **Medium** (later) vulnerability scan levels", + "recommendationUrl": "https://hub.prowler.com/check/securitycenter_vulnerability_scan_enabled", + "cli": "aliyun sas ModifyVulConfig --Type --Config on", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "securitycenter_notification_enabled_high_risk": { + "checkTitle": "Notification is enabled on all high risk items", + "recommendation": "1. Log on to the **Security Center Console**\n2. Select **Settings**\n3. Click **Notification**\n4. Enable all high-risk items on Notification setting\n\nRoute values: `1`=text message, `2`=email, `3`=internal message, `4`=text+email, `5`=text+internal, `6`=email+internal, `7`=all methods", + "recommendationUrl": "https://hub.prowler.com/check/securitycenter_notification_enabled_high_risk", + "cli": "aliyun sas ModifyNoticeConfig --Project --Route ", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "vpc_flow_logs_enabled": { + "checkTitle": "VPC flow logs are enabled", + "recommendation": "Enable **VPC Flow Logs** for all VPCs to provide baseline telemetry.\nPrefer capturing at least `REJECT` and, for sensitive networks, `ALL`. Send logs to a centralized, access-controlled destination with retention. Apply **least privilege** to writers/readers and integrate with monitoring for **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/vpc_flow_logs_enabled", + "cli": "aws ec2 create-flow-logs --resource-type VPC --resource-ids --traffic-type ALL --log-destination-type s3 --log-destination arn:aws:s3:::", + "nativeIaC": "```yaml\n# CloudFormation: Enable VPC Flow Logs to S3 for an existing VPC\nResources:\n FlowLog:\n Type: AWS::EC2::FlowLog\n Properties:\n ResourceId: # Critical: target the VPC ID\n ResourceType: VPC # Critical: enable flow logs at VPC level\n TrafficType: ALL # Critical: log all traffic\n LogDestinationType: s3 # Critical: send logs to S3 (no IAM role needed)\n LogDestination: arn:aws:s3::: # Critical: S3 bucket ARN\n```", + "terraform": "```hcl\n# Enable VPC Flow Logs to S3 for an existing VPC\nresource \"aws_flow_log\" \"vpc\" {\n vpc_id = \"\" # Critical: target the VPC to enable flow logs\n traffic_type = \"ALL\" # Critical: log all traffic\n log_destination_type = \"s3\" # Critical: send logs to S3 (no IAM role needed)\n log_destination = \"arn:aws:s3:::\" # Critical: S3 bucket ARN\n}\n```", + "other": "1. In the AWS Console, go to VPC > Your VPCs\n2. Select the target VPC\n3. Open the Flow logs tab and click Create flow log\n4. Set Traffic type to All\n5. Set Destination to S3 and enter Bucket ARN: arn:aws:s3:::\n6. Click Create flow log" + }, + "oss_bucket_not_publicly_accessible": { + "checkTitle": "OSS bucket is not anonymously or publicly accessible", + "recommendation": "**Set Bucket ACL to Private:**\n1. Log on to the **OSS Console**\n2. In the bucket-list pane, click on a target OSS bucket\n3. Click on **Basic Setting** in the top middle of the console\n4. Under ACL section, click on **Configure**\n5. Click **Private** and click **Save**\n\n**For Bucket Policy:**\n1. Click **Bucket**, and then click the name of the target bucket\n2. Click the **Files** tab and click **Authorize**\n3. In the Authorize dialog, choose `Anonymous Accounts (*)` for Accounts and choose `None` for Authorized Operation\n4. Click **OK**", + "recommendationUrl": "https://hub.prowler.com/check/oss_bucket_not_publicly_accessible", + "cli": "aliyun oss PutBucketAcl --bucket --acl private", + "nativeIaC": null, + "terraform": "resource \"alicloud_oss_bucket_public_access_block\" \"example\" {\n bucket = alicloud_oss_bucket.example.bucket\n block_public_access = true\n}", + "other": null + }, + "oss_bucket_logging_enabled": { + "checkTitle": "Logging is enabled for OSS buckets", + "recommendation": "1. Log on to the **OSS Console**\n2. In the bucket-list pane, click on a target OSS bucket\n3. Under **Log**, click **Configure**\n4. Click the **Enabled** checkbox\n5. Select `Target Bucket` from the list\n6. Enter a `Target Prefix`\n7. Click **Save**", + "recommendationUrl": "https://hub.prowler.com/check/oss_bucket_logging_enabled", + "cli": "ossutil logging --method put oss:// --target-bucket --target-prefix ", + "nativeIaC": null, + "terraform": "resource \"alicloud_oss_bucket_logging\" \"example\" {\n bucket = alicloud_oss_bucket.example.bucket\n target_bucket = alicloud_oss_bucket.log_bucket.bucket\n target_prefix = \"log/\"\n}", + "other": null + }, + "oss_bucket_secure_transport_enabled": { + "checkTitle": "Secure transfer required is set to Enabled", + "recommendation": "1. Log on to the **OSS Console**\n2. In the bucket-list pane, click on a target OSS bucket\n3. Click on **Files** in the top middle of the console\n4. Click on **Authorize**\n5. Configure: `Whole Bucket`, `*`, `None` (Authorized Operation) and `http` (Conditions: Access Method) to deny HTTP access\n6. Click **Save**", + "recommendationUrl": "https://hub.prowler.com/check/oss_bucket_secure_transport_enabled", + "cli": null, + "nativeIaC": null, + "terraform": "resource \"alicloud_oss_bucket\" \"example\" {\n bucket = \"example-bucket\"\n \n policy = jsonencode({\n \"Version\": \"1\",\n \"Statement\": [{\n \"Effect\": \"Deny\",\n \"Principal\": [\"*\"],\n \"Action\": [\"oss:*\"],\n \"Resource\": [\"acs:oss:*:*:example-bucket\", \"acs:oss:*:*:example-bucket/*\"],\n \"Condition\": {\n \"Bool\": {\n \"acs:SecureTransport\": \"false\"\n }\n }\n }]\n })\n}", + "other": null + }, + "ecs_securitygroup_restrict_ssh_internet": { + "checkTitle": "SSH access is restricted from the internet", + "recommendation": "1. Log on to the **ECS Console**\n2. In the left-side navigation pane, choose **Network & Security** > **Security Groups**\n3. Find the Security Group you want to modify\n4. Modify Source IP range to specific IP instead of `0.0.0.0/0`\n5. Click **Save**", + "recommendationUrl": "https://hub.prowler.com/check/ecs_securitygroup_restrict_ssh_internet", + "cli": "aliyun ecs RevokeSecurityGroup --SecurityGroupId --IpProtocol tcp --PortRange 22/22 --SourceCidrIp 0.0.0.0/0", + "nativeIaC": null, + "terraform": "resource \"alicloud_security_group_rule\" \"deny_ssh_internet\" {\n type = \"ingress\"\n ip_protocol = \"tcp\"\n port_range = \"22/22\"\n security_group_id = alicloud_security_group.example.id\n cidr_ip = \"10.0.0.0/8\" # Restrict to internal network\n policy = \"accept\"\n}", + "other": null + }, + "ecs_unattached_disk_encrypted": { + "checkTitle": "Unattached disks are encrypted", + "recommendation": "1. Log on to the **ECS Console**\n2. In the left-side navigation pane, choose **Storage & Snapshots** > **Disk**\n3. In the upper-right corner of the Disks page, click **Create Disk**\n4. In the Disk section, check the **Disk Encryption** box and select a key from the drop-down list\n\n**Note:** After a data disk is created, you can only encrypt the data disk by manually copying data from the unencrypted disk to a new encrypted disk.", + "recommendationUrl": "https://hub.prowler.com/check/ecs_unattached_disk_encrypted", + "cli": "aliyun ecs CreateDisk --DiskName --Size --Encrypted true --KmsKeyId ", + "nativeIaC": null, + "terraform": "resource \"alicloud_ecs_disk\" \"encrypted\" {\n zone_id = \"cn-hangzhou-a\"\n disk_name = \"encrypted-disk\"\n category = \"cloud_efficiency\"\n size = 20\n encrypted = true\n kms_key_id = alicloud_kms_key.example.id\n}", + "other": null + }, + "ecs_securitygroup_restrict_rdp_internet": { + "checkTitle": "RDP access is restricted from the internet", + "recommendation": "1. Log on to the **ECS Console**\n2. In the left-side navigation pane, choose **Network & Security** > **Security Groups**\n3. Find the Security Group you want to modify\n4. Modify Source IP range to specific IP instead of `0.0.0.0/0`\n5. Click **Save**", + "recommendationUrl": "https://hub.prowler.com/check/ecs_securitygroup_restrict_rdp_internet", + "cli": "aliyun ecs RevokeSecurityGroup --SecurityGroupId --IpProtocol tcp --PortRange 3389/3389 --SourceCidrIp 0.0.0.0/0", + "nativeIaC": null, + "terraform": "resource \"alicloud_security_group_rule\" \"deny_rdp_internet\" {\n type = \"ingress\"\n ip_protocol = \"tcp\"\n port_range = \"3389/3389\"\n security_group_id = alicloud_security_group.example.id\n cidr_ip = \"10.0.0.0/8\" # Restrict to internal network\n policy = \"accept\"\n}", + "other": null + }, + "ecs_instance_no_legacy_network": { + "checkTitle": "Legacy networks does not exist", + "recommendation": "1. Log on to the **ECS Console**\n2. In the left-side navigation pane, choose **Instance & Image** > **Instances**\n3. Click **Create Instance**\n4. Specify the basic instance information required and click **Next: Networking**\n5. Select the Network Type of **VPC**", + "recommendationUrl": "https://hub.prowler.com/check/ecs_instance_no_legacy_network", + "cli": "aliyun ecs CreateInstance --InstanceName --ImageId --InstanceType --VSwitchId ", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "ecs_attached_disk_encrypted": { + "checkTitle": "Virtual Machines disk are encrypted", + "recommendation": "**Encrypt a system disk when copying an image:**\n1. Log on to the **ECS Console** > **Instances & Images** > **Images**\n2. Select the **Custom Image** tab and select target image\n3. Click **Copy Image** and check the **Encrypt** box\n4. Select a key and click **OK**\n\n**Encrypt a data disk when creating an instance:**\n1. Log on to the **ECS Console** > **Instances & Images** > **Instances** > **Create Instance**\n2. In the Storage section, click **Add Disk**\n3. Select **Disk Encryption** and choose a key\n\n**Note:** You cannot directly convert unencrypted disks to encrypted disks.", + "recommendationUrl": "https://hub.prowler.com/check/ecs_attached_disk_encrypted", + "cli": "aliyun ecs CreateDisk --DiskName --Size --Encrypted true --KmsKeyId ", + "nativeIaC": null, + "terraform": "resource \"alicloud_ecs_disk\" \"encrypted\" {\n zone_id = \"cn-hangzhou-a\"\n disk_name = \"encrypted-disk\"\n category = \"cloud_efficiency\"\n size = 20\n encrypted = true\n kms_key_id = alicloud_kms_key.example.id\n}", + "other": null + }, + "ecs_instance_latest_os_patches_applied": { + "checkTitle": "The latest OS Patches for all Virtual Machines are applied", + "recommendation": "1. Log on to the **Security Center Console**\n2. Select **Vulnerabilities**\n3. Ensure all vulnerabilities are fixed\n4. Apply all patches for vulnerabilities", + "recommendationUrl": "https://hub.prowler.com/check/ecs_instance_latest_os_patches_applied", + "cli": "Logon to Security Center Console > Select Vulnerabilities > Apply all patches for vulnerabilities", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "ecs_instance_endpoint_protection_installed": { + "checkTitle": "The endpoint protection for all Virtual Machines is installed", + "recommendation": "1. Log on to the **Security Center Console**\n2. Select **Settings**\n3. Click **Agent**\n4. On the Agent tab, select the virtual machines without Security Center agent installed\n5. Click **Install**", + "recommendationUrl": "https://hub.prowler.com/check/ecs_instance_endpoint_protection_installed", + "cli": "Logon to Security Center Console > Select Settings > Click Agent > Select virtual machines without Security Center agent > Click Install", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "ram_policy_attached_only_to_group_or_roles": { + "checkTitle": "RAM policies are attached only to groups or roles", + "recommendation": "1. Create **RAM user groups** and assign policies to those groups\n2. Add users to the appropriate groups\n3. Detach any policies directly attached to users using the RAM Console or CLI", + "recommendationUrl": "https://hub.prowler.com/check/ram_policy_attached_only_to_group_or_roles", + "cli": "aliyun ram DetachPolicyFromUser --PolicyName --PolicyType --UserName ", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "ram_password_policy_uppercase": { + "checkTitle": "RAM password policy requires at least one uppercase letter", + "recommendation": "1. Log on to the **RAM Console**\n2. Choose **Settings**\n3. In the Password section, click **Modify**\n4. In the Charset section, select **Upper case**\n5. Click **OK**", + "recommendationUrl": "https://hub.prowler.com/check/ram_password_policy_uppercase", + "cli": "aliyun ram SetPasswordPolicy --RequireUppercaseCharacters true", + "nativeIaC": null, + "terraform": "resource \"alicloud_ram_password_policy\" \"example\" {\n require_uppercase_characters = true\n}", + "other": null + }, + "ram_password_policy_minimum_length": { + "checkTitle": "RAM password policy requires minimum length of 14 or greater", + "recommendation": "1. Log on to the **RAM Console**\n2. Choose **Settings**\n3. In the Password section, click **Modify**\n4. In the Length section, enter `14` or a greater number\n5. Click **OK**", + "recommendationUrl": "https://hub.prowler.com/check/ram_password_policy_minimum_length", + "cli": "aliyun ram SetPasswordPolicy --MinimumPasswordLength 14", + "nativeIaC": null, + "terraform": "resource \"alicloud_ram_password_policy\" \"example\" {\n minimum_password_length = 14\n}", + "other": null + }, + "ram_password_policy_number": { + "checkTitle": "RAM password policy require at least one number", + "recommendation": "1. Log on to the **RAM Console**\n2. Choose **Settings**\n3. In the Password section, click **Modify**\n4. In the Charset section, select **Number**\n5. Click **OK**", + "recommendationUrl": "https://hub.prowler.com/check/ram_password_policy_number", + "cli": "aliyun ram SetPasswordPolicy --RequireNumbers true", + "nativeIaC": null, + "terraform": "resource \"alicloud_ram_password_policy\" \"example\" {\n require_numbers = true\n}", + "other": null + }, + "ram_password_policy_lowercase": { + "checkTitle": "RAM password policy requires at least one lowercase letter", + "recommendation": "1. Log on to the **RAM Console**\n2. Choose **Settings**\n3. In the Password section, click **Modify**\n4. In the Charset section, select **Lower case**\n5. Click **OK**", + "recommendationUrl": "https://hub.prowler.com/check/ram_password_policy_lowercase", + "cli": "aliyun ram SetPasswordPolicy --RequireLowercaseCharacters true", + "nativeIaC": null, + "terraform": "resource \"alicloud_ram_password_policy\" \"example\" {\n require_lowercase_characters = true\n}", + "other": null + }, + "ram_password_policy_max_login_attempts": { + "checkTitle": "RAM password policy temporarily blocks logon after 5 incorrect logon attempts within an hour", + "recommendation": "1. Log on to the **RAM Console**\n2. Choose **Settings**\n3. In the Password section, click **Modify**\n4. In the `Max Attempts` field, check the box next to **Enable** and enter `5`\n5. Click **OK**", + "recommendationUrl": "https://hub.prowler.com/check/ram_password_policy_max_login_attempts", + "cli": "aliyun ram SetPasswordPolicy --MaxLoginAttemps 5", + "nativeIaC": null, + "terraform": "resource \"alicloud_ram_password_policy\" \"example\" {\n max_login_attemps = 5\n}", + "other": null + }, + "ram_user_mfa_enabled_console_access": { + "checkTitle": "Multi-factor authentication is enabled for all RAM users that have a console password", + "recommendation": "1. Log on to the **RAM Console**\n2. For each user with console access, go to the user's details\n3. In the **Console Logon Management** section, click **Modify Logon Settings**\n4. For `Enable MFA`, select **Required**\n5. Click **OK** to save the settings", + "recommendationUrl": "https://hub.prowler.com/check/ram_user_mfa_enabled_console_access", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "ram_rotate_access_key_90_days": { + "checkTitle": "Access keys are rotated every 90 days or less", + "recommendation": "1. Create a new **AccessKey pair** for rotation\n2. Update all applications and systems to use the new AccessKey pair\n3. **Disable** the original AccessKey pair\n4. Confirm that your applications and systems are working\n5. **Delete** the original AccessKey pair", + "recommendationUrl": "https://hub.prowler.com/check/ram_rotate_access_key_90_days", + "cli": "aliyun ram CreateAccessKey --UserName && aliyun ram UpdateAccessKey --UserAccessKeyId --Status Inactive --UserName && aliyun ram DeleteAccessKey --UserAccessKeyId --UserName ", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "ram_no_root_access_key": { + "checkTitle": "No root account access key exists", + "recommendation": "1. Log on to the **RAM Console** by using your Alibaba Cloud account (root account)\n2. Move the pointer over the account icon in the upper-right corner and click **AccessKey**\n3. Click **Continue to manage AccessKey**\n4. On the Security Management page, find the target access keys and click **Delete** to delete the target access keys permanently", + "recommendationUrl": "https://hub.prowler.com/check/ram_no_root_access_key", + "cli": "aliyun ram DeleteAccessKey --UserAccessKeyId ", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "ram_policy_no_administrative_privileges": { + "checkTitle": "RAM policies that allow full \"*:*\" administrative privileges are not created", + "recommendation": "1. Log on to the **RAM Console**\n2. Choose **Permissions** > **Policies**\n3. From the Policy Type drop-down list, select **Custom Policy**\n4. In the Policy Name column, click the name of the target policy\n5. In the Policy Document section, edit the policy to remove the statement with full administrative privileges, or remove the policy from any RAM users, user groups, or roles that have this policy attached", + "recommendationUrl": "https://hub.prowler.com/check/ram_policy_no_administrative_privileges", + "cli": "aliyun ram DetachPolicyFromUser --PolicyName --PolicyType Custom --UserName ", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "ram_password_policy_symbol": { + "checkTitle": "RAM password policy require at least one symbol", + "recommendation": "1. Log on to the **RAM Console**\n2. Choose **Settings**\n3. In the Password section, click **Modify**\n4. In the Charset section, select **Symbol**\n5. Click **OK**", + "recommendationUrl": "https://hub.prowler.com/check/ram_password_policy_symbol", + "cli": "aliyun ram SetPasswordPolicy --RequireSymbols true", + "nativeIaC": null, + "terraform": "resource \"alicloud_ram_password_policy\" \"example\" {\n require_symbols = true\n}", + "other": null + }, + "ram_password_policy_max_password_age": { + "checkTitle": "RAM password policy expires passwords in 365 days or greater", + "recommendation": "1. Log on to the **RAM Console**\n2. Choose **Settings**\n3. In the Password section, click **Modify**\n4. Check the box under `Max Age`, enter `365` or a greater number up to `1095`\n5. Click **OK**", + "recommendationUrl": "https://hub.prowler.com/check/ram_password_policy_max_password_age", + "cli": "aliyun ram SetPasswordPolicy --MaxPasswordAge 365", + "nativeIaC": null, + "terraform": "resource \"alicloud_ram_password_policy\" \"example\" {\n max_password_age = 90\n}", + "other": null + }, + "ram_user_console_access_unused": { + "checkTitle": "Users not logged on for 90 days or longer are disabled for console logon", + "recommendation": "1. Log on to the **RAM Console**\n2. Choose **Identities** > **Users**\n3. In the User Logon Name/Display Name column, click the username of the target RAM user\n4. In the Console Logon Management section, click **Modify Logon Settings**\n5. In the Console Password Logon section, select **Disabled**\n6. Click **OK**", + "recommendationUrl": "https://hub.prowler.com/check/ram_user_console_access_unused", + "cli": "aliyun ram DeleteLoginProfile --UserName ", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "ram_password_policy_password_reuse_prevention": { + "checkTitle": "RAM password policy prevents password reuse", + "recommendation": "1. Log on to the **RAM Console**\n2. Choose **Settings**\n3. In the Password section, click **Modify**\n4. In the `Do Not repeat History` section field, enter `5`\n5. Click **OK**", + "recommendationUrl": "https://hub.prowler.com/check/ram_password_policy_password_reuse_prevention", + "cli": "aliyun ram SetPasswordPolicy --PasswordReusePrevention 5", + "nativeIaC": null, + "terraform": "resource \"alicloud_ram_password_policy\" \"example\" {\n password_reuse_prevention = 24\n}", + "other": null + }, + "sharepoint_onedrive_sync_restricted_unmanaged_devices": { + "checkTitle": "Ensure OneDrive sync is restricted for unmanaged devices.", + "recommendation": "Restrict OneDrive sync to managed devices to prevent unauthorized access to sensitive data.", + "recommendationUrl": "https://learn.microsoft.com/en-us/sharepoint/allow-syncing-only-on-specific-domains", + "cli": "Set-SPOTenantSyncClientRestriction -Enable -DomainGuids '; ; ...'", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Click Settings then select OneDrive - Sync. 3. Check the Allow syncing only on computers joined to specific domains. 4. Use the Get-ADDomain PowerShell command on the on-premises server to obtain the GUID for each on-premises domain. 5. Click Save." + }, + "sharepoint_modern_authentication_required": { + "checkTitle": "Ensure modern authentication for SharePoint applications is required.", + "recommendation": "Block access for SharePoint applications that don't use modern authentication to ensure secure authentication mechanisms.", + "recommendationUrl": "https://learn.microsoft.com/en-us/powershell/module/sharepoint-online/set-spotenant?view=sharepoint-ps", + "cli": "Set-SPOTenant -LegacyAuthProtocolsEnabled $false", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint. 2. Click to expand Policies select Access control. 3. Select Apps that don't use modern authentication. 4. Select the radio button for Block access. 5. Click Save." + }, + "sharepoint_external_sharing_managed": { + "checkTitle": "Ensure SharePoint external sharing is managed through domain whitelists/blacklists.", + "recommendation": "Enforce domain-based restrictions for SharePoint external sharing to control document sharing with trusted domains.", + "recommendationUrl": "https://learn.microsoft.com/en-us/powershell/module/sharepoint-online/set-spotenant?view=sharepoint-ps", + "cli": "Set-SPOTenant -SharingDomainRestrictionMode AllowList -SharingAllowedDomainList 'domain1.com domain2.com'", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint. 2. Expand Policies then click Sharing. 3. Expand More external sharing settings and check 'Limit external sharing by domain'. 4. Select 'Add domains' to configure a list of approved domains. 5. Click Save." + }, + "sharepoint_guest_sharing_restricted": { + "checkTitle": "Ensure that SharePoint guest users cannot share items they don't own.", + "recommendation": "Restrict guest users from sharing items they don't own to enhance security and prevent unauthorized access.", + "recommendationUrl": "https://learn.microsoft.com/en-us/sharepoint/turn-external-sharing-on-or-off", + "cli": "Set-SPOTenant -PreventExternalUsersFromResharing $True", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint. 2. Click to expand Policies then select Sharing. 3. Expand More external sharing settings and uncheck 'Allow guests to share items they don't own'. 4. Click Save." + }, + "sharepoint_external_sharing_restricted": { + "checkTitle": "Ensure external content sharing is restricted.", + "recommendation": "Restrict external sharing in SharePoint to 'New and existing guests' or a more restrictive setting to enhance security.", + "recommendationUrl": "https://learn.microsoft.com/en-us/powershell/module/sharepoint-online/set-spotenant?view=sharepoint-ps", + "cli": "Set-SPOTenant -SharingCapability ExternalUserSharingOnly", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint. 2. Click to expand Policies > Sharing. 3. Locate the External sharing section. 4. Under SharePoint, move the slider bar to 'New and existing guests' or a less permissive level." + }, + "exchange_transport_rules_mail_forwarding_disabled": { + "checkTitle": "Ensure mail transport rules are set to disable mail forwarding.", + "recommendation": "Block all forms of mail forwarding using Transport rules in Exchange Online. Apply exclusions only where justified by organizational policy.", + "recommendationUrl": "https://learn.microsoft.com/en-us/exchange/security-and-compliance/mail-flow-rules/mail-flow-rules", + "cli": "Remove-TransportRule -Identity ", + "nativeIaC": null, + "terraform": null, + "other": "1. Select Exchange to open the Exchange admin center. 2. Select Mail Flow then Rules. 3. For each rule that redirects email to external domains, select the rule and click the 'Delete' icon." + }, + "exchange_mailbox_policy_additional_storage_restricted": { + "checkTitle": "Ensure additional storage providers are restricted in Outlook on the web.", + "recommendation": "Disable access to additional storage providers in Outlook on the web to reduce the risk of data leakage.", + "recommendationUrl": "https://learn.microsoft.com/en-us/powershell/module/exchange/set-owamailboxpolicy?view=exchange-ps", + "cli": "Set-OwaMailboxPolicy -Identity OwaMailboxPolicy-Default -AdditionalStorageProvidersAvailable $false", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "exchange_user_mailbox_auditing_enabled": { + "checkTitle": "Ensure mailbox auditing is enabled for all user mailboxes.", + "recommendation": "Enable mailbox auditing for all user mailboxes and configure auditing for key mailbox actions for owners, delegates, and admins.", + "recommendationUrl": "https://learn.microsoft.com/en-us/purview/audit-mailboxes?view=o365-worldwide", + "cli": "$AuditAdmin = @(\"ApplyRecord\", \"Copy\", \"Create\", \"FolderBind\", \"HardDelete\", \"Move\", \"MoveToDeletedItems\", \"SendAs\", \"SendOnBehalf\", \"SoftDelete\", \"Update\", \"UpdateCalendarDelegation\", \"UpdateFolderPermissions\", \"UpdateInboxRules\"); $AuditDelegate = @(\"ApplyRecord\", \"Create\", \"FolderBind\", \"HardDelete\", \"Move\", \"MoveToDeletedItems\", \"SendAs\", \"SendOnBehalf\", \"SoftDelete\", \"Update\", \"UpdateFolderPermissions\", \"UpdateInboxRules\"); $AuditOwner = @(\"ApplyRecord\", \"Create\", \"HardDelete\", \"MailboxLogin\", \"Move\", \"MoveToDeletedItems\", \"SoftDelete\", \"Update\", \"UpdateCalendarDelegation\", \"UpdateFolderPermissions\", \"UpdateInboxRules\"); $MBX = Get-EXOMailbox -ResultSize Unlimited | Where-Object { $_.RecipientTypeDetails -eq \"UserMailbox\" }; $MBX | Set-Mailbox -AuditEnabled $true -AuditLogAgeLimit 90 -AuditAdmin $AuditAdmin -AuditDelegate $AuditDelegate -AuditOwner $AuditOwner", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "exchange_external_email_tagging_enabled": { + "checkTitle": "Ensure email from external senders is identified.", + "recommendation": "Enable the External tag for Outlook to help users visually identify emails from outside the organization.", + "recommendationUrl": "https://techcommunity.microsoft.com/t5/exchange-team-blog/native-external-sender-callouts-on-email-in-outlook/ba-p/2250098", + "cli": "Set-ExternalInOutlook -Enabled $true", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "exchange_organization_mailtips_enabled": { + "checkTitle": "Ensure MailTips are enabled for end users.", + "recommendation": "Enable MailTips features in Exchange Online and configure the large audience threshold appropriately to assist users when composing emails.", + "recommendationUrl": "https://learn.microsoft.com/en-us/powershell/module/exchange/set-organizationconfig?view=exchange-ps", + "cli": "$TipsParams = @{ MailTipsAllTipsEnabled = $true; MailTipsExternalRecipientsTipsEnabled = $true; MailTipsGroupMetricsEnabled = $true; MailTipsLargeAudienceThreshold = '25' }; Set-OrganizationConfig @TipsParams", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "exchange_shared_mailbox_sign_in_disabled": { + "checkTitle": "Shared mailbox has sign-in blocked", + "recommendation": "Block sign-in for all shared mailboxes to ensure users can only access them through delegation. This enforces accountability and reduces security risks from shared credentials.", + "recommendationUrl": "https://hub.prowler.com/check/exchange_shared_mailbox_sign_in_disabled", + "cli": "Get-EXOMailbox -RecipientTypeDetails SharedMailbox | ForEach-Object { Update-MgUser -UserId $_.ExternalDirectoryObjectId -AccountEnabled:$false }", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Entra admin center (https://entra.microsoft.com/)\n2. Expand Identity > Users and select All users\n3. Search for and select the shared mailbox user account\n4. In the properties pane, go to Account status\n5. Uncheck 'Account enabled' and click Save\n6. Repeat for all shared mailbox accounts" + }, + "exchange_roles_assignment_policy_addins_disabled": { + "checkTitle": "Ensure there is no policy with Outlook add-ins allowed.", + "recommendation": "Restrict Outlook add-in installation by updating the Role Assignment Policy to exclude roles that allow app installation.", + "recommendationUrl": "https://learn.microsoft.com/en-us/exchange/permissions-exo/role-assignment-policies", + "cli": "$policy = \"Role Assignment Policy - Prevent Add-ins\"; $roles = \"MyTextMessaging\", \"MyDistributionGroups\", \"MyMailSubscriptions\", \"MyBaseOptions\", \"MyVoiceMail\", \"MyProfileInformation\", \"MyContactInformation\", \"MyRetentionPolicies\", \"MyDistributionGroupMembership\"; New-RoleAssignmentPolicy -Name $policy -Roles $roles; Set-RoleAssignmentPolicy -id $policy -IsDefault; Get-EXOMailbox -ResultSize Unlimited | Set-Mailbox -RoleAssignmentPolicy $policy", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Exchange admin center https://admin.exchange.microsoft.com. 2. Click to expand Roles > User roles. 3. Select Default Role Assignment Policy. 4. In the right pane, click Manage permissions. 5. Uncheck My Custom Apps, My Marketplace Apps and My ReadWriteMailboxApps under Other roles. 6. Save changes." + }, + "exchange_mailbox_audit_bypass_disabled": { + "checkTitle": "Ensure 'AuditBypassEnabled' is not enabled on any mailbox in the organization.", + "recommendation": "Ensure that no mailboxes have 'AuditBypassEnabled' enabled to guarantee full audit logging for all mailbox activities.", + "recommendationUrl": "https://learn.microsoft.com/en-us/powershell/module/exchange/set-mailboxauditbypassassociation?view=exchange-ps", + "cli": "$MBXAudit = Get-MailboxAuditBypassAssociation -ResultSize unlimited | Where-Object { $_.AuditBypassEnabled -eq $true }; foreach ($mailbox in $MBXAudit) { $mailboxName = $mailbox.Name; Set-MailboxAuditBypassAssociation -Identity $mailboxName -AuditBypassEnabled $false; Write-Host \"Audit Bypass disabled for mailbox Identity: $mailboxName\" -ForegroundColor Green }", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "exchange_organization_modern_authentication_enabled": { + "checkTitle": "Ensure Modern Authentication for Exchange Online is enabled.", + "recommendation": "Enable modern authentication in Exchange Online to enforce secure authentication methods for email clients.", + "recommendationUrl": "https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/enable-or-disable-modern-authentication-in-exchange-online", + "cli": "Set-OrganizationConfig -OAuth2ClientProfileEnabled $True", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "exchange_transport_rules_whitelist_disabled": { + "checkTitle": "Ensure mail transport rules do not whitelist specific domains", + "recommendation": "Remove transport rules that whitelist specific domains to ensure proper scanning.", + "recommendationUrl": "https://learn.microsoft.com/en-us/exchange/security-and-compliance/mail-flow-rules/mail-flow-rules", + "cli": "Remove-TransportRule -Identity ", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Exchange admin center https://admin.exchange.microsoft.com.. 2. Click to expand Mail Flow and then select Rules. 3. For each rule that whitelists specific domains, select the rule and click the 'Delete' icon." + }, + "exchange_transport_config_smtp_auth_disabled": { + "checkTitle": "Ensure SMTP AUTH is disabled.", + "recommendation": "Disable SMTP AUTH at the organization level to support secure, modern authentication practices and block legacy protocol usage.", + "recommendationUrl": "https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission", + "cli": "Set-TransportConfig -SmtpClientAuthenticationDisabled $true", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Exchange admin center https://admin.exchange.microsoft.com. 2. Select Settings > Mail flow. 3. Ensure 'Turn off SMTP AUTH protocol for your organization' is checked." + }, + "exchange_organization_mailbox_auditing_enabled": { + "checkTitle": "Ensure AuditDisabled organizationally is set to False.", + "recommendation": "Set AuditDisabled to False at the organization level to ensure mailbox auditing is always enforced.", + "recommendationUrl": "https://learn.microsoft.com/en-us/powershell/module/exchange/set-organizationconfig?view=exchange-ps#-auditdisabled", + "cli": "Set-OrganizationConfig -AuditDisabled $false", + "nativeIaC": null, + "terraform": null, + "other": null + }, + "admincenter_users_admins_reduced_license_footprint": { + "checkTitle": "Ensure administrative accounts use licenses with a reduced application footprint", + "recommendation": "Assign Microsoft Entra ID P1 or P2 licenses to administrative accounts to participate in essential security services without enabling access to vulnerable applications.", + "recommendationUrl": "https://learn.microsoft.com/en-us/microsoft-365/admin/add-users/add-users?view=o365-worldwide", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Click to expand Users select Active users. 3. Click Add a user. 4. Fill out the appropriate fields for Name, user, etc. 5. When prompted to assign licenses select as needed Microsoft Entra ID P1 or Microsoft Entra ID P2, then click Next. 6. Under the Option settings screen you may choose from several types of privileged roles. Choose Admin center access followed by the appropriate role then click Next. 7. Select Finish adding." + }, + "admincenter_groups_not_public_visibility": { + "checkTitle": "Ensure that only organizationally managed/approved public groups exist", + "recommendation": "Review and adjust the privacy settings of Microsoft 365 Groups to ensure only organizationally managed and approved public groups exist.", + "recommendationUrl": "https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/microsoft-365-groups-governance", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Click to expand Teams & groups select Active teams & groups. 3. On the Active teams and groups page, select the group's name that is public. 4. On the popup groups name page, select Settings. 5. Under Privacy, select Private." + }, + "admincenter_external_calendar_sharing_disabled": { + "checkTitle": "Ensure external sharing of calendars is disabled", + "recommendation": "Disable external calendar sharing by setting the Default Sharing Policy to disabled.", + "recommendationUrl": "https://learn.microsoft.com/en-us/microsoft-365/admin/manage/share-calendars-with-external-users?view=o365-worldwide", + "cli": "Set-SharingPolicy -Identity \"Default Sharing Policy\" -Enabled $False", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to https://admin.microsoft.com. 2. Click Settings > Org settings. 3. Select Calendar in the Services section. 4. Uncheck 'Let your users share their calendars with people outside of your organization who have Office 365 or Exchange'. 5. Click Save." + }, + "admincenter_users_between_two_and_four_global_admins": { + "checkTitle": "Ensure that between two and four global admins are designated", + "recommendation": "Review the number of global administrators in your tenant. Add or remove global admins as necessary to ensure compliance with the recommended range of two to four.", + "recommendationUrl": "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/manage-roles-portal", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to the Microsoft 365 admin center https://admin.microsoft.com 2. Select Users > Active Users. 3. In the Search field enter the name of the user to be made a Global Administrator. 4. To create a new Global Admin: 1. Select the user's name. 2. A window will appear to the right. 3. Select Manage roles. 4. Select Admin center access. 5. Check Global Administrator. 6. Click Save changes. 5. To remove Global Admins: 1. Select User. 2. Under Roles select Manage roles. 3. De-Select the appropriate role. 4. Click Save changes." + }, + "admincenter_organization_customer_lockbox_enabled": { + "checkTitle": "Ensure that customer lockbox is enabled for the organization", + "recommendation": "Enable the Customer Lockbox feature to ensure explicit approval is required before Microsoft engineers can access your data during support operations.", + "recommendationUrl": "https://learn.microsoft.com/en-us/azure/security/fundamentals/customer-lockbox-overview", + "cli": "Set-OrganizationConfig -CustomerLockBoxEnabled $true", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Click Settings > Org settings. 3. Select the Security & privacy tab. 4. Click Customer lockbox. 5. Check the box 'Require approval for all data access requests'. 6. Click Save." + }, + "admincenter_settings_password_never_expire": { + "checkTitle": "Ensure the 'Password expiration policy' is set to 'Set passwords to never expire (recommended)'", + "recommendation": "Enable the 'Never Expire Passwords' option in Microsoft 365 Admin Center.", + "recommendationUrl": "https://learn.microsoft.com/en-us/microsoft-365/admin/misc/password-policy-recommendations?view=o365-worldwide", + "cli": "Set-MsolUser -UserPrincipalName -PasswordNeverExpires $true", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Click to expand Settings select Org Settings. 3. Click on Security & privacy. 4. Check the Set passwords to never expire (recommended) box. 5. Click Save." + }, + "purview_audit_log_search_enabled": { + "checkTitle": "Ensure Purview audit log search is enabled", + "recommendation": "Ensure that Microsoft 365 audit log search is enabled to maintain a comprehensive record of user and admin activities. This will help improve security monitoring, support compliance needs, and provide critical insights for responding to incidents.", + "recommendationUrl": "https://learn.microsoft.com/en-us/purview/audit-search?tabs=microsoft-purview-portal", + "cli": "Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled $true", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft Purview https://compliance.microsoft.com. 2. Select Audit to open the audit search. 3. Click Start recording user and admin activity next to the information warning at the top. 4. Click Yes on the dialog box to confirm." + }, + "teams_meeting_external_control_disabled": { + "checkTitle": "Ensure external participants can't give or request control", + "recommendation": "Disable the ability for external participants to give or request control during Teams meetings to prevent unauthorized content sharing and maintain meeting security.", + "recommendationUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps", + "cli": "Set-CsTeamsMeetingPolicy -Identity Global -AllowExternalParticipantGiveRequestControl $false", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click to expand Meetings select Meeting policies. 3. Click Global (Org-wide default). 4. Under content sharing set External participants can give or request control to Off." + }, + "teams_external_domains_restricted": { + "checkTitle": "Ensure external domains are restricted.", + "recommendation": "Restrict external collaboration by configuring Teams to either Block all external domains or Allow only specific, trusted external domains. This ensures users can only interact with vetted organizations, significantly reducing the attack surface.", + "recommendationUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/set-cstenantfederationconfiguration?view=teams-ps", + "cli": "Set-CsTenantFederationConfiguration -AllowFederatedUsers $false", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Click to expand Users select External access. 3. Under Teams and Skype for Business users in external organizations set Choose which external domains your users have access to to one of the following: Allow only specific external domains or Block all external domains. 4. Click Save." + }, + "teams_external_file_sharing_restricted": { + "checkTitle": "Ensure external file sharing in Teams is enabled for only approved cloud storage services", + "recommendation": "Restrict external file sharing in Teams to only approved cloud storage providers, such as SharePoint Online and OneDrive. Configure Teams policies to block unauthorized services and enforce compliance with organizational data protection standards.", + "recommendationUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/get-csteamsclientconfiguration?view=teams-ps", + "cli": "Set-CsTeamsClientConfiguration -AllowGoogleDrive $false -AllowShareFile $false -AllowBox $false -AllowDropBox $false -AllowEgnyte $false", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click to expand Teams select Teams settings. 3. Set any unauthorized providers to Off." + }, + "teams_email_sending_to_channel_disabled": { + "checkTitle": "Ensure users are not be able to email the channel directly.", + "recommendation": "Disable the ability for users to send emails to Teams channel email addresses to reduce the risk of external abuse and enhance control over organizational communications.", + "recommendationUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/get-csteamsclientconfiguration?view=teams-ps", + "cli": "Set-CsTeamsClientConfiguration -Identity Global -AllowEmailIntoChannel $false", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click to expand Teams select Teams settings. 3. Under email integration set Users can send emails to a channel email address to Off." + }, + "teams_external_users_cannot_start_conversations": { + "checkTitle": "Ensure external users cannot start conversations.", + "recommendation": "Disable the ability for external Teams users not managed by an organization to initiate conversations by unchecking the option that permits them to contact users in your organization. This provides an added layer of protection, especially if exceptions are made to allow limited communication with unmanaged users.", + "recommendationUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/set-cstenantfederationconfiguration?view=teams-ps", + "cli": "Set-CsTenantFederationConfiguration -AllowTeamsConsumerInbound $false", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Click to expand Users select External access. 3. Scroll to Teams accounts not managed by an organization. 4. Uncheck External users with Teams accounts not managed by an organization can contact users in my organization. 5. Click Save." + }, + "teams_meeting_external_lobby_bypass_disabled": { + "checkTitle": "Ensure only people in the organization can bypass the lobby.", + "recommendation": "Ensure that only people within the organization can bypass the lobby, requiring external users and dial-in participants to wait for approval from an organizer, co-organizer, or presenter. This helps secure sensitive meetings and prevents unauthorized access.", + "recommendationUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps", + "cli": "Set-CsTeamsMeetingPolicy -Identity Global -AutoAdmittedUsers 'EveryoneInCompanyExcludingGuests' ", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click to expand Meetings select Meeting policies. 3. Click Global (Org-wide default). 4. Under meeting join & lobby set Who can bypass the lobby to People in my org." + }, + "teams_meeting_chat_anonymous_users_disabled": { + "checkTitle": "Ensure meeting chat does not allow anonymous users", + "recommendation": "Restrict chat access during meetings to only authenticated and authorized users. Disable chat capabilities for anonymous users to maintain confidentiality and prevent misuse.", + "recommendationUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps", + "cli": "Set-CsTeamsMeetingPolicy -Identity Global -MeetingChatEnabledType 'EnabledExceptAnonymous'", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click to expand Meetings select Meeting policies. 3. Click Global (Org-wide default). 4. Under meeting engagement verify that Meeting chat is set to On for everyone but anonymous users." + }, + "teams_meeting_presenters_restricted": { + "checkTitle": "Ensure only organizers and co-organizers can present", + "recommendation": "Restrict presentation capabilities to only organizers and co-organizers to reduce the risk of inappropriate content being shown.", + "recommendationUrl": "https://learn.microsoft.com/en-us/microsoftteams/meeting-who-present-request-control", + "cli": "Set-CsTeamsMeetingPolicy -Identity Global -DesignatedPresenterRoleMode \"OrganizerOnlyUserOverride\"", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click to expand Meetings select Meeting policies. 3. Click Global (Org-wide default). 4. Under content sharing set Who can present to Only organizers and co-organizers." + }, + "teams_meeting_anonymous_user_join_disabled": { + "checkTitle": "Ensure anonymous users are not able to join meetings.", + "recommendation": "Disable anonymous user access to Microsoft Teams meetings to ensure only invited participants can join. This adds a layer of vetting by requiring organizer approval for anyone not explicitly invited.", + "recommendationUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps", + "cli": "Set-CsTeamsMeetingPolicy -Identity Global -AllowAnonymousUsersToJoinMeeting $false", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click to expand Meetings select Meeting policies. 3. Click Global (Org-wide default). 4. Under meeting join & lobby set Anonymous users can join a meeting to Off." + }, + "teams_unmanaged_communication_disabled": { + "checkTitle": "Ensure unmanaged communication is disabled.", + "recommendation": "Disable communication with Teams users whose accounts aren't managed by an organization by setting 'People in my organization can communicate with Teams users whose accounts aren't managed by an organization' to Off. This helps prevent unauthorized or risky external interactions.", + "recommendationUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/set-cstenantfederationconfiguration?view=teams-ps", + "cli": "Set-CsTenantFederationConfiguration -AllowTeamsConsumer $false", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Click to expand Users select External access. 3. Scroll to Teams accounts not managed by an organization. 4. Set People in my organization can communicate with Teams users whose accounts aren't managed by an organization to Off. 5. Click Save." + }, + "teams_security_reporting_enabled": { + "checkTitle": "Ensure users can report security concerns in Teams", + "recommendation": "Enable security reporting in Teams messaging policy.", + "recommendationUrl": "https://learn.microsoft.com/en-us/defender-office-365/submissions-teams?view=o365-worldwide", + "cli": "Set-CsTeamsMessagingPolicy -Identity Global -AllowSecurityEndUserReporting $true", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft Teams admin center (https://admin.teams.microsoft.com). 2. Click to expand Messaging and select Messaging policies. 3. Click Global (Org-wide default). 4. Ensure Report a security concern is On." + }, + "teams_meeting_recording_disabled": { + "checkTitle": "Ensure meeting recording is disabled by default", + "recommendation": "Disable meeting recording in the Global meeting policy to ensure only authorized users can initiate recordings. Create separate policies for users or groups who need recording capabilities.", + "recommendationUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps", + "cli": "Set-CsTeamsMeetingPolicy -Identity Global -AllowCloudRecording $false", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click to expand Meetings select Meeting policies. 3. Click Global (Org-wide default). 4. Under Recording & transcription set Meeting recording to Off." + }, + "teams_meeting_external_chat_disabled": { + "checkTitle": "Ensure external meeting chat is off", + "recommendation": "Disable external meeting chat to prevent potential security risks from untrusted organizations. This helps protect against exploits like GIFShell or DarkGate malware.", + "recommendationUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps", + "cli": "Set-CsTeamsMeetingPolicy -Identity Global -AllowExternalNonTrustedMeetingChat $false", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click to expand Meetings select Meeting policies. 3. Click Global (Org-wide default). 4. Under meeting engagement set External meeting chat to Off." + }, + "teams_meeting_anonymous_user_start_disabled": { + "checkTitle": "Ensure anonymous users are not able to start meetings.", + "recommendation": "Ensure that anonymous users and dial-in callers are required to wait in the lobby until a verified user from the organization or a trusted external domain starts the meeting. This reduces the risk of abuse and maintains meeting integrity.", + "recommendationUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps", + "cli": "Set-CsTeamsMeetingPolicy -Identity Global -AllowAnonymousUsersToStartMeeting $false", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click to expand Meetings select Meeting policies. 3. Click Global (Org-wide default). 4. Under meeting join & lobby set Anonymous users and dial-in callers can start a meeting to Off." + }, + "teams_meeting_dial_in_lobby_bypass_disabled": { + "checkTitle": "Ensure that dial-in users cannot bypass the lobby in Teams meetings", + "recommendation": "Require all users dialing in by phone to wait in the lobby until admitted by the meeting organizer, co-organizer, or presenter. This ensures proper vetting before granting access to potentially sensitive discussions.", + "recommendationUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps", + "cli": "Set-CsTeamsMeetingPolicy -Identity Global -AllowPSTNUsersToBypassLobby $false", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click to expand Meetings select Meeting policies. 3. Click Global (Org-wide default). 4. Under meeting join & lobby set People dialing in can bypass the lobby to Off." + }, + "entra_admin_consent_workflow_enabled": { + "checkTitle": "Ensure the admin consent workflow is enabled.", + "recommendation": "Enable the admin consent workflow in Microsoft Entra to securely manage application consent requests.", + "recommendationUrl": "https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-admin-consent-workflow", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Click to expand Identity > Applications and select Enterprise applications. 3. Under Security, select Consent and permissions. 4. Under Manage, select Admin consent settings. 5. Set 'Users can request admin consent to apps they are unable to consent to' to 'Yes'. 6. Configure the reviewers and email notifications settings. 7. Click Save." + }, + "entra_password_hash_sync_enabled": { + "checkTitle": "Ensure that password hash sync is enabled for hybrid deployments.", + "recommendation": "Enable password hash synchronization in Microsoft Entra Connect to streamline authentication and enhance security monitoring.", + "recommendationUrl": "https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/whatis-phs", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. Log in to the on-premises server hosting Microsoft Entra Connect. 2. Open Azure AD Connect and click Configure. 3. Select 'Customize synchronization options' and click Next. 4. Provide admin credentials. 5. On the Optional features screen, check 'Password hash synchronization' and click Next. 6. Click Configure and wait for the process to complete." + }, + "entra_identity_protection_user_risk_enabled": { + "checkTitle": "Ensure that Identity Protection user risk policies are enabled", + "recommendation": "Enable Identity Protection user risk policies to detect and respond to potential account compromises. Configure Conditional Access policies to enforce MFA or password resets when a high user risk level is detected. Regularly review the Risky Users section to assess potential threats before enforcing strict access controls.", + "recommendationUrl": "https://learn.microsoft.com/en-us/entra/id-protection/howto-identity-protection-configure-risk-policies", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Click expand Protection > Conditional Access select Policies. 3. Create a new policy by selecting New policy. 4. Set the following conditions within the policy: Under Users or workload identities choose All users. Under Cloud apps or actions choose All cloud apps. Under Conditions choose User risk then Yes and select the user risk level High. Under Access Controls select Grant then in the right pane click Grant access then select Require multifactor authentication and Require password change. Under Session ensure Sign-in frequency is set to Every time. Click Select. 5. Under Enable policy set it to Report Only until the organization is ready to enable it. 6. Click Create." + }, + "entra_policy_guest_users_access_restrictions": { + "checkTitle": "Ensure That 'Guest users access restrictions' is set to 'Guest user access is restricted to properties and memberships of their own directory objects'", + "recommendation": "1. From Azure Home select the Portal Menu 2. Select Microsoft Entra ID 3. Then External Identities 4. Select External collaboration settings 5. Under Guest user access, change Guest user access restrictions to be Guest user access is restricted to properties and memberships of their own directory objects", + "recommendationUrl": "https://learn.microsoft.com/en-us/entra/fundamentals/users-default-permissions#member-and-guest-users", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "entra_admin_users_cloud_only": { + "checkTitle": "Ensure all Microsoft 365 administrative users are cloud-only", + "recommendation": "Ensure all Microsoft 365 administrative users are cloud-only to reduce the attack surface and improve security posture.", + "recommendationUrl": "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/best-practices#9-use-cloud-native-accounts-for-microsoft-entra-roles", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. Identify on-premises synchronized administrative users using Microsoft Entra Connect or equivalent tools. 2. Create new cloud-only administrative user with appropriate permissions. 3. Migrate administrative tasks from on-premises synchronized users to the new cloud-only user. 4. Disable or remove the on-premises synchronized administrative users." + }, + "entra_admin_users_sign_in_frequency_enabled": { + "checkTitle": "Ensure Sign-in frequency periodic reauthentication is enabled and properly configured.", + "recommendation": "Enforce a sign-in frequency limit of no more than 4 hours for E3 tenants (or 24 hours for E5 with Privileged Identity Management) and set browser sessions to Never persistent. This ensures that administrative users are regularly reauthenticated, reducing the risk of prolonged unauthorized access and mitigating session hijacking threats.", + "recommendationUrl": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-session-lifetime#user-sign-in-frequency", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Click to expand Protection > Conditional Access Select Policies. 3. Click New policy. Under Users include, select users and groups and check Directory roles. At a minimum, include the directory roles listed below in this section of the document. Under Target resources, include All cloud apps and do not create any exclusions. Under Grant, select Grant Access and check Require multifactor authentication. Under Session, select Sign-in frequency, select Periodic reauthentication, and set it to 4 hours for E3 tenants. E5 tenants with PIM can be set to a maximum value of 24 hours. Check Persistent browser session, then select Never persistent in the drop-down menu. 4. Under Enable policy, set it to Report Only until the organization is ready to enable it." + }, + "entra_admin_users_phishing_resistant_mfa_enabled": { + "checkTitle": "Ensure phishing-resistant MFA strength is required for all administrator accounts", + "recommendation": "Require phishing-resistant MFA strength for all administrator accounts through Conditional Access policies. Enforce the use of FIDO2 security keys, Windows Hello for Business, or certificate-based authentication. Ensure administrators are pre-registered for these methods before enforcement to prevent lockouts. Maintain a break-glass account exempt from this policy for emergency access.", + "recommendationUrl": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-admin-phish-resistant-mfa#create-a-conditional-access-policy", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Click expand Protection > Conditional Access select Policies. 3. Click New policy. Under Users include Select users and groups and check Directory roles. At a minimum, include the directory roles listed below in this section of the document. Under Target resources include All cloud apps and do not create any exclusions. Under Grant select Grant Access and check Require authentication strength and set Phishing-resistant MFA in the dropdown box. Click Select. 4. Under Enable policy set it to Report Only until the organization is ready to enable it. 5. Click Create." + }, + "entra_users_mfa_capable": { + "checkTitle": "Ensure all users are MFA capable", + "recommendation": "Ensure all member users are MFA capable by registering and enabling a strong authentication method that complies with the organization's authentication policy. Regularly review user status to detect gaps in MFA deployment and correct misconfigurations.", + "recommendationUrl": "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-mfa-howitworks", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "Remediation steps will depend on the status of the personnel in question or configuration of Conditional Access policies. Administrators should review each user identified on a case-by-case basis." + }, + "entra_policy_guest_invite_only_for_admin_roles": { + "checkTitle": "Ensure that 'Guest invite restrictions' is set to 'Only users assigned to specific admin roles can invite guest users'", + "recommendation": "1. From Azure Home select the Portal Menu 2. Select Microsoft Entra ID 3. Then External Identities 4. Select External collaboration settings 5. Under Guest invite settings, for Guest invite restrictions, ensure that Only users assigned to specific admin roles can invite guest users is selected", + "recommendationUrl": "https://learn.microsoft.com/en-us/answers/questions/685101/how-to-allow-only-admins-to-add-guests", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "entra_managed_device_required_for_authentication": { + "checkTitle": "Ensure that only managed devices are required for authentication", + "recommendation": "Enforce Conditional Access policies requiring authentication only from managed devices. Configure policies to allow access only from Entra hybrid joined or Intune-compliant devices. This ensures that only secure, policy-enforced endpoints can access corporate resources, reducing the risk of credential theft and unauthorized access.", + "recommendationUrl": "https://learn.microsoft.com/en-us/mem/intune/protect/create-conditional-access-intune", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Click expand Protection > Conditional Access select Policies. 3. Create a new policy by selecting New policy. Under Users include All users. Under Target resources include All cloud apps. Under Grant select Grant access. Check Require multifactor authentication and Require Microsoft Entra hybrid joined device. Choose Require one of the selected controls and click Select at the bottom. 4. Under Enable policy set it to Report Only until the organization is ready to enable it. 5. Click Create." + }, + "entra_legacy_authentication_blocked": { + "checkTitle": "Ensure that Conditional Access policy blocks legacy authentication", + "recommendation": "Enforce Conditional Access policies to block legacy authentication across all users in Microsoft Entra ID. Ensure all applications and devices use modern authentication methods such as OAuth 2.0. For necessary exceptions (e.g., multifunction printers), configure secure alternatives following Microsoft's mail flow best practices.", + "recommendationUrl": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-block-legacy-authentication", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Click expand Protection > Conditional Access select Policies. 3. Create a new policy by selecting New policy. Under Users include All users. Under Target resources include All cloud apps and do not create any exclusions. Under Conditions select Client apps and check the boxes for Exchange ActiveSync clients and Other clients. Under Grant select Block Access. Click Select. 4. Set the policy On and click Create." + }, + "entra_dynamic_group_for_guests_created": { + "checkTitle": "Ensure a dynamic group for guest users is created.", + "recommendation": "Create a dynamic group for guest users to automate policy enforcement and access control.", + "recommendationUrl": "https://learn.microsoft.com/en-us/entra/identity/users/groups-create-rule", + "cli": "New-MgGroup -DisplayName 'Dynamic Guest Users' -MailNickname 'DynGuestUsers' -MailEnabled $false -SecurityEnabled $true -GroupTypes 'DynamicMembership' -MembershipRule '(user.userType -eq \"Guest\")' -MembershipRuleProcessingState 'On'", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Click to expand Identity > Groups and select All groups. 3. Select 'New group' and configure: Group type: Security, Membership type: Dynamic User. 4. Add dynamic query with rule: (user.userType -eq 'Guest'). 5. Click Save." + }, + "entra_managed_device_required_for_mfa_registration": { + "checkTitle": "Ensure that only managed devices are required for MFA registration", + "recommendation": "Enforce MFA registration only from managed devices by requiring compliance through Intune or Entra hybrid join. This ensures that users enroll MFA using secure, organization-controlled devices.", + "recommendationUrl": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-all-users-device-registration", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Click expand Protection > Conditional Access select Policies. 3. Create a new policy by selecting New policy. Under Users include All users. Under Target resources select User actions and check Register security information. Under Grant select Grant access. Check Require multifactor authentication and Require Microsoft Entra hybrid joined device. Choose Require one of the selected controls and click Select at the bottom. 4. Under Enable policy set it to Report Only until the organization is ready to enable it. 5. Click Create." + }, + "entra_users_mfa_enabled": { + "checkTitle": "Ensure multifactor authentication is enabled for all users.", + "recommendation": "Enable multifactor authentication for all users in the Microsoft 365 tenant. Ensure users register at least one strong second-factor authentication method, such as Microsoft Authenticator, SMS codes, or phone calls. Educate users on the importance of MFA and provide clear instructions for enrollment to minimize disruptions.", + "recommendationUrl": "https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-enable-azure-mfa", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Click expand Protection > Conditional Access select Policies. 3. Click New policy. Under Users include All users (and do not exclude any user). Under Target resources include All cloud apps and do not create any exclusions. Under Grant select Grant Access and check Require multifactor authentication. Click Select at the bottom of the pane. 4. Under Enable policy set it to Report Only until the organization is ready to enable it. 5. Click Create." + }, + "entra_policy_restricts_user_consent_for_apps": { + "checkTitle": "Ensure 'User consent for applications' is set to 'Do not allow user consent'", + "recommendation": "1. From Azure Home select the Portal Menu 2. Select Microsoft Entra ID 3. Select Enterprise Applications 4. Select Consent and permissions 5. Select User consent settings 6. Set User consent for applications to Do not allow user consent 7. Click save", + "recommendationUrl": "https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-privileged-access#pa-1-separate-and-limit-highly-privilegedadministrative-users", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/ActiveDirectory/users-can-consent-to-apps-accessing-company-data-on-their-behalf.html#" + }, + "entra_intune_enrollment_sign_in_frequency_every_time": { + "checkTitle": "Ensure sign-in frequency for Intune Enrollment is set to every time", + "recommendation": "Configure a Conditional Access policy that targets Microsoft Intune Enrollment and enforces sign-in frequency to 'Every time'. This ensures that users must reauthenticate for each Intune enrollment action, reducing the risk of unauthorized device enrollment using compromised credentials. Note: Microsoft accounts for a five-minute clock skew when 'every time' is selected, ensuring users are not prompted more frequently than once every five minutes.", + "recommendationUrl": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-session#sign-in-frequency", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Click expand Protection > Conditional Access select Policies. 3. Create a new policy by selecting New policy. o Under Users include All users. o Under Target resources select Resources (formerly cloud apps), choose Select resources and add Microsoft Intune Enrollment to the list. o Under Grant select Grant access. o Check either Require multifactor authentication or Require authentication strength. o Under Session check Sign-in frequency and select Every time. 4. Under Enable policy set it to Report-only until the organization is ready to enable it. 5. Click Create" + }, + "entra_admin_users_mfa_enabled": { + "checkTitle": "Ensure multifactor authentication is enabled for all users in administrative roles.", + "recommendation": "Enable MFA for all users in administrative roles using a Conditional Access policy in Microsoft Entra.", + "recommendationUrl": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-admin-mfa", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Protection > Conditional Access and select Policies. 3. Click 'New policy' and configure: Users: Select users and groups > Directory roles (include admin roles). Target resources: Include 'All cloud apps' with no exclusions. Grant: Select 'Grant Access' and check 'Require multifactor authentication'. 4. Set policy to 'Report Only' for testing before full enforcement. 5. Click 'Create'." + }, + "entra_thirdparty_integrated_apps_not_allowed": { + "checkTitle": "Ensure third party integrated applications are not allowed", + "recommendation": "Disable third-party integrated application permissions unless explicitly required. If third-party applications are necessary, implement strict approval processes and security controls to mitigate risks associated with external integrations.", + "recommendationUrl": "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/delegate-app-roles#restrict-who-can-create-applications", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. From Entra select the Portal Menu 2. Select Azure Active Directory 3. Select Users 4. Select User settings 5. Ensure that Users can register applications is set to No" + }, + "entra_policy_ensure_default_user_cannot_create_tenants": { + "checkTitle": "Ensure that 'Restrict non-admin users from creating tenants' is set to 'Yes'", + "recommendation": "1. From Azure Home select the Portal Menu 2. Select Azure Active Directory 3. Select Users 4. Select User settings 5. Set 'Restrict non-admin users from creating' tenants to 'Yes'", + "recommendationUrl": "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference#tenant-creator", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": null + }, + "entra_identity_protection_sign_in_risk_enabled": { + "checkTitle": "Ensure that Identity Protection sign-in risk policies are enabled", + "recommendation": "Enable Identity Protection sign-in risk policies to detect and respond to suspicious login attempts in real time. Configure Conditional Access to require MFA for risky sign-ins and ensure all users are enrolled in MFA to prevent account lockouts. Regularly review sign-in risk reports to identify and mitigate potential security threats.", + "recommendationUrl": "https://learn.microsoft.com/en-us/entra/id-protection/howto-identity-protection-configure-risk-policies", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Click expand Protection > Conditional Access select Policies. 3. Create a new policy by selecting New policy. 4. Set the following conditions within the policy. Under Users or workload identities choose All users. Under Cloud apps or actions choose All cloud apps. Under Conditions choose Sign-in risk then Yes and check the risk level boxes High and Medium. Under Access Controls select Grant then in the right pane click Grant access then select Require multifactor authentication. Under Session select Sign-in Frequency and set to Every time. Click Select. 5. Under Enable policy set it to Report Only until the organization is ready to enable it. 6. Click Create." + }, + "entra_admin_portals_access_restriction": { + "checkTitle": "Ensure that only administrative roles have access to Microsoft Admin Portals", + "recommendation": "Enforce Conditional Access policies to restrict Microsoft Admin Portals to predefined administrative roles. Ensure that only necessary users have access to these portals, applying the principle of least privilege and conducting periodic access reviews to maintain security compliance.", + "recommendationUrl": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/overview", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Click expand Protection > Conditional Access select Policies. 3. Click New Policy. Under Users include All Users. Under Users select Exclude and check Directory roles and select only administrative roles and a group of PIM eligible users. Under Target resources select Cloud apps and Select apps then select the Microsoft Admin Portals app. Confirm by clicking Select. Under Grant select Block access and click Select. 4. Under Enable policy set it to Report Only until the organization is ready to enable it. 5. Click Create." + }, + "defender_domain_dkim_enabled": { + "checkTitle": "Ensure that DKIM is enabled for all Exchange Online Domains", + "recommendation": "Enable DKIM for all your Exchange Online domains to ensure emails are cryptographically signed and to protect against email spoofing.", + "recommendationUrl": "https://learn.microsoft.com/en-us/powershell/module/exchange/set-dkimsigningconfig?view=exchange-ps", + "cli": "Set-DkimSigningConfig -Identity -Enabled $True", + "nativeIaC": null, + "terraform": null, + "other": "1. After DNS records are created, enable DKIM signing in Microsoft 365 Defender. 2. Navigate to Microsoft 365 Defender at https://security.microsoft.com/. 3. Go to Email & collaboration > Policies & rules > Threat policies. 4. Under Rules, select Email authentication settings. 5. Choose DKIM, click on each domain, and enable 'Sign messages for this domain with DKIM signature'." + }, + "defender_antispam_connection_filter_policy_safe_list_off": { + "checkTitle": "Ensure the default connection filter policy has the SafeList setting disabled", + "recommendation": "Ensure that the EnableSafeList setting in your connection filter policy is set to False to prevent bypassing essential security checks.", + "recommendationUrl": "https://learn.microsoft.com/en-us/defender-office-365/create-safe-sender-lists-in-office-365#use-the-ip-allow-list", + "cli": "Set-HostedConnectionFilterPolicy -Identity Default -EnableSafeList $false", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft 365 Defender https://security.microsoft.com. 2. Click to expand Email & collaboration and select Policies & rules. 3. On the Policies & rules page select Threat policies. 4. Under Policies, select Anti-spam and click on the Connection filter policy (Default). 5. Disable the safe list option. 6. Click Save." + }, + "defender_antispam_policy_inbound_no_allowed_domains": { + "checkTitle": "Ensure inbound anti-spam policies do not contain allowed domains", + "recommendation": "Ensure that the AllowedSenderDomains list in your inbound anti-spam policies is empty to prevent bypassing essential security checks.", + "recommendationUrl": "https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/configure-the-allowed-sender-domains?view=o365-worldwide", + "cli": "Set-HostedContentFilterPolicy -Identity -AllowedSenderDomains @{}", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft 365 Defender (https://security.microsoft.com). 2. Click to expand Email & collaboration and select Policies & rules > Threat policies. 3. Under Policies, select Anti-spam. 4. Open each out-of-compliance inbound anti-spam policy by clicking on it. 5. Click Edit allowed and blocked senders and domains. 6. Select Allow domains. 7. Delete each domain from the domains list. 8. Click Done > Save. 9. Repeat as needed." + }, + "defender_malware_policy_comprehensive_attachments_filter_applied": { + "checkTitle": "Ensure the Common Attachment Types Filter is enabled and applied in a comprehensive way", + "recommendation": "Enable the common attachment types filter in your default or custom anti-malware policy to prevent the delivery of emails with potentially dangerous attachments.", + "recommendationUrl": "https://learn.microsoft.com/en-us/powershell/module/exchange/set-malwarefilterpolicy?view=exchange-ps", + "cli": "$Policy = @{Name = 'CIS L2 Attachment Policy'; EnableFileFilter = $true; }; $L2Extensions = @('ace','ani','apk','app','appx','arj','bat','cab','cmd','com','deb','dex','dll','docm','elf','exe','hta','img','iso','jar','jnlp','kext','lha','lib','library','lnk','lzh','macho','msc','msi','msix','msp','mst','pif','ppa','ppam','reg','rev','scf','scr','sct','sys','uif','vb','vbe','vbs','vxd','wsc','wsf','wsh','xll','xz','z'); New-MalwareFilterPolicy @Policy -FileTypes $L2Extensions; $Rule = @{Name = $Policy.Name; Enabled = $false; MalwareFilterPolicy = $Policy.Name; Priority = 0}; New-MalwareFilterRule @Rule", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft 365 Defender https://security.microsoft.com. 2. Click to expand Email & collaboration and select Policies & rules. 3. On the Policies & rules page select Threat policies. 4. Under Policies, select Anti-malware and click on the Default (Default) policy. 5. On the policy page, scroll to the bottom and click Edit protection settings. 6. Check the option Enable the common attachments filter. 7. Click on select file types and select the file types you want to block. 8. Click Save. 9. Ensure the status of the policy is On" + }, + "defender_antispam_outbound_policy_forwarding_disabled": { + "checkTitle": "Ensure Defender Outbound Spam Policies are set to disable mail forwarding.", + "recommendation": "Block all forms of mail forwarding using Anti-spam outbound policies in Exchange Online. Apply exclusions only where justified by organizational policy.", + "recommendationUrl": "https://learn.microsoft.com/en-us/defender-office-365/outbound-spam-protection-about", + "cli": "Set-HostedOutboundSpamFilterPolicy -Identity {policyName} -AutoForwardingMode Off", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft 365 Defender https://security.microsoft.com/. 2. Expand E-mail & collaboration then select Policies & rules. 3. Select Threat policies > Anti-spam. 4. Select Anti-spam outbound policy (default). 5. Click Edit protection settings. 6. Set Automatic forwarding rules dropdown to Off - Forwarding is disabled and click Save. 7. Repeat steps 4-6 for any additional higher priority, custom policies." + }, + "defender_malware_policy_common_attachments_filter_enabled": { + "checkTitle": "Ensure the Common Attachment Types Filter is enabled.", + "recommendation": "Enable the common attachment types filter in your default or custom anti-malware policy to prevent the delivery of emails with potentially dangerous attachments.", + "recommendationUrl": "https://learn.microsoft.com/en-us/powershell/module/exchange/set-malwarefilterpolicy?view=exchange-ps", + "cli": "Set-MalwareFilterPolicy -Identity Default -EnableFileFilter $true", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft 365 Defender https://security.microsoft.com. 2. Click to expand Email & collaboration and select Policies & rules. 3. On the Policies & rules page select Threat policies. 4. Under Policies, select Anti-malware and click on the Default (Default) policy. 5. On the policy page, scroll to the bottom and click Edit protection settings. 6. Check the option Enable the common attachments filter. 7. Click Save." + }, + "defender_antiphishing_policy_configured": { + "checkTitle": "Ensure anti-phishing policies are properly configured and active.", + "recommendation": "Create and configure anti-phishing policies for specific users, groups, or domains to enhance protection against phishing attacks.", + "recommendationUrl": "https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/set-up-anti-phishing-policies?view=o365-worldwide", + "cli": "$params = @{Name='';PhishThresholdLevel=3;EnableTargetedUserProtection=$true;EnableOrganizationDomainsProtection=$true;EnableMailboxIntelligence=$true;EnableMailboxIntelligenceProtection=$true;EnableSpoofIntelligence=$true;TargetedUserProtectionAction='Quarantine';TargetedDomainProtectionAction='Quarantine';MailboxIntelligenceProtectionAction='Quarantine';TargetedUserQuarantineTag='DefaultFullAccessWithNotificationPolicy';MailboxIntelligenceQuarantineTag='DefaultFullAccessWithNotificationPolicy';TargetedDomainQuarantineTag='DefaultFullAccessWithNotificationPolicy';EnableFirstContactSafetyTips=$true;EnableSimilarUsersSafetyTips=$true;EnableSimilarDomainsSafetyTips=$true;EnableUnusualCharactersSafetyTips=$true;HonorDmarcPolicy=$true}; New-AntiPhishPolicy @params; New-AntiPhishRule -Name $params.Name -AntiPhishPolicy $params.Name -RecipientDomainIs (Get-AcceptedDomain).Name -Priority 0", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft 365 Defender https://security.microsoft.com. 2. Click to expand Email & collaboration and select Policies & rules. 3. On the Policies & rules page select Threat policies. 4. Under Policies, select Anti-phishing 5. Ensure policies have rules with the state set to 'on' and validate settings: spoof intelligence enabled, spoof intelligence action set to 'Quarantine', DMARC reject and quarantine actions, safety tips enabled, unauthenticated sender action enabled, show tag enabled, and honor DMARC policy enabled. If not, modify them to be as recommended." + }, + "defender_chat_report_policy_configured": { + "checkTitle": "Ensure chat report submission policy is properly configured in Defender", + "recommendation": "Configure Defender report submission policy to use customized addresses and enable chat message reporting to customized addresses, while disabling report chat message to Microsoft.", + "recommendationUrl": "https://learn.microsoft.com/en-us/defender-office-365/submissions-teams?view=o365-worldwide", + "cli": "Set-ReportSubmissionPolicy -Identity DefaultReportSubmissionPolicy -EnableReportToMicrosoft $false -ReportChatMessageEnabled $false -ReportChatMessageToCustomizedAddressEnabled $true -ReportJunkToCustomizedAddress $true -ReportNotJunkToCustomizedAddress $true -ReportPhishToCustomizedAddress $true -ReportJunkAddresses $usersub -ReportNotJunkAddresses $usersub -ReportPhishAddresses $usersub", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft 365 Defender (https://security.microsoft.com/). 2. Click on Settings > Email & collaboration > User reported settings. 3. Scroll to Microsoft Teams section. 4. Ensure Monitor reported messages in Microsoft Teams is checked. 5. Ensure Send reported messages to: is set to My reporting mailbox only with report email addresses defined for authorized staff." + }, + "defender_antispam_connection_filter_policy_empty_ip_allowlist": { + "checkTitle": "Ensure the Anti-Spam Connection Filter Policy IP Allowlist is empty or undefined.", + "recommendation": "Ensure that the IP Allowlist in your connection filter policy is empty or undefined to prevent bypassing essential security checks.", + "recommendationUrl": "https://learn.microsoft.com/en-us/powershell/module/exchange/set-hostedconnectionfilterpolicy?view=exchange-ps", + "cli": "Set-HostedConnectionFilterPolicy -Identity Default -IPAllowList @{}", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft 365 Defender https://security.microsoft.com. 2. Click to expand Email & collaboration and select Policies & rules. 3. On the Policies & rules page select Threat policies. 4. Under Policies, select Anti-spam and click on the Connection filter policy (Default). 5. Remove IP entries from the allow list. 6. Click Save." + }, + "defender_malware_policy_notifications_internal_users_malware_enabled": { + "checkTitle": "Ensure notifications for internal users sending malware is Enabled", + "recommendation": "Enable notifications for internal users sending malware in your Defender Malware Policy to ensure admins are alerted of potential threats.", + "recommendationUrl": "https://learn.microsoft.com/en-us/powershell/module/exchange/set-malwarefilterpolicy?view=exchange-ps", + "cli": "Set-MalwareFilterPolicy -Identity Default -EnableInternalSenderAdminNotifications $true -InternalSenderAdminAddress 'admin@example.com'", + "nativeIaC": null, + "terraform": null, + "other": "1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Execute the command: Get-MalwareFilterPolicy | fl Identity, EnableInternalSenderAdminNotifications, InternalSenderAdminAddress. 3. Ensure 'Notify an admin about undelivered messages from internal senders' is set to On and that at least one email address is listed under Administrator email address." + }, + "defender_zap_for_teams_enabled": { + "checkTitle": "Zero-hour auto purge (ZAP) protects Microsoft Teams from malware and phishing", + "recommendation": "Enable Zero-hour auto purge (ZAP) for Microsoft Teams to ensure malicious content is automatically removed from chats after detection, even if it was delivered before being identified as harmful.", + "recommendationUrl": "https://hub.prowler.com/check/defender_zap_for_teams_enabled", + "cli": "Set-TeamsProtectionPolicy -Identity 'Teams Protection Policy' -ZapEnabled $true", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft Defender https://security.microsoft.com/\n2. Click to expand System and select Settings > Email & collaboration > Microsoft Teams protection\n3. Set Zero-hour auto purge (ZAP) to On (Default)" + }, + "defender_antispam_outbound_policy_configured": { + "checkTitle": "Ensure Defender Outbound Spam Policies are set to notify administrators.", + "recommendation": "Configure Defender outbound spam filter policies to notify administrators and copy suspicious outbound messages when users are blocked for sending spam.", + "recommendationUrl": "https://learn.microsoft.com/en-us/defender-office-365/outbound-spam-protection-about", + "cli": "$BccEmailAddress = @(\"\")\n$NotifyEmailAddress = @(\"\")\nSet-HostedOutboundSpamFilterPolicy -Identity Default -BccSuspiciousOutboundAdditionalRecipients $BccEmailAddress -BccSuspiciousOutboundMail $true -NotifyOutboundSpam $true -NotifyOutboundSpamRecipients $NotifyEmailAddress", + "nativeIaC": null, + "terraform": null, + "other": "1. Navigate to Microsoft 365 Defender https://security.microsoft.com. 2. Click to expand Email & collaboration and select Policies & rules > Threat policies. 3. Under Policies, select Anti-spam. 4. Click on the Anti-spam outbound policy (default). 5. Select Edit protection settings then under Notifications: 6. Check 'Send a copy of suspicious outbound messages or message that exceed these limits to these users and groups' and enter the email addresses. 7. Check 'Notify these users and groups if a sender is blocked due to sending outbound spam' and enter the desired email addresses. 8. Click Save." + }, + "securityhub_enabled": { + "checkTitle": "Security Hub is enabled with standards or integrations configured", + "recommendation": "- Enable in all required accounts/Regions\n- Turn on relevant **standards** (`AWS FSBP`, `CIS`)\n- Connect AWS and third-party **integrations**\n- Use **central configuration** and **least privilege**\n- Automate triage and monitor continuously for **defense in depth**", + "recommendationUrl": "https://hub.prowler.com/check/securityhub_enabled", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: Enable Security Hub and at least one standard\nResources:\n Hub:\n Type: AWS::SecurityHub::Hub\n # Critical: Enables Security Hub in this account/Region\n\n Standard:\n Type: AWS::SecurityHub::Standard\n Properties:\n StandardsArn: arn:aws:securityhub:::standards/aws-foundational-security-best-practices/v/1.0.0 # Critical: enables a standard so the check passes\n```", + "terraform": "```hcl\n# Enable Security Hub\nresource \"aws_securityhub_account\" \"\" {}\n\n# Critical: Enable at least one standard so the check passes\nresource \"aws_securityhub_standards_subscription\" \"_fsbp\" {\n standards_arn = \"arn:aws:securityhub:::standards/aws-foundational-security-best-practices/v/1.0.0\" # Enables AWS FSBP\n}\n```", + "other": "1. Open the AWS console and go to Security Hub\n2. If prompted (first use): click Enable Security Hub and keep the default standards selected, then choose Enable\n3. If Security Hub is already enabled: go to Security standards and enable AWS Foundational Security Best Practices\n4. Wait for the status to show Enabled" + }, + "appsync_graphql_api_no_api_key_authentication": { + "checkTitle": "AWS AppSync GraphQL API does not use API key authentication", + "recommendation": "Replace `API_KEY` with stronger modes and apply least privilege:\n- **AWS_IAM** for service-to-service\n- **Cognito User Pools** or **OIDC** for end users\n- **Lambda authorizer** for custom logic\n*If guest access is unavoidable*, limit to read-only fields, enforce throttling, use short key lifetimes, and apply schema-level authorization.", + "recommendationUrl": "https://hub.prowler.com/check/appsync_graphql_api_no_api_key_authentication", + "cli": "aws appsync update-graphql-api --api-id --name --authentication-type AWS_IAM", + "nativeIaC": "```yaml\n# CloudFormation: set default auth to non-API key\nResources:\n :\n Type: AWS::AppSync::GraphQLApi\n Properties:\n Name: \n AuthenticationType: AWS_IAM # Critical: switches default auth away from API_KEY\n```", + "terraform": "```hcl\n# AppSync GraphQL API with non-API key auth\nresource \"aws_appsync_graphql_api\" \"\" {\n name = \"\"\n authentication_type = \"AWS_IAM\" # Critical: avoids API_KEY default auth\n}\n```", + "other": "1. In the AWS Console, go to AppSync > APIs and select your GraphQL API\n2. Open Settings (Authorization)\n3. Change Default authorization mode to AWS IAM (or Cognito/OIDC/Lambda)\n4. Click Save" + }, + "appsync_field_level_logging_enabled": { + "checkTitle": "AWS AppSync API has field-level logging set to ALL or ERROR", + "recommendation": "- Enable field-level logging at least `ERROR`; raise to `INFO`/`DEBUG`/`ALL` only for troubleshooting.\n- Enforce **least privilege** on the logging role.\n- Avoid sensitive data in logs; limit verbose content.\n- Set retention and consider log **sampling** to balance visibility and cost.", + "recommendationUrl": "https://hub.prowler.com/check/appsync_field_level_logging_enabled", + "cli": "aws appsync update-graphql-api --api-id --name --authentication-type AWS_IAM --log-config fieldLogLevel=ERROR,cloudWatchLogsRoleArn=", + "nativeIaC": "```yaml\n# CloudFormation - Enable field-level logging for AppSync API\nResources:\n :\n Type: AWS::AppSync::GraphQLApi\n Properties:\n Name: \n AuthenticationType: AWS_IAM\n LogConfig:\n CloudWatchLogsRoleArn: arn:aws:iam:::role/ # CRITICAL: allows AppSync to write logs\n FieldLogLevel: ERROR # CRITICAL: sets field-level logging to a compliant level\n```", + "terraform": "```hcl\n# Terraform - Enable field-level logging for AppSync API\nresource \"aws_appsync_graphql_api\" \"\" {\n name = \"\"\n authentication_type = \"AWS_IAM\"\n\n log_config {\n cloudwatch_logs_role_arn = \"\" # CRITICAL: permits logging to CloudWatch\n field_log_level = \"ERROR\" # CRITICAL: compliant field-level logging\n }\n}\n```", + "other": "1. In the AWS Console, go to AppSync and open your GraphQL API\n2. Go to Settings > Logging\n3. Turn on Enable logs\n4. Set Field resolver log level to ERROR (or ALL)\n5. Select an IAM role that allows AppSync to write to CloudWatch Logs\n6. Click Save" + }, + "neptune_cluster_integration_cloudwatch_logs": { + "checkTitle": "Neptune cluster has CloudWatch audit logs enabled", + "recommendation": "Enable and centralize **audit logging** for Neptune by exporting `audit` events to CloudWatch Logs and integrating with monitoring or SIEM.\n\n- Enforce **least privilege** on log access\n- Configure retention, encryption, and alerting for anomalous queries\n\nThis supports proactive detection and forensic readiness.", + "recommendationUrl": "https://hub.prowler.com/check/neptune_cluster_integration_cloudwatch_logs", + "cli": "aws neptune modify-db-cluster --db-cluster-identifier --cloudwatch-logs-export-configuration '{\"EnableLogTypes\":[\"audit\"]}'", + "nativeIaC": "```yaml\nResources:\n NeptuneCluster:\n Type: AWS::Neptune::DBCluster\n Properties:\n DBClusterIdentifier: \"\"\n EnableCloudwatchLogsExports:\n - audit # Export audit logs to CloudWatch for monitoring and forensics\n```", + "terraform": "```hcl\nresource \"aws_neptune_cluster\" \"example_resource\" {\n cluster_identifier = \"\"\n enabled_cloudwatch_logs_exports = [\"audit\"] # Export audit logs to CloudWatch for monitoring and forensics\n}\n```", + "other": "1. Sign in to the AWS Management Console and open Amazon Neptune\n2. Go to Databases and select the Neptune DB cluster\n3. Actions > Modify\n4. In Log exports, check \"Audit\"\n5. Continue > Modify DB Cluster" + }, + "neptune_cluster_uses_public_subnet": { + "checkTitle": "Neptune cluster is not using public subnets", + "recommendation": "Place Neptune clusters in **private subnets** and remove public routability to reduce attack surface.\n\n- Apply **least privilege** and network segmentation\n- Restrict inbound access with scoped network controls and minimal trusted paths\n- Enforce logging, monitoring, and private connectivity for administrative and application access", + "recommendationUrl": "https://hub.prowler.com/check/neptune_cluster_uses_public_subnet", + "cli": null, + "nativeIaC": "```yaml\nResources:\n NeptuneSubnetGroup:\n Type: AWS::Neptune::DBSubnetGroup\n Properties:\n DBSubnetGroupDescription: \"Private subnets for Neptune\"\n SubnetIds: # Use only private subnet IDs to prevent public access\n - \n - \n\n NeptuneDBCluster:\n Type: AWS::Neptune::DBCluster\n Properties:\n DBSubnetGroupName: !Ref NeptuneSubnetGroup # Associate cluster with private subnet group\n```", + "terraform": "```hcl\nresource \"aws_neptune_subnet_group\" \"neptune\" {\n name = \"neptune-private-subnets\"\n subnet_ids = [\"\", \"\"] # Use only private subnet IDs to prevent public access\n}\n\nresource \"aws_neptune_cluster\" \"example_cluster\" {\n neptune_subnet_group_name = aws_neptune_subnet_group.neptune.name # Associate cluster with private subnet group\n}\n```", + "other": "1. Open the AWS Console and go to Amazon Neptune > Subnet groups\n2. Click Create DB Subnet Group\n3. Enter a name and description, select the VPC, and add only private subnet IDs (at least two)\n4. Click Create\n5. Go to Amazon Neptune > DB clusters > Select the cluster > Actions > Modify\n6. Set DB subnet group to the newly created subnet group and save (Apply immediately if required)\n7. Verify the cluster subnet group now lists only private subnets" + }, + "neptune_cluster_iam_authentication_enabled": { + "checkTitle": "Neptune cluster has IAM authentication enabled", + "recommendation": "Adopt **IAM database authentication** and centralized identity management to remove static DB credentials and improve auditability.\n\n- Enforce **least privilege** for database roles\n- Use short-lived credentials, centralized rotation and logging\n- Apply defense-in-depth and integrate DB access with IAM for accountability", + "recommendationUrl": "https://hub.prowler.com/check/neptune_cluster_iam_authentication_enabled", + "cli": "aws neptune modify-db-cluster --db-cluster-identifier --enable-iam-database-authentication --apply-immediately", + "nativeIaC": "```yaml\nResources:\n NeptuneCluster:\n Type: AWS::Neptune::DBCluster\n Properties:\n DBClusterIdentifier: \n IamAuthEnabled: true # Enable IAM authentication instead of static DB credentials\n```", + "terraform": "```hcl\nresource \"aws_neptune_cluster\" \"example_resource\" {\n cluster_identifier = \"\"\n iam_database_authentication_enabled = true # Enable IAM authentication instead of static DB credentials\n}\n```", + "other": "1. Sign in to the AWS Management Console and open Amazon Neptune > Databases\n2. Select the DB cluster and choose **Actions** > **Modify**\n3. In **Authentication**, enable **IAM DB authentication** and check **Apply immediately**\n4. Click **Continue** then **Modify DB cluster**" + }, + "neptune_cluster_deletion_protection": { + "checkTitle": "Neptune cluster has deletion protection enabled", + "recommendation": "Enable **deletion protection** for production Neptune clusters and apply the principles of **least privilege** and **separation of duties** for delete operations.\n\nEnforce change-control approvals, restrict delete permissions to audited roles, and limit automated workflows that can perform destructive actions to prevent accidental or malicious deletions.", + "recommendationUrl": "https://hub.prowler.com/check/neptune_cluster_deletion_protection", + "cli": "aws neptune modify-db-cluster --db-cluster-identifier --deletion-protection --apply-immediately", + "nativeIaC": "```yaml\nResources:\n NeptuneCluster:\n Type: AWS::Neptune::DBCluster\n Properties:\n DBClusterIdentifier: \n DeletionProtection: true # Prevent accidental or malicious cluster deletion\n```", + "terraform": "```hcl\nresource \"aws_neptune_cluster\" \"example_resource\" {\n cluster_identifier = \"\"\n deletion_protection = true # Prevent accidental or malicious cluster deletion\n}\n```", + "other": "1. Sign in to the AWS Management Console and open Amazon Neptune\n2. In the navigation pane, choose Databases\n3. Select the DB cluster and choose Modify\n4. Enable Deletion protection\n5. Choose Apply immediately (if shown) and then Modify DB cluster" + }, + "neptune_cluster_backup_enabled": { + "checkTitle": "Neptune cluster has automated backups enabled with retention period equal to or greater than the configured minimum", + "recommendation": "Ensure automated backups are enabled and retention aligns with your **RPO/RTO** and regulatory requirements (at least `7` days).\n\n- Define backup lifecycle and storage retention policies\n- Regularly test restore procedures and monitor backup health\n- Incorporate backups into Disaster Recovery and retention governance", + "recommendationUrl": "https://hub.prowler.com/check/neptune_cluster_backup_enabled", + "cli": "aws neptune modify-db-cluster --db-cluster-identifier --backup-retention-period 7 --apply-immediately", + "nativeIaC": "```yaml\nParameters:\n DBClusterId:\n Type: String\nResources:\n NeptuneCluster:\n Type: AWS::Neptune::DBCluster\n Properties:\n DBClusterIdentifier: !Ref DBClusterId\n BackupRetentionPeriod: 7 # Enable automated backups with 7-day retention minimum\n```", + "terraform": "```hcl\nresource \"aws_neptune_cluster\" \"example_resource\" {\n cluster_identifier = var.cluster_id\n backup_retention_period = 7 # Enable automated backups with 7-day retention minimum\n}\n```", + "other": "1. Sign in to the AWS Management Console\n2. Services → Amazon Neptune → Databases\n3. Select the DB cluster and click Modify\n4. In Backup retention period set the value to 7 (or higher)\n5. Choose Apply immediately and click Modify cluster" + }, + "neptune_cluster_snapshot_encrypted": { + "checkTitle": "Neptune DB cluster snapshot is encrypted at rest", + "recommendation": "Protect snapshot data by enforcing **encryption at rest** and strong key governance.\n\n- Use **customer-managed keys** with controlled lifecycle and rotation\n- Apply **least privilege** to snapshot access and sharing\n- Prevent creation of unencrypted snapshots via organizational configuration and policy controls", + "recommendationUrl": "https://hub.prowler.com/check/neptune_cluster_snapshot_encrypted", + "cli": "aws rds copy-db-cluster-snapshot --source-db-cluster-snapshot-identifier --target-db-cluster-snapshot-identifier --kms-key-id ", + "nativeIaC": null, + "terraform": "```hcl\nresource \"aws_neptune_cluster\" \"restored\" {\n cluster_identifier = \"restored-cluster\"\n snapshot_identifier = \"\"\n storage_encrypted = true # Ensure restored cluster from snapshot is encrypted\n}\n```", + "other": "1. Sign in to the AWS Management Console and open Amazon Neptune\n2. In the left pane choose **Snapshots**\n3. Select the unencrypted snapshot and click **Actions** > **Restore snapshot**\n4. In the Restore page enable **Encryption** and select a KMS key\n5. Click **Restore DB cluster**\n6. After the cluster is restored, create a new snapshot of the restored (encrypted) cluster" + }, + "neptune_cluster_storage_encrypted": { + "checkTitle": "Neptune cluster storage is encrypted at rest", + "recommendation": "Provision all new Neptune DB clusters with **encryption at rest** and prefer **Customer-Managed Keys (CMK)** for key ownership and auditability.\n\nEnforce **least privilege** on KMS keys, implement key lifecycle practices (rotation, revocation) and ensure backups/snapshots remain encrypted to prevent exposure.", + "recommendationUrl": "https://hub.prowler.com/check/neptune_cluster_storage_encrypted", + "cli": null, + "nativeIaC": "```yaml\nResources:\n EncryptedNeptuneCluster:\n Type: AWS::Neptune::DBCluster\n Properties:\n DBClusterIdentifier: !Sub ${DBClusterIdentifier}\n StorageEncrypted: true # Enable encryption at rest for data protection\n```", + "terraform": "```hcl\nresource \"aws_neptune_cluster\" \"example_resource\" {\n cluster_identifier = \"\"\n storage_encrypted = true # Enable encryption at rest for data protection\n}\n```", + "other": null + }, + "neptune_cluster_public_snapshot": { + "checkTitle": "NeptuneDB cluster snapshot is not publicly shared", + "recommendation": "Avoid public sharing and apply **least privilege** when granting snapshot access: share only with specific AWS accounts or roles.\n\nUse **encryption**, enforce automated policies and regular audits, and apply **separation of duties** and tagging to control and track snapshot access.", + "recommendationUrl": "https://hub.prowler.com/check/neptune_cluster_public_snapshot", + "cli": "aws neptune modify-db-cluster-snapshot-attribute --db-cluster-snapshot-identifier --attribute-name restore --values-to-remove all", + "nativeIaC": null, + "terraform": null, + "other": "1. Sign in to the AWS Management Console and open the Amazon RDS console\n2. In the left navigation, choose Snapshots > DB cluster snapshots\n3. Select the snapshot, choose Actions > Manage snapshot permissions\n4. In the permissions dialog remove the Public/all-accounts permission and click Save" + }, + "neptune_cluster_multi_az": { + "checkTitle": "Neptune cluster has Multi-AZ enabled", + "recommendation": "Adopt a **high availability** deployment model for production Neptune clusters by placing read-replicas in separate Availability Zones to avoid single points of failure.\n\nRegularly test automated failover and combine HA with robust backup and recovery practices as part of a defense-in-depth strategy.", + "recommendationUrl": "https://hub.prowler.com/check/neptune_cluster_multi_az", + "cli": null, + "nativeIaC": "```yaml\nResources:\n NeptuneCluster:\n Type: AWS::Neptune::DBCluster\n Properties:\n DBClusterIdentifier: \"\"\n # Deploy across multiple AZs for high availability and failover\n AvailabilityZones:\n - \"\"\n - \"\"\n - \"\"\n```", + "terraform": "```hcl\nresource \"aws_neptune_cluster\" \"example\" {\n cluster_identifier = \"\"\n availability_zones = [\"\", \"\", \"\"] # Deploy across multiple AZs for high availability\n}\n```", + "other": null + }, + "neptune_cluster_copy_tags_to_snapshots": { + "checkTitle": "Neptune DB cluster is configured to copy tags to snapshots.", + "recommendation": "Preserve metadata by enabling tag inheritance for snapshots and enforcing a consistent tagging strategy.\n\n- Adopt a standardized tag taxonomy\n- Use tag-based access controls and apply least privilege\n- Automate tagging and policy checks in provisioning to prevent untagged snapshots", + "recommendationUrl": "https://hub.prowler.com/check/neptune_cluster_copy_tags_to_snapshots", + "cli": "aws neptune modify-db-cluster --db-cluster-identifier --copy-tags-to-snapshot --apply-immediately", + "nativeIaC": "```yaml\nResources:\n NeptuneCluster:\n Type: AWS::RDS::DBCluster\n Properties:\n DBClusterIdentifier: \n EngineVersion: neptune\n CopyTagsToSnapshot: true # Inherit tags for snapshot governance and access control\n```", + "terraform": "```hcl\nresource \"aws_neptune_cluster\" \"example_resource\" {\n cluster_identifier = \"\"\n copy_tags_to_snapshot = true # Inherit tags for snapshot governance and access control\n}\n```", + "other": "1. Sign in to the AWS Management Console and open Amazon Neptune\n2. Click Clusters and select the cluster\n3. Click Modify\n4. In Backup, enable \"Copy tags to snapshots\"\n5. Check \"Apply immediately\"\n6. Click Modify Cluster" + }, + "glacier_vaults_policy_public_access": { + "checkTitle": "S3 Glacier vault has no policy or its policy does not allow access to everyone", + "recommendation": "Enforce **least privilege** on vault policies: restrict to specific AWS accounts or roles, avoid `Principal: '*'`, and grant only necessary actions. Apply **defense in depth** with **Vault Lock** for immutable retention and continuous review and monitoring of access to prevent broad or unintended exposure.", + "recommendationUrl": "https://hub.prowler.com/check/glacier_vaults_policy_public_access", + "cli": "aws glacier delete-vault-access-policy --account-id --vault-name ", + "nativeIaC": "```yaml\n# CloudFormation: Glacier vault without an access policy (no public access)\nResources:\n :\n Type: AWS::Glacier::Vault\n Properties:\n VaultName: \n # AccessPolicy omitted to remove any public access and pass the check\n```", + "terraform": "```hcl\n# Glacier vault with no access policy (not public)\nresource \"aws_glacier_vault\" \"\" {\n name = \"\"\n # access_policy omitted to remove any public access and pass the check\n}\n```", + "other": "1. In AWS Console, open Amazon S3 Glacier (Classic)\n2. Go to Vaults and select the target vault\n3. Open the Access policy tab and click Edit\n4. Remove the policy (clear all content) or delete it\n5. Save changes" + }, + "elb_is_in_multiple_az": { + "checkTitle": "Classic Load Balancer is in multiple Availability Zones", + "recommendation": "Design for **multi-AZ high availability**:\n- Enable at least `2` AZs per load balancer\n- Distribute targets evenly and use Auto Scaling across AZs\n- Enable **cross-zone load balancing** to smooth imbalances\n- Regularly test failover and health thresholds\n\nApply **fault isolation** and **defense in depth** principles.", + "recommendationUrl": "https://hub.prowler.com/check/elb_is_in_multiple_az", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: Ensure CLB spans at least two Availability Zones by adding two subnets\nResources:\n :\n Type: AWS::ElasticLoadBalancing::LoadBalancer\n Properties:\n Subnets:\n - # Critical: add a subnet in AZ A to ensure multiple AZs\n - # Critical: add a subnet in a different AZ (>=2 AZs total)\n Listeners:\n - LoadBalancerPort: 80\n InstancePort: 80\n Protocol: HTTP\n```", + "terraform": "```hcl\n# Terraform: Ensure CLB spans at least two Availability Zones by adding two subnets\nresource \"aws_elb\" \"\" {\n name = \"\"\n subnets = [\n \"\", # Critical: subnet in AZ A to ensure multiple AZs\n \"\" # Critical: subnet in different AZ (>=2 AZs total)\n ]\n\n listener {\n lb_port = 80\n lb_protocol = \"http\"\n instance_port = 80\n }\n}\n```", + "other": "1. Open the Amazon EC2 console and go to Load Balancers\n2. Select your Classic Load Balancer (type: classic)\n3. Choose Edit subnets (or the Subnets tab > Edit)\n4. Add a subnet from a different Availability Zone than the existing one (ensure at least two AZs)\n5. Click Save\n6. If your CLB is in EC2-Classic, use Edit Availability Zones instead and select an additional AZ, then Save" + }, + "elb_desync_mitigation_mode": { + "checkTitle": "Classic Load Balancer desync mitigation mode is defensive or strictest", + "recommendation": "Set CLB desync mitigation to **`defensive`** or, where compatible, **`strictest`**. Validate in staging to avoid client breakage. Apply **defense in depth**: enforce strict header handling, pair with WAF controls, and monitor non-compliant request indicators.", + "recommendationUrl": "https://hub.prowler.com/check/elb_desync_mitigation_mode", + "cli": "aws elb modify-load-balancer-attributes --load-balancer-name --load-balancer-attributes '{\"AdditionalAttributes\":[{\"Key\":\"elb.http.desyncmitigationmode\",\"Value\":\"defensive\"}]}'", + "nativeIaC": null, + "terraform": "```hcl\nresource \"aws_elb\" \"\" {\n name = \"\"\n availability_zones = [\"\"]\n\n listener {\n instance_port = 80\n instance_protocol = \"http\"\n lb_port = 80\n lb_protocol = \"http\"\n }\n\n desync_mitigation_mode = \"defensive\" # Critical: sets CLB desync mitigation to defensive to pass the check\n}\n```", + "other": "1. Open the AWS Management Console and go to EC2\n2. Under Load Balancing, select Load Balancers\n3. Select your Classic Load Balancer\n4. On the Attributes tab, click Edit\n5. Set Desync mitigation mode to Defensive or Strictest\n6. Click Save changes" + }, + "elb_ssl_listeners_use_acm_certificate": { + "checkTitle": "Classic Load Balancer HTTPS/SSL listeners use ACM-issued certificates", + "recommendation": "Standardize on **Amazon-issued ACM certificates** for CLB HTTPS/SSL listeners to ensure managed validation and **automatic renewal**.\n\nApply **least privilege** to certificate operations, automate rotation, and monitor certificate health as part of **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/elb_ssl_listeners_use_acm_certificate", + "cli": "aws elb set-load-balancer-listener-ssl-certificate --load-balancer-name --load-balancer-port --ssl-certificate-id ", + "nativeIaC": "```yaml\n# CloudFormation: Attach an Amazon-issued ACM cert to a CLB HTTPS/SSL listener\nResources:\n :\n Type: AWS::ElasticLoadBalancing::LoadBalancer\n Properties:\n AvailabilityZones:\n - \n Listeners:\n - LoadBalancerPort: 443\n InstancePort: 443\n Protocol: HTTPS\n SSLCertificateId: # critical: use Amazon-issued ACM certificate to pass ELB.2\n```", + "terraform": "```hcl\n# Terraform: Attach an Amazon-issued ACM cert to a CLB HTTPS/SSL listener\nresource \"aws_elb\" \"\" {\n availability_zones = [\"\"]\n\n listener {\n lb_port = 443\n lb_protocol = \"https\"\n instance_port = 443\n instance_protocol = \"https\"\n ssl_certificate_id = \"\" # critical: Amazon-issued ACM cert to satisfy ELB.2\n }\n}\n```", + "other": "1. In the AWS Console, go to EC2 > Load Balancing > Load Balancers (Classic)\n2. Select the Classic Load Balancer\n3. Open the Listeners tab and choose the HTTPS/SSL listener\n4. Click Edit (or Change SSL certificate)\n5. Select an ACM certificate that is Amazon-issued (not imported)\n6. Save changes" + }, + "elb_internet_facing": { + "checkTitle": "Elastic Load Balancer is not internet-facing", + "recommendation": "Use `internal` load balancers for private services and restrict exposure with **security groups**, subnets, and allowlists. For public endpoints, apply **defense in depth**: associate an **AWS WAF** web ACL (*when supported*), enforce **TLS**, least-privilege network rules, and consider **Shield** or rate limiting. Regularly review necessity of public access.", + "recommendationUrl": "https://hub.prowler.com/check/elb_internet_facing", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: create an internal load balancer\nResources:\n :\n Type: AWS::ElasticLoadBalancingV2::LoadBalancer\n Properties:\n Scheme: internal # CRITICAL: makes the load balancer internal (not internet-facing)\n Subnets:\n - \n - \n SecurityGroups:\n - \n```", + "terraform": "```hcl\nresource \"aws_lb\" \"\" {\n internal = true # CRITICAL: sets scheme to internal so it's not internet-facing\n subnets = [\"\", \"\"]\n security_groups = [\"\"]\n}\n```", + "other": "1. In AWS Console, go to EC2 > Load Balancers\n2. Click Create load balancer (Application or Network)\n3. Set Scheme to Internal\n4. Select at least two subnets and a security group; recreate listeners/target groups as needed\n5. Create the new load balancer and update DNS to its DNS name\n6. Delete the old internet-facing load balancer" + }, + "elb_ssl_listeners": { + "checkTitle": "Elastic Load Balancer has only HTTPS or SSL listeners", + "recommendation": "Enforce **encryption in transit** by using only `HTTPS`/`TLS` listeners. Redirect `HTTP` to `HTTPS` and retire plaintext listeners. Use trusted certificates (e.g., ACM) and modern TLS policies; align with **zero trust** and **defense in depth**. *If needed*, use end-to-end TLS to targets and monitor certificate health.", + "recommendationUrl": "https://hub.prowler.com/check/elb_ssl_listeners", + "cli": "aws elb delete-load-balancer-listeners --load-balancer-name --load-balancer-ports 80", + "nativeIaC": "```yaml\n# CloudFormation: Classic ELB with only encrypted (HTTPS) listener\nResources:\n :\n Type: AWS::ElasticLoadBalancing::LoadBalancer\n Properties:\n AvailabilityZones:\n - \n Listeners:\n - Protocol: HTTPS # CRITICAL: enforce encrypted listener\n LoadBalancerPort: 443\n InstanceProtocol: HTTP\n InstancePort: 80\n SSLCertificateId: # CRITICAL: required for HTTPS termination\n```", + "terraform": "```hcl\n# Classic ELB with only encrypted (HTTPS) listener\nresource \"aws_elb\" \"\" {\n availability_zones = [\"\"]\n\n listener {\n lb_port = 443\n lb_protocol = \"https\" # CRITICAL: enforce encrypted listener\n instance_port = 80\n instance_protocol = \"http\"\n ssl_certificate_id = \"\" # CRITICAL: required for HTTPS/SSL\n }\n}\n```", + "other": "1. In the AWS console, go to EC2 > Load Balancers (Classic)\n2. Select the load balancer and open the Listeners tab\n3. Click Edit and remove any listener with Protocol HTTP or TCP\n4. Add a listener with Protocol HTTPS (port 443) and select an SSL certificate\n5. Save changes" + }, + "elb_connection_draining_enabled": { + "checkTitle": "Classic Load Balancer has connection draining enabled", + "recommendation": "Enable **connection draining** on all Classic Load Balancers and set a drain interval aligned to typical request latency. Coordinate autoscaling and deployments to allow graceful instance shutdowns. Monitor errors and retries to validate behavior and adjust the `timeout` conservatively to protect **availability** and **integrity**.", + "recommendationUrl": "https://hub.prowler.com/check/elb_connection_draining_enabled", + "cli": "aws elb modify-load-balancer-attributes --load-balancer-name --load-balancer-attributes '{\"ConnectionDraining\":{\"Enabled\":true}}'", + "nativeIaC": "```yaml\n# CloudFormation: Enable connection draining on a Classic Load Balancer\nResources:\n :\n Type: AWS::ElasticLoadBalancing::LoadBalancer\n Properties:\n Listeners:\n - InstancePort: 80\n LoadBalancerPort: 80\n Protocol: HTTP\n AvailabilityZones:\n - us-east-1a\n ConnectionDrainingPolicy:\n Enabled: true # CRITICAL: turns on connection draining so in-flight requests complete\n # Timeout is optional; default 300s is used if omitted\n```", + "terraform": "```hcl\n# Terraform: Enable connection draining on a Classic Load Balancer\nresource \"aws_elb\" \"\" {\n name = \"\"\n availability_zones = [\"us-east-1a\"]\n\n listener {\n lb_port = 80\n lb_protocol = \"http\"\n instance_port = 80\n instance_protocol = \"http\"\n }\n\n connection_draining = true # CRITICAL: enables connection draining so existing connections complete\n # connection_draining_timeout can be omitted (defaults to 300s)\n}\n```", + "other": "1. Open the EC2 console and go to Load Balancers (Classic)\n2. Select the Classic Load Balancer\n3. Choose the Attributes tab, then click Edit\n4. Check Enable connection draining (leave default timeout or set as needed)\n5. Click Save changes" + }, + "elb_logging_enabled": { + "checkTitle": "Elastic Load Balancer has access logs to S3 configured", + "recommendation": "Enable **access logs** to Amazon S3 (`access_logs.s3.enabled=true`). Apply **least privilege** bucket policies, encrypt objects, and restrict read access. Define lifecycle retention and centralize analysis. Monitor for delivery failures and alert on anomalies. Standardize across all load balancers via IaC as part of **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/elb_logging_enabled", + "cli": "aws elb modify-load-balancer-attributes --load-balancer-name --load-balancer-attributes AccessLog={Enabled=true,S3BucketName=}", + "nativeIaC": "```yaml\n# CloudFormation: Enable access logs for a Classic Load Balancer (CLB)\nResources:\n :\n Type: AWS::ElasticLoadBalancing::LoadBalancer\n Properties:\n Listeners:\n - LoadBalancerPort: 80\n InstancePort: 80\n Protocol: HTTP\n AvailabilityZones:\n - \n AccessLoggingPolicy: # CRITICAL: Enables S3 access logs\n Enabled: true # CRITICAL: Turn on access logging\n S3BucketName: # CRITICAL: S3 bucket to store logs\n```", + "terraform": "```hcl\n# Enable access logs for an ELBv2 load balancer (minimal)\nresource \"aws_lb\" \"\" {\n load_balancer_type = \"network\"\n subnets = [\"\", \"\"]\n\n access_logs { # CRITICAL: Enables S3 access logs\n bucket = \"\" # CRITICAL: S3 bucket for logs\n enabled = true # CRITICAL: Turn on access logging\n }\n}\n```", + "other": "1. In the AWS Console, go to EC2 > Load Balancers\n2. Select the load balancer and choose Edit attributes (or the Attributes tab)\n3. Turn on Access logs\n4. Enter the S3 URI (e.g., s3://)\n5. Click Save" + }, + "elb_cross_zone_load_balancing_enabled": { + "checkTitle": "Classic Load Balancer has cross-zone load balancing enabled", + "recommendation": "Set `cross-zone load balancing` to `enabled` on Classic Load Balancers and use at least two AZs.\n\nBalance capacity per AZ, enforce robust health checks with autoscaling, and design for **high availability** so load remains evenly distributed during demand spikes or partial AZ outages.", + "recommendationUrl": "https://hub.prowler.com/check/elb_cross_zone_load_balancing_enabled", + "cli": "aws elb modify-load-balancer-attributes --load-balancer-name --load-balancer-attributes \"{\\\"CrossZoneLoadBalancing\\\":{\\\"Enabled\\\":true}}\"", + "nativeIaC": "```yaml\n# CloudFormation: Enable cross-zone load balancing on a Classic Load Balancer\nResources:\n :\n Type: AWS::ElasticLoadBalancing::LoadBalancer\n Properties:\n CrossZone: true # Critical: enables cross-zone load balancing to pass the check\n Listeners:\n - LoadBalancerPort: 80\n InstancePort: 80\n Protocol: HTTP\n AvailabilityZones:\n - \n```", + "terraform": "```hcl\n# Terraform: Enable cross-zone load balancing on a Classic Load Balancer\nresource \"aws_elb\" \"\" {\n name = \"\"\n\n listener {\n lb_port = 80\n lb_protocol = \"http\"\n instance_port = 80\n instance_protocol = \"http\"\n }\n\n availability_zones = [\"\"]\n\n cross_zone_load_balancing = true # Critical: enables cross-zone load balancing to pass the check\n}\n```", + "other": "1. Open the AWS EC2 console\n2. Go to Load Balancing > Load Balancers and select your Classic Load Balancer\n3. Open the Attributes tab and click Edit\n4. Enable Cross-zone load balancing\n5. Click Save changes" + }, + "elb_insecure_ssl_ciphers": { + "checkTitle": "Elastic Load Balancer HTTPS listeners, if present, use the ELBSecurityPolicy-TLS-1-2-2017-01 policy", + "recommendation": "Standardize on ELB policies enforcing **TLS 1.2+** with modern AEAD ciphers; disable legacy protocols and weak suites. Enable server cipher order, retire outdated policies, and review regularly for crypto agility. Validate client compatibility, use strong certificates, and monitor negotiation results.", + "recommendationUrl": "https://hub.prowler.com/check/elb_insecure_ssl_ciphers", + "cli": "aws elb set-load-balancer-policies-of-listener --load-balancer-name --load-balancer-port 443 --policy-names ELBSecurityPolicy-TLS-1-2-2017-01", + "nativeIaC": "```yaml\n# CloudFormation: Classic ELB with TLS 1.2-only security policy on HTTPS listener\nResources:\n :\n Type: AWS::ElasticLoadBalancing::LoadBalancer\n Properties:\n AvailabilityZones:\n - \n Listeners:\n - LoadBalancerPort: 443\n InstancePort: 443\n Protocol: HTTPS\n InstanceProtocol: HTTPS\n SSLCertificateId: \n PolicyNames:\n - ELBSecurityPolicy-TLS-1-2-2017-01 # Critical: attach TLS 1.2-only policy to the HTTPS listener\n Policies:\n - PolicyName: ELBSecurityPolicy-TLS-1-2-2017-01 # Critical: create policy referencing the predefined TLS 1.2 policy\n PolicyType: SSLNegotiationPolicyType\n Attributes:\n - Name: Reference-Security-Policy\n Value: ELBSecurityPolicy-TLS-1-2-2017-01 # Critical: enforce TLS 1.2-only\n```", + "terraform": "```hcl\n# Create and attach TLS 1.2-only policy to a Classic ELB HTTPS listener\nresource \"aws_load_balancer_policy\" \"\" {\n load_balancer_name = \"\"\n policy_name = \"ELBSecurityPolicy-TLS-1-2-2017-01\" # Critical: policy named as required by the check\n policy_type_name = \"SSLNegotiationPolicyType\"\n\n policy_attributes {\n name = \"Reference-Security-Policy\"\n value = \"ELBSecurityPolicy-TLS-1-2-2017-01\" # Critical: reference the predefined TLS 1.2 policy\n }\n}\n\nresource \"aws_load_balancer_listener_policy\" \"\" {\n load_balancer_name = \"\"\n load_balancer_port = 443\n policy_names = [aws_load_balancer_policy..policy_name] # Critical: attach policy to HTTPS listener\n}\n```", + "other": "1. Open the AWS Management Console and go to EC2\n2. In the left menu, under Load Balancing, click Load Balancers\n3. Select your Classic Load Balancer\n4. On the Listeners tab, click Manage listeners (or Edit)\n5. Select the HTTPS (port 443) listener and under Security policy choose ELBSecurityPolicy-TLS-1-2-2017-01\n6. Click Save changes" + }, + "autoscaling_group_launch_configuration_no_public_ip": { + "checkTitle": "Auto Scaling group associated launch configuration does not assign a public IP address", + "recommendation": "Place instances in private subnets and disable public addressing (`AssociatePublicIpAddress=false`). Publish services via **load balancers** or **private endpoints**, enforce **least privilege** security groups, and use **SSM**, VPN, or a hardened bastion for admin access. Prefer **launch templates** to standardize network controls.", + "recommendationUrl": "https://hub.prowler.com/check/autoscaling_group_launch_configuration_no_public_ip", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation Launch Configuration without public IPs\nResources:\n :\n Type: AWS::AutoScaling::LaunchConfiguration\n Properties:\n ImageId: \n InstanceType: \n AssociatePublicIpAddress: false # Critical: disables assigning public IPs to instances\n```", + "terraform": "```hcl\n# Launch Configuration without public IPs\nresource \"aws_launch_configuration\" \"\" {\n image_id = \"\"\n instance_type = \"\"\n associate_public_ip_address = false # Critical: disables assigning public IPs\n}\n```", + "other": "1. In the AWS console, go to EC2 > Auto Scaling > Launch configurations and click Create launch configuration\n2. Use the same AMI and instance type as the current group; under Advanced details set IP address type to Do not assign a public IP address\n3. Create the launch configuration\n4. Go to EC2 > Auto Scaling Groups, select your group, click Edit next to Launch configuration, choose the new configuration, and click Update" + }, + "autoscaling_group_launch_configuration_requires_imdsv2": { + "checkTitle": "Auto Scaling group enforces IMDSv2 or disables the instance metadata service", + "recommendation": "Require **IMDSv2** for Auto Scaling-launched instances by setting `http_tokens=required` when metadata is `enabled`. *If metadata is not needed*, disable it.\n\nApply **least privilege** to instance roles, set IMDSv2 as an account default, and use **defense in depth** (egress filtering, SSRF protections) to limit exposure.", + "recommendationUrl": "https://hub.prowler.com/check/autoscaling_group_launch_configuration_requires_imdsv2", + "cli": "aws autoscaling create-launch-configuration --launch-configuration-name --image-id --instance-type --metadata-options 'HttpTokens=required,HttpEndpoint=enabled'", + "nativeIaC": "```yaml\n# CloudFormation: ASG launch configuration enforces IMDSv2\nResources:\n LaunchConfig:\n Type: AWS::AutoScaling::LaunchConfiguration\n Properties:\n ImageId: \n InstanceType: \n MetadataOptions:\n HttpTokens: required # critical: require IMDSv2 tokens (disables IMDSv1)\n HttpEndpoint: enabled # critical: keep IMDS enabled while enforcing v2\n\n AutoScalingGroup:\n Type: AWS::AutoScaling::AutoScalingGroup\n Properties:\n LaunchConfigurationName: !Ref LaunchConfig\n MinSize: 1\n MaxSize: 1\n VPCZoneIdentifier:\n - \n```", + "terraform": "```hcl\n# ASG launch configuration enforces IMDSv2\nresource \"aws_launch_configuration\" \"example\" {\n image_id = \"\"\n instance_type = \"\"\n\n metadata_options {\n http_tokens = \"required\" # critical: require IMDSv2 tokens (blocks IMDSv1)\n http_endpoint = \"enabled\" # critical: IMDS enabled while enforcing v2\n }\n}\n\nresource \"aws_autoscaling_group\" \"example\" {\n launch_configuration = aws_launch_configuration.example.name\n min_size = 1\n max_size = 1\n vpc_zone_identifier = [\"\"]\n}\n```", + "other": "1. In the AWS Console, go to EC2 > Auto Scaling > Launch configurations\n2. Click Create launch configuration and choose the same AMI and instance type used by the group\n3. Expand Advanced details and set Metadata options to: Metadata accessible = Enabled, Metadata version = V2 only (token required)\n4. Create the launch configuration\n5. Go to EC2 > Auto Scaling > Auto Scaling groups, select the group, click Edit\n6. Under Launch configuration, select the new launch configuration and Save\n7. (Alternative) To disable IMDS entirely: when creating the launch configuration, set Metadata accessible = Disabled" + }, + "autoscaling_group_capacity_rebalance_enabled": { + "checkTitle": "Amazon EC2 Auto Scaling group has Capacity Rebalancing enabled", + "recommendation": "Enable **Capacity Rebalancing** for ASGs that use Spot.\n\nApply resilience practices:\n- Prefer `price-capacity-optimized` allocation\n- Keep headroom below `MaxSize`\n- Use lifecycle hooks to drain/deregister\n- Design stateless, interruption-tolerant workloads (least privilege and defense-in-depth for dependencies)", + "recommendationUrl": "https://hub.prowler.com/check/autoscaling_group_capacity_rebalance_enabled", + "cli": "aws autoscaling update-auto-scaling-group --auto-scaling-group-name --capacity-rebalance", + "nativeIaC": "```yaml\n# CloudFormation: Enable Capacity Rebalancing on an Auto Scaling group\nResources:\n :\n Type: AWS::AutoScaling::AutoScalingGroup\n Properties:\n MinSize: \"1\"\n MaxSize: \"1\"\n AvailabilityZones: [\"\"]\n LaunchTemplate:\n LaunchTemplateName: \n Version: \"$Default\"\n CapacityRebalance: true # CRITICAL: Enables proactive replacement of at-risk Spot instances\n```", + "terraform": "```hcl\n# Terraform: Enable Capacity Rebalancing on an Auto Scaling group\nresource \"aws_autoscaling_group\" \"\" {\n name = \"\"\n min_size = 1\n max_size = 1\n desired_capacity = 1\n availability_zones = [\"\"]\n\n launch_template {\n id = \"\"\n version = \"$Latest\"\n }\n\n capacity_rebalance = true # CRITICAL: Turns on Capacity Rebalancing\n}\n```", + "other": "1. In the AWS Console, go to EC2 > Auto Scaling Groups\n2. Select and open the Details tab\n3. Click Allocation strategies > Edit, check Capacity rebalancing\n4. Click Update/Save" + }, + "autoscaling_group_multiple_az": { + "checkTitle": "Auto Scaling group uses multiple Availability Zones", + "recommendation": "Distribute each group across at least two **Availability Zones** to design for failure. Use a load balancer to spread traffic and health-based replacement to sustain capacity. Apply **resilience** and **fault isolation** principles so service continues during zonal degradation.", + "recommendationUrl": "https://hub.prowler.com/check/autoscaling_group_multiple_az", + "cli": "aws autoscaling update-auto-scaling-group --auto-scaling-group-name --vpc-zone-identifier \",\"", + "nativeIaC": "```yaml\n# CloudFormation: ensure ASG spans multiple AZs\nResources:\n :\n Type: AWS::AutoScaling::AutoScalingGroup\n Properties:\n MinSize: '1'\n MaxSize: '1'\n LaunchTemplate:\n LaunchTemplateId: \n Version: '$Latest'\n VPCZoneIdentifier:\n - \n - # CRITICAL: Add a second subnet in a different AZ to ensure multiple AZs\n```", + "terraform": "```hcl\n# Terraform: ensure ASG spans multiple AZs\nresource \"aws_autoscaling_group\" \"\" {\n min_size = 1\n max_size = 1\n\n launch_template {\n id = \"\"\n version = \"$Latest\"\n }\n\n vpc_zone_identifier = [\n \"\",\n \"\" # CRITICAL: two subnets in different AZs to pass the check\n ]\n}\n```", + "other": "1. In the AWS Console, go to EC2 > Auto Scaling Groups\n2. Select the group and open the Details tab\n3. Click Network > Edit\n4. In Subnets, add one more subnet from a different Availability Zone\n5. Click Update to save" + }, + "autoscaling_group_elb_health_check_enabled": { + "checkTitle": "Auto Scaling group associated with a load balancer has ELB health checks enabled", + "recommendation": "Enable **ELB health checks** for Auto Scaling groups behind load balancers to reflect real client reachability. Apply **high availability** and **defense in depth** by:\n- Using application-appropriate LB probes\n- Tuning grace and threshold settings to avoid flapping\n- Monitoring health metrics and alerts", + "recommendationUrl": "https://hub.prowler.com/check/autoscaling_group_elb_health_check_enabled", + "cli": "aws autoscaling update-auto-scaling-group --auto-scaling-group-name --health-check-type ELB", + "nativeIaC": "```yaml\n# CloudFormation: Enable ELB health checks for the Auto Scaling group\nResources:\n :\n Type: AWS::AutoScaling::AutoScalingGroup\n Properties:\n HealthCheckType: ELB # Remediation: use ELB health checks so the ASG evaluates instance health via the load balancer\n```", + "terraform": "```hcl\n# Enable ELB health checks on the Auto Scaling group\nresource \"aws_autoscaling_group\" \"\" {\n health_check_type = \"ELB\" # Remediation: ensures ASG uses load balancer health status\n}\n```", + "other": "1. In AWS Console, go to EC2 > Auto Scaling Groups\n2. Select the Auto Scaling group\n3. On the Details tab, click Edit under Health checks\n4. Under Additional health check types, select Elastic Load Balancing (ELB)\n5. Click Update/Save" + }, + "autoscaling_group_multiple_instance_types": { + "checkTitle": "Auto Scaling group spans multiple Availability Zones and has multiple instance types per Availability Zone", + "recommendation": "Adopt a **mixed instances** strategy for resilience:\n- Use diverse instance families and sizes per AZ\n- Distribute capacity across multiple AZs\n- Favor allocation approaches that tolerate spot/on-demand scarcity\nApply **redundancy** and **fault tolerance** principles and validate scaling policies to avoid single points of capacity failure.", + "recommendationUrl": "https://hub.prowler.com/check/autoscaling_group_multiple_instance_types", + "cli": "aws autoscaling update-auto-scaling-group --auto-scaling-group-name --mixed-instances-policy '{\"LaunchTemplate\":{\"LaunchTemplateSpecification\":{\"LaunchTemplateName\":\"\",\"Version\":\"$Latest\"},\"Overrides\":[{\"InstanceType\":\"\"},{\"InstanceType\":\"\"}]}}' --vpc-zone-identifier \",\"", + "nativeIaC": "```yaml\n# CloudFormation: Ensure ASG uses multiple instance types across multiple AZs\nResources:\n :\n Type: AWS::AutoScaling::AutoScalingGroup\n Properties:\n MinSize: \"1\"\n MaxSize: \"1\"\n VPCZoneIdentifier:\n - # CRITICAL: Use subnets in different AZs to span multiple AZs\n - # CRITICAL: Ensures at least two Availability Zones\n MixedInstancesPolicy:\n LaunchTemplate:\n LaunchTemplateSpecification:\n LaunchTemplateName: \n Version: $Latest\n Overrides:\n - InstanceType: # CRITICAL: Multiple instance types per AZ\n - InstanceType: # CRITICAL: Multiple instance types per AZ\n```", + "terraform": "```hcl\n# Terraform: Ensure ASG uses multiple instance types across multiple AZs\nresource \"aws_autoscaling_group\" \"\" {\n name = \"\"\n min_size = 1\n max_size = 1\n vpc_zone_identifier = [\"\", \"\"] # CRITICAL: Subnets in different AZs\n\n mixed_instances_policy {\n launch_template {\n launch_template_specification {\n launch_template_name = \"\"\n version = \"$Latest\"\n }\n override { instance_type = \"\" } # CRITICAL: Multiple instance types per AZ\n override { instance_type = \"\" } # CRITICAL: Multiple instance types per AZ\n }\n }\n}\n```", + "other": "1. In the AWS Console, go to EC2 > Auto Scaling Groups and select \n2. Click Edit\n3. Under Network, add at least two subnets in different Availability Zones\n4. Under Launch options, choose Mixed instance types\n5. Select your Launch template and set Version to $Latest\n6. Add at least two Instance types in Overrides\n7. Click Update to save" + }, + "autoscaling_find_secrets_ec2_launch_configuration": { + "checkTitle": "[DEPRECATED] EC2 Auto Scaling launch configuration user data contains no secrets", + "recommendation": "Never place secrets in `User Data`.\n- Use a managed secret store with an instance role to fetch at runtime\n- Enforce **least privilege**, rotate secrets, and avoid writing secrets to logs\n- Prefer short-lived, scoped credentials and layer controls for **defense in depth**", + "recommendationUrl": "https://hub.prowler.com/check/autoscaling_find_secrets_ec2_launch_configuration", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation Launch Configuration without secrets in UserData\nResources:\n :\n Type: AWS::AutoScaling::LaunchConfiguration\n Properties:\n ImageId: \n InstanceType: \n UserData: '' # Critical: empty user data ensures no secrets are present\n```", + "terraform": "```hcl\n# Launch configuration with no secrets in user data\nresource \"aws_launch_configuration\" \"\" {\n image_id = \"\"\n instance_type = \"\"\n user_data = \"\" # Critical: empty user data ensures no secrets are present\n}\n```", + "other": "1. In the AWS Console, go to EC2 > Launch configurations and click Create launch configuration\n2. Reuse the same AMI and instance type; leave User data empty\n3. Go to EC2 > Auto Scaling groups, select the group using the failing launch configuration, click Edit\n4. Under Launch options, select the new launch configuration and Save\n5. After the ASG is updated, delete the old launch configuration" + }, + "autoscaling_group_using_ec2_launch_template": { + "checkTitle": "Amazon EC2 Auto Scaling group uses an EC2 launch template", + "recommendation": "Adopt **launch templates** for all Auto Scaling groups and include them in any `mixed instances policy`. Use versioning with approvals, enforce hardened defaults (least privilege roles, secure metadata like `IMDSv2`, encrypted storage), and apply change control to ensure consistency and defense in depth.", + "recommendationUrl": "https://hub.prowler.com/check/autoscaling_group_using_ec2_launch_template", + "cli": "aws autoscaling update-auto-scaling-group --auto-scaling-group-name --launch-template LaunchTemplateId=", + "nativeIaC": "```yaml\n# CloudFormation: attach a Launch Template to the ASG\nResources:\n ASG:\n Type: AWS::AutoScaling::AutoScalingGroup\n Properties:\n MinSize: '0'\n MaxSize: '1'\n VPCZoneIdentifier:\n - \n LaunchTemplate: # critical: ensures the ASG uses an EC2 launch template (fixes the check)\n LaunchTemplateId: # references the EC2 Launch Template\n Version: $Default\n```", + "terraform": "```hcl\n# Terraform: attach a Launch Template to the ASG\nresource \"aws_autoscaling_group\" \"example\" {\n min_size = 0\n max_size = 1\n vpc_zone_identifier = [\"\"]\n\n launch_template {\n id = \"\" # critical: ensures the ASG uses an EC2 launch template (fixes the check)\n version = \"$Default\"\n }\n}\n```", + "other": "1. In the AWS console, go to EC2 > Auto Scaling Groups\n2. Select and click Edit\n3. Under \"Launch template or configuration\", choose Launch template and select your template and version (Default or Latest)\n4. Click Update to save" + }, + "ssm_managed_compliant_patching": { + "checkTitle": "EC2 managed instance is compliant with Systems Manager patching requirements", + "recommendation": "Adopt **automated patch management** with Systems Manager: enroll EC2 as managed nodes, define strict **patch baselines**, run frequent **compliance scans**, and **install critical updates** promptly.\n\nApply **defense in depth**: least-privileged roles for patching, staged rollouts, maintenance windows, and centralized compliance reporting with alerting.", + "recommendationUrl": "https://hub.prowler.com/check/ssm_managed_compliant_patching", + "cli": "aws ssm send-command --instance-ids --document-name AWS-RunPatchBaseline --parameters Operation=Install", + "nativeIaC": "```yaml\n# Create an SSM Association to install missing patches on the instance\nResources:\n :\n Type: AWS::SSM::Association\n Properties:\n Name: AWS-RunPatchBaseline\n InstanceId: \n Parameters:\n Operation:\n - Install # Critical: installs missing patches so the instance becomes COMPLIANT\n```", + "terraform": "```hcl\n# Run AWS-RunPatchBaseline to install missing patches on the instance\nresource \"aws_ssm_association\" \"\" {\n name = \"AWS-RunPatchBaseline\"\n instance_id = \"\"\n parameters = {\n Operation = [\"Install\"] # Critical: installs patches to achieve COMPLIANT status\n }\n}\n```", + "other": "1. Open AWS Console > Systems Manager > Run Command\n2. Click Run command\n3. Select document: AWS-RunPatchBaseline\n4. In Parameters, set Operation = Install\n5. In Targets, select the non-compliant instance\n6. Click Run; wait for command to complete and verify Compliance shows COMPLIANT" + }, + "ssm_documents_set_as_public": { + "checkTitle": "SSM document is not public and shared only with trusted AWS accounts", + "recommendation": "Apply **least privilege** to document distribution:\n- Keep documents private; share only with specific trusted account IDs\n- Enable account-level block public sharing for documents\n- Remove secrets from content; use secure parameters\n- Limit who can share or run documents; require reviews and version control", + "recommendationUrl": "https://hub.prowler.com/check/ssm_documents_set_as_public", + "cli": "aws ssm modify-document-permission --name --permission-type Share --account-ids-to-remove all", + "nativeIaC": null, + "terraform": "```hcl\nresource \"aws_ssm_document\" \"\" {\n name = \"\"\n document_type = \"Command\"\n content = jsonencode({\n schemaVersion = \"2.2\"\n mainSteps = []\n })\n # Critical: no permissions block -> document remains private (not public/shared)\n}\n```", + "other": "1. Open AWS Systems Manager > Documents\n2. Select the document > Permissions tab > Edit\n3. Select Private (remove Public/'all')\n4. Remove any non-trusted AWS account IDs\n5. Save" + }, + "ssm_document_secrets": { + "checkTitle": "SSM document contains no secrets", + "recommendation": "Avoid embedding secrets. Store them in **Secrets Manager** or **Parameter Store** as `SecureString` (KMS-encrypted) and reference at runtime.\n\nApply **least privilege** to documents and secrets, prefer **short-lived role credentials**, rotate credentials, continuously scan/audit documents, and enforce **separation of duties** for authoring and approval.", + "recommendationUrl": "https://hub.prowler.com/check/ssm_document_secrets", + "cli": "aws ssm update-document --name --content file://.json", + "nativeIaC": "```yaml\nResources:\n :\n Type: AWS::SSM::Document\n Properties:\n DocumentType: Command\n Content:\n schemaVersion: '2.2'\n mainSteps:\n - action: aws:runShellScript\n inputs:\n runCommand:\n # Critical: reference a SecureString parameter instead of hardcoding a secret\n # This avoids embedding secrets in the document content\n - \"export PASSWORD='{{ssm-secure:/path/to/secret}}'\"\n```", + "terraform": "```hcl\nresource \"aws_ssm_document\" \"\" {\n name = \"\"\n document_type = \"Command\"\n\n content = jsonencode({\n schemaVersion = \"2.2\"\n mainSteps = [{\n action = \"aws:runShellScript\"\n name = \"run\"\n inputs = {\n runCommand = [\n // Critical: use ssm-secure dynamic reference to avoid hardcoded secrets\n \"export PASSWORD='{{ssm-secure:/path/to/secret}}'\"\n ]\n }\n }]\n })\n}\n```", + "other": "1. In the AWS Console, go to Systems Manager > Parameter Store > Create parameter\n2. Set Name to /path/to/secret, Type to SecureString, enter the secret value, and click Create parameter\n3. Go to Systems Manager > Documents, select the document, then Actions > Edit content\n4. Remove any hardcoded secrets and reference the SecureString parameter, e.g.: {{ssm-secure:/path/to/secret}}\n5. Save to create a new version and set it as Default\n6. Re-run the check to confirm it passes" + }, + "rds_instance_storage_encrypted": { + "checkTitle": "RDS DB instance storage is encrypted at rest", + "recommendation": "Enable **encryption at rest** for all RDS instances. Prefer **customer-managed KMS keys** to control rotation and fine-grained access, applying **least privilege** and **defense in depth**. Restrict key usage, monitor key activity, and manage key lifecycle. Migrate unencrypted instances via encrypted snapshot copy and restore.", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_storage_encrypted", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: create an encrypted RDS DB instance\nResources:\n :\n Type: AWS::RDS::DBInstance\n Properties:\n DBInstanceClass: \"\"\n Engine: \"\"\n AllocatedStorage: 20\n MasterUsername: \"\"\n MasterUserPassword: \"\"\n StorageEncrypted: true # CRITICAL: enables encryption at rest so the instance passes the check\n```", + "terraform": "```hcl\n# Terraform: create an encrypted RDS DB instance\nresource \"aws_db_instance\" \"\" {\n engine = \"\"\n instance_class = \"\"\n username = \"\"\n password = \"\"\n allocated_storage = 20\n\n storage_encrypted = true # CRITICAL: enables encryption at rest to pass the check\n}\n```", + "other": "1. In the AWS Console, go to RDS > Databases, select the unencrypted DB instance, then choose Actions > Take snapshot.\n2. After the snapshot is available, go to Snapshots, select it, choose Actions > Copy snapshot, enable encryption, and select a KMS key (or aws/rds).\n3. When the encrypted copy is ready, select it and choose Actions > Restore snapshot to create a new (encrypted) DB instance.\n4. Update your application/endpoint to use the new encrypted DB instance.\n5. Decommission the old unencrypted instance after cutover." + }, + "rds_cluster_iam_authentication_enabled": { + "checkTitle": "RDS cluster has IAM authentication enabled", + "recommendation": "Enable **IAM database authentication** on supported clusters and enforce **least privilege**. Grant only necessary `rds-db:connect` permissions to specific principals, prefer role-based access for workloads to obtain short-lived tokens, require **TLS**, and deprecate static DB passwords. Pair with auditing and segmentation for **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/rds_cluster_iam_authentication_enabled", + "cli": "aws rds modify-db-cluster --db-cluster-identifier --enable-iam-database-authentication --apply-immediately", + "nativeIaC": "```yaml\n# CloudFormation: Enable IAM authentication on an existing RDS/Aurora DB Cluster\nResources:\n :\n Type: AWS::RDS::DBCluster\n Properties:\n EnableIAMDatabaseAuthentication: true # Critical: turns on IAM DB auth for the cluster\n```", + "terraform": "```hcl\n# Enable IAM authentication on an existing RDS/Aurora cluster\nresource \"aws_rds_cluster\" \"\" {\n cluster_identifier = \"\"\n iam_database_authentication_enabled = true # Critical: enables IAM DB auth so the check passes\n}\n```", + "other": "1. In the AWS Console, go to RDS > Databases\n2. Select the DB Cluster (not the instances)\n3. Click Modify\n4. In Database authentication, enable IAM database authentication\n5. Choose Apply immediately and click Modify cluster" + }, + "rds_cluster_storage_encrypted": { + "checkTitle": "RDS cluster storage is encrypted", + "recommendation": "Create clusters with `StorageEncrypted=true` using **AWS KMS**, preferably **customer-managed keys**. Apply **least privilege** to key usage, enable rotation and monitoring, and separate key administration from DB operations. Ensure snapshots and cross-account copies remain encrypted for **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/rds_cluster_storage_encrypted", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: Create an encrypted RDS/Aurora DB cluster\nResources:\n :\n Type: AWS::RDS::DBCluster\n Properties:\n Engine: \n MasterUsername: \n MasterUserPassword: \n StorageEncrypted: true # Critical: enables encryption at rest for the cluster\n```", + "terraform": "```hcl\n# Terraform: Create an encrypted RDS/Aurora DB cluster\nresource \"aws_rds_cluster\" \"\" {\n engine = \"\"\n master_username = \"\"\n master_password = \"\"\n storage_encrypted = true # Critical: enables encryption at rest for the cluster\n}\n```", + "other": "1. In the AWS Console, go to RDS > Databases > Create database\n2. Select your engine (Aurora or Multi-AZ DB cluster)\n3. In the configuration, enable Storage encryption (Enable encryption)\n4. Leave the KMS key as default (aws/rds or aws/aurora) unless you require a custom key\n5. Create the cluster, migrate traffic, then delete the unencrypted cluster" + }, + "rds_instance_event_subscription_security_groups": { + "checkTitle": "RDS event subscription for DB security groups is enabled for configuration change and failure events", + "recommendation": "Create or update an **RDS event subscription** for source type `db-security-group` including `configuration change` and `failure`. Route alerts to monitored channels, restrict topic access (**least privilege**), integrate with **incident response**, and enforce change control and **separation of duties** for security group updates.", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_event_subscription_security_groups", + "cli": "aws rds create-event-subscription --subscription-name --sns-topic-arn --source-type db-security-group --event-categories \"configuration change\" \"failure\"", + "nativeIaC": "```yaml\nResources:\n :\n Type: AWS::RDS::EventSubscription\n Properties:\n SnsTopicArn: \n SourceType: db-security-group # Critical: subscribe to DB security group events\n EventCategories: # Critical: required categories\n - configuration change\n - failure\n```", + "terraform": "```hcl\nresource \"aws_db_event_subscription\" \"\" {\n name = \"\"\n sns_topic = \"\"\n source_type = \"db-security-group\" # Critical: DB security group events\n event_categories = [\"configuration change\", \"failure\"] # Critical: required categories\n}\n```", + "other": "1. Open the AWS Console > RDS > Event subscriptions\n2. Click Create event subscription\n3. Set Name to and select an existing SNS topic\n4. Set Source type to Security group\n5. Under Event categories, select Configuration change and Failure\n6. Leave Targets as All security groups (no specific IDs)\n7. Ensure Enabled is On and click Create" + }, + "rds_instance_copy_tags_to_snapshots": { + "checkTitle": "RDS DB instance has copy tags to snapshots enabled", + "recommendation": "Enable `CopyTagsToSnapshot` on non-Aurora RDS instances so snapshots inherit required metadata. Establish a consistent **tag taxonomy** and automate enforcement to support **least privilege** via ABAC, cost tracking, and retention controls. For Aurora, configure tag copy at the cluster level.", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_copy_tags_to_snapshots", + "cli": "aws rds modify-db-instance --db-instance-identifier --copy-tags-to-snapshot --apply-immediately", + "nativeIaC": "```yaml\n# CloudFormation: enable copying tags to snapshots on an RDS DB instance\nResources:\n :\n Type: AWS::RDS::DBInstance\n Properties:\n CopyTagsToSnapshot: true # Critical: ensures DB instance tags are copied to snapshots\n```", + "terraform": "```hcl\n# Terraform: enable copying tags to snapshots on an RDS DB instance\nresource \"aws_db_instance\" \"\" {\n copy_tags_to_snapshot = true # Critical: ensures DB instance tags are copied to snapshots\n}\n```", + "other": "1. In the AWS Console, go to RDS > Databases and select the non-Aurora DB instance\n2. Click Modify\n3. Under Additional configuration, enable Copy tags to snapshots\n4. Check Apply immediately and click Modify DB instance" + }, + "rds_instance_minor_version_upgrade_enabled": { + "checkTitle": "RDS instance has minor version upgrade enabled", + "recommendation": "Enable `auto_minor_version_upgrade` on RDS instances so minor releases are applied promptly. Use maintenance windows and stage testing to limit impact. Follow **defense in depth** and **least privilege**; keep reliable backups and Multi-AZ to preserve continuity if upgrades require rollback.", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_minor_version_upgrade_enabled", + "cli": "aws rds modify-db-instance --db-instance-identifier --auto-minor-version-upgrade --apply-immediately", + "nativeIaC": "```yaml\n# CloudFormation: Enable auto minor version upgrades on an RDS instance\nResources:\n :\n Type: AWS::RDS::DBInstance\n Properties:\n DBInstanceIdentifier: \n DBInstanceClass: db.t3.micro\n Engine: mysql\n MasterUsername: \n MasterUserPassword: \n AllocatedStorage: '20'\n AutoMinorVersionUpgrade: true # Critical: ensures RDS applies minor engine updates automatically\n```", + "terraform": "```hcl\n# Enable auto minor version upgrades on an RDS instance\nresource \"aws_db_instance\" \"\" {\n allocated_storage = 20\n engine = \"mysql\"\n instance_class = \"db.t3.micro\"\n username = \"\"\n password = \"\"\n auto_minor_version_upgrade = true # Critical: turns on automatic minor engine upgrades\n}\n```", + "other": "1. In the AWS Console, go to RDS > Databases\n2. Select the DB instance and click Modify\n3. Find \"Auto minor version upgrade\" and set it to Enable\n4. Click Continue, check Apply immediately, then click Modify DB instance" + }, + "rds_instance_non_default_port": { + "checkTitle": "RDS instance uses a non-default port for its engine", + "recommendation": "Use a **non-default DB port** and enforce **defense in depth**:\n- Apply **least-privilege** network rules\n- Keep databases in **private subnets**; avoid public exposure\n- Require strong authentication and audit logging\n\n*Update client connection strings and security rules when the port changes.*", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_non_default_port", + "cli": "aws rds modify-db-instance --db-instance-identifier --db-port ", + "nativeIaC": "```yaml\n# CloudFormation: set a non-default port on an RDS instance\nResources:\n :\n Type: AWS::RDS::DBInstance\n Properties:\n Port: # Critical: use a non-default DB engine port to pass the check\n```", + "terraform": "```hcl\n# Terraform: set a non-default port on an RDS instance\nresource \"aws_db_instance\" \"\" {\n port = # Critical: use a non-default DB engine port to pass the check\n}\n```", + "other": "1. In the AWS Console, go to Amazon RDS > Databases\n2. Select the DB instance and click Modify\n3. Set \"Database port\" to a non-default value for the engine (e.g., not 3306, 5432, 1521, 1433, or 50000)\n4. Click Continue, then Modify DB instance" + }, + "rds_instance_default_admin": { + "checkTitle": "RDS instance does not use the default master username (admin or postgres)", + "recommendation": "Adopt a **unique, non-default admin username** for each database and avoid enabling default accounts.\n- Enforce **least privilege** with separate admin and app users\n- Use strong, rotated secrets in a manager and prefer **IAM DB authentication**\n- Restrict network exposure and audit authentication activity", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_default_admin", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: Create an RDS instance with a non-default master username\nResources:\n :\n Type: AWS::RDS::DBInstance\n Properties:\n Engine: mysql\n DBInstanceClass: db.t3.micro\n AllocatedStorage: 20\n MasterUsername: # Critical: use a custom admin username (not \"admin\" or \"postgres\")\n MasterUserPassword: \n```", + "terraform": "```hcl\n# Terraform: Create an RDS instance with a non-default master username\nresource \"aws_db_instance\" \"\" {\n engine = \"mysql\"\n instance_class = \"db.t3.micro\"\n allocated_storage = 20\n username = \"\" # Critical: custom admin username (not \"admin\" or \"postgres\")\n password = \"\"\n}\n```", + "other": "1. In the AWS Console, go to RDS > Databases and click Create database\n2. Choose your engine and select Standard create\n3. In Settings, set Master username to a value that is not \"admin\" or \"postgres\"\n4. Complete creation and note the new endpoint\n5. Migrate data from the old instance to the new one (e.g., dump/restore or replication)\n6. Update applications to use the new endpoint, then delete the old instance\n7. If the instance is part of an Aurora cluster, create a new cluster with a non-default master username and migrate to it" + }, + "rds_cluster_protected_by_backup_plan": { + "checkTitle": "RDS cluster is protected by an AWS Backup plan", + "recommendation": "Include RDS clusters in an **AWS Backup backup plan**. Apply **defense in depth**: define schedules and retention, enable immutable vault controls and cross-Region copies, use tags for consistent coverage, enforce **least privilege** for backup roles, and regularly test restores to validate RPO/RTO.", + "recommendationUrl": "https://hub.prowler.com/check/rds_cluster_protected_by_backup_plan", + "cli": "aws backup create-backup-selection --backup-plan-id --backup-selection '{\"SelectionName\":\"rds-clusters\",\"IamRoleArn\":\"arn:aws:iam:::role/service-role/AWSBackupDefaultServiceRole\",\"Resources\":[\"arn:aws:rds:*:*:cluster:*\"]}'", + "nativeIaC": "```yaml\n# CloudFormation: assign RDS clusters to an AWS Backup plan\nResources:\n :\n Type: AWS::Backup::BackupSelection\n Properties:\n BackupPlanId: \"\"\n BackupSelection:\n SelectionName: \"rds-clusters\"\n IamRoleArn: \"arn:aws:iam:::role/service-role/AWSBackupDefaultServiceRole\"\n Resources:\n - \"arn:aws:rds:*:*:cluster:*\" # Critical: includes all RDS clusters in the plan to mark them protected\n```", + "terraform": "```hcl\n# Assign RDS clusters to an existing AWS Backup plan\nresource \"aws_backup_selection\" \"\" {\n name = \"rds-clusters\"\n plan_id = \"\"\n iam_role_arn = \"arn:aws:iam:::role/service-role/AWSBackupDefaultServiceRole\"\n\n resources = [\n \"arn:aws:rds:*:*:cluster:*\" # Critical: includes all RDS clusters in the plan to pass the check\n ]\n}\n```", + "other": "1. In the AWS Backup console, go to Settings > Service opt-in and enable RDS if it is not enabled.\n2. Go to Backup plans and select an existing plan (create a minimal plan if none exists).\n3. Click Assign resources.\n4. Set a name and choose IAM role: AWSBackupDefaultServiceRole.\n5. Under Resources, choose By resource ID and select the target RDS DB cluster (or use the ARN), then click Assign resources.\n6. The cluster now appears as protected by the backup plan." + }, + "rds_cluster_multi_az": { + "checkTitle": "RDS cluster has Multi-AZ enabled", + "recommendation": "Enable **Multi-AZ** for production DB clusters to ensure cross-AZ redundancy and **automatic failover**. Choose a model that meets your SLA (one standby or two readable standbys; Aurora spans 3 AZs). Place subnets in distinct AZs, implement connection retries, and regularly test failover to validate **RTO/RPO** and readiness.", + "recommendationUrl": "https://hub.prowler.com/check/rds_cluster_multi_az", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: create an RDS Multi-AZ DB cluster (non-Aurora)\nResources:\n :\n Type: AWS::RDS::DBCluster\n Properties:\n Engine: mysql # CRITICAL: using mysql/postgres with the properties below creates a Multi-AZ DB cluster\n DBClusterInstanceClass: db.r6g.large # CRITICAL: required to make it a Multi-AZ DB cluster (creates 1 writer + 2 readers across AZs)\n AllocatedStorage: 100 # CRITICAL: required for Multi-AZ DB clusters\n Iops: 1000 # CRITICAL: required for Multi-AZ DB clusters\n StorageType: io1 # CRITICAL: required for Multi-AZ DB clusters\n MasterUsername: \n MasterUserPassword: \n```", + "terraform": "```hcl\n# Terraform: create an RDS Multi-AZ DB cluster (non-Aurora)\nresource \"aws_rds_cluster\" \"\" {\n cluster_identifier = \"\"\n engine = \"mysql\" # CRITICAL: mysql/postgres with the lines below creates a Multi-AZ DB cluster\n db_cluster_instance_class = \"db.r6g.large\" # CRITICAL: makes this a Multi-AZ DB cluster (1 writer + 2 readers across AZs)\n storage_type = \"io1\" # CRITICAL: required for Multi-AZ DB clusters\n allocated_storage = 100 # CRITICAL: required for Multi-AZ DB clusters\n iops = 1000 # CRITICAL: required for Multi-AZ DB clusters\n master_username = \"\"\n master_password = \"\"\n}\n```", + "other": "1. In the AWS console, go to RDS > Databases > Create database\n2. Engine: select MySQL or PostgreSQL\n3. Under Availability and durability, select Multi-AZ DB cluster\n4. Enter DB cluster identifier and master credentials\n5. Choose a DB instance class and create the database\n6. Migrate data to this new Multi-AZ DB cluster and switch your applications to its endpoint" + }, + "rds_instance_inside_vpc": { + "checkTitle": "RDS instance is deployed in a VPC", + "recommendation": "Deploy all RDS instances in a **VPC**, preferably in **private subnets**. Enforce **least privilege** with security groups, network ACLs, and restrictive routing. Use private connectivity (peering, VPN, Direct Connect), avoid public exposure, and apply **defense in depth** through segmentation and monitoring.", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_inside_vpc", + "cli": "aws rds modify-db-instance --db-instance-identifier --db-subnet-group-name --apply-immediately", + "nativeIaC": "```yaml\n# CloudFormation: move RDS instance into a VPC by assigning a DB subnet group\nResources:\n :\n Type: AWS::RDS::DBSubnetGroup\n Properties:\n DBSubnetGroupDescription: \"subnets for rds\"\n SubnetIds:\n - # CRITICAL: Subnets in the target VPC\n - # CRITICAL: At least two AZs recommended\n\n :\n Type: AWS::RDS::DBInstance\n Properties:\n DBSubnetGroupName: !Ref # CRITICAL: Ensures the DB instance is deployed in a VPC\n```", + "terraform": "```hcl\n# Terraform: ensure RDS instance is in a VPC via DB subnet group\nresource \"aws_db_subnet_group\" \"\" {\n name = \"\"\n subnet_ids = [\n \"\", # CRITICAL: Subnets in the target VPC\n \"\"\n ]\n}\n\nresource \"aws_db_instance\" \"\" {\n identifier = \"\"\n db_subnet_group_name = aws_db_subnet_group..name # CRITICAL: Places instance in a VPC\n}\n```", + "other": "1. In the AWS Console, go to RDS > Subnet groups and create/select a DB subnet group in the target VPC (with subnets in at least two AZs)\n2. Go to RDS > Databases, select the DB instance, click Modify\n3. Under Connectivity, set DB subnet group to the VPC subnet group from step 1 (select a VPC security group if prompted)\n4. Check Apply immediately and choose Continue > Modify DB instance" + }, + "rds_instance_multi_az": { + "checkTitle": "RDS instance has Multi-AZ enabled", + "recommendation": "Apply fault-tolerance and redundancy principles: enable **Multi-AZ** for production RDS workloads. Choose one standby or two readable standbys based on RTO/RPO and performance needs. Regularly test failover, monitor configuration drift, and allow exceptions only with documented, risk-based approval.", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_multi_az", + "cli": "aws rds modify-db-instance --db-instance-identifier --multi-az --apply-immediately", + "nativeIaC": "```yaml\n# CloudFormation: enable Multi-AZ on an existing RDS DB instance\nResources:\n RDSInstance:\n Type: AWS::RDS::DBInstance\n Properties:\n MultiAZ: true # Critical: enables Multi-AZ to pass the check\n```", + "terraform": "```hcl\n# Enable Multi-AZ on an RDS DB instance\nresource \"aws_db_instance\" \"example\" {\n multi_az = true # Critical: enables Multi-AZ to pass the check\n}\n```", + "other": "1. Open the AWS Management Console and go to RDS > Databases\n2. Select the affected DB instance and click Modify\n3. Under Availability & durability, set Multi-AZ deployment to Enabled (create a standby)\n4. Check Apply immediately\n5. Click Continue, then Modify DB instance\n6. Wait until status is Available and Multi-AZ shows Yes" + }, + "rds_instance_deprecated_engine_version": { + "checkTitle": "RDS instance uses a supported engine version", + "recommendation": "Standardize on **supported engine versions** and keep them current.\n- Plan and test upgrades; back up and define rollback\n- Enable `AutoMinorVersionUpgrade` where acceptable\n- Monitor deprecation notices and upgrade before EoS\n- Enforce **least privilege** to limit blast radius during incidents", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_deprecated_engine_version", + "cli": "aws rds modify-db-instance --db-instance-identifier --engine-version --allow-major-version-upgrade --apply-immediately", + "nativeIaC": "```yaml\n# CloudFormation: upgrade RDS engine version for an existing instance\nResources:\n :\n Type: AWS::RDS::DBInstance\n Properties:\n DBInstanceIdentifier: \n Engine: \n DBInstanceClass: db.t3.micro\n EngineVersion: # CRITICAL: move to a supported engine version\n AllowMajorVersionUpgrade: true # CRITICAL: required if upgrading major version\n ApplyImmediately: true # CRITICAL: apply change now to pass the check\n```", + "terraform": "```hcl\n# Upgrade RDS engine version\nresource \"aws_db_instance\" \"\" {\n identifier = \"\"\n engine = \"\"\n instance_class = \"db.t3.micro\"\n allocated_storage = 20\n\n engine_version = \"\" # CRITICAL: use a supported version\n allow_major_version_upgrade = true # CRITICAL: needed for major upgrades\n apply_immediately = true # CRITICAL: apply now to pass the check\n}\n```", + "other": "1. In the AWS Console, go to RDS > Databases\n2. Select the DB instance\n3. Click Modify\n4. Under DB engine version, select a supported version\n5. If moving to a new major version, check Allow major version upgrade\n6. Check Apply immediately\n7. Click Continue, then Modify DB instance" + }, + "rds_cluster_critical_event_subscription": { + "checkTitle": "RDS cluster event subscription is enabled for maintenance and failure categories", + "recommendation": "Enable **event subscriptions** for RDS clusters that include `maintenance` and `failure`, delivered via **SNS** to monitored channels.\n- Enforce **least privilege** on topics\n- Separate topics per environment\n- Integrate with on-call/IR playbooks and test alerts\n- Add multiple recipients and escalation for **defense in depth**", + "recommendationUrl": "https://hub.prowler.com/check/rds_cluster_critical_event_subscription", + "cli": "aws rds create-event-subscription --subscription-name --sns-topic-arn --source-type db-cluster --event-categories maintenance failure --enabled", + "nativeIaC": "```yaml\nResources:\n :\n Type: AWS::RDS::EventSubscription\n Properties:\n SnsTopicArn: # Critical: SNS topic to receive notifications\n SourceType: db-cluster # Critical: Scope to DB clusters\n EventCategories: # Critical: Subscribe to required categories only\n - maintenance\n - failure\n Enabled: true # Critical: Must be enabled to pass\n```", + "terraform": "```hcl\nresource \"aws_db_event_subscription\" \"\" {\n name = \"\"\n sns_topic = \"\" # Critical: SNS topic ARN\n source_type = \"db-cluster\" # Critical: Scope to clusters\n event_categories = [\"maintenance\", \"failure\"] # Critical: Required categories\n enabled = true # Critical: Must be enabled\n}\n```", + "other": "1. In the AWS Console, go to RDS > Event subscriptions > Create event subscription\n2. Name: enter \n3. Send notifications to: Choose existing Amazon SNS ARN and select \n4. Source type: select Clusters\n5. Event categories: select only Maintenance and Failure (unselect others)\n6. Ensure Enabled is on\n7. Click Create" + }, + "rds_instance_event_subscription_parameter_groups": { + "checkTitle": "RDS DB parameter group event subscription is enabled and subscribes to configuration change events or all categories", + "recommendation": "Create and maintain an **SNS-backed event subscription** for **DB parameter groups** that includes `configuration change` (or all) and keep it enabled.\n\n- Apply **least privilege** to SNS topics\n- Route to on-call/SIEM and test alerts\n- Enforce change control and monitoring across all Regions", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_event_subscription_parameter_groups", + "cli": "aws rds create-event-subscription --subscription-name --sns-topic-arn --source-type db-parameter-group --event-categories \"configuration change\" --enabled", + "nativeIaC": "```yaml\nResources:\n :\n Type: AWS::RDS::EventSubscription\n Properties:\n SnsTopicArn: \n SourceType: db-parameter-group # Critical: targets DB parameter group events\n EventCategories:\n - configuration change # Critical: subscribes to configuration change events\n Enabled: true # Critical: subscription must be enabled\n```", + "terraform": "```hcl\nresource \"aws_db_event_subscription\" \"\" {\n name = \"\"\n sns_topic = \"\"\n source_type = \"db-parameter-group\" # Critical: target DB parameter groups\n event_categories = [\"configuration change\"] # Critical: include configuration change events\n}\n```", + "other": "1. In the AWS Console, go to Amazon RDS > Event subscriptions\n2. Click Create event subscription\n3. Send notifications to: select an existing SNS topic\n4. Source type: Parameter groups\n5. Event categories: select Configuration change (or choose All event categories)\n6. Ensure Enabled is On\n7. Click Create" + }, + "rds_cluster_minor_version_upgrade_enabled": { + "checkTitle": "RDS cluster has automatic minor version upgrades enabled", + "recommendation": "Enable `auto_minor_version_upgrade` on **RDS Multi-AZ clusters** and align updates with approved maintenance windows. Validate changes in non-production, and document any exceptions with a strict manual patch cadence. This strengthens **defense in depth** and improves **availability**.", + "recommendationUrl": "https://hub.prowler.com/check/rds_cluster_minor_version_upgrade_enabled", + "cli": "aws rds modify-db-cluster --db-cluster-identifier --auto-minor-version-upgrade", + "nativeIaC": "```yaml\n# CloudFormation snippet to enable automatic minor version upgrades on an RDS DB cluster\nResources:\n :\n Type: AWS::RDS::DBCluster\n Properties:\n AutoMinorVersionUpgrade: true # Critical: enables automatic minor engine version upgrades to pass the check\n```", + "terraform": "```hcl\n# Terraform snippet to enable automatic minor version upgrades on an RDS DB cluster\nresource \"aws_rds_cluster\" \"\" {\n cluster_identifier = \"\"\n auto_minor_version_upgrade = true # Critical: enables automatic minor engine version upgrades to pass the check\n}\n```", + "other": "1. In the AWS Console, go to RDS > Databases\n2. Select your Multi-AZ DB cluster\n3. Click Modify\n4. Set Auto minor version upgrade to Enabled\n5. Click Continue, then Modify cluster" + }, + "rds_cluster_copy_tags_to_snapshots": { + "checkTitle": "RDS DB cluster has copy tags to snapshots enabled", + "recommendation": "Enable `CopyTagsToSnapshot` on all applicable **RDS/Aurora clusters**.\n- Standardize required tags (owner, environment, data class)\n- Use **least privilege** and **ABAC** based on tags\n- Automate tagging and periodic audits so snapshots inherit metadata and lifecycle policies", + "recommendationUrl": "https://hub.prowler.com/check/rds_cluster_copy_tags_to_snapshots", + "cli": "aws rds modify-db-cluster --db-cluster-identifier --copy-tags-to-snapshot", + "nativeIaC": "```yaml\n# CloudFormation: enable copying tags to snapshots on an RDS DB cluster\nResources:\n :\n Type: AWS::RDS::DBCluster\n Properties:\n CopyTagsToSnapshot: true # CRITICAL: ensures cluster tags are copied to snapshots\n```", + "terraform": "```hcl\n# Terraform: enable copying tags to snapshots on an RDS DB cluster\nresource \"aws_rds_cluster\" \"\" {\n copy_tags_to_snapshot = true # CRITICAL: ensures cluster tags are copied to snapshots\n}\n```", + "other": "1. In the AWS Console, go to RDS > Databases\n2. Select the DB cluster and choose Modify\n3. Check Copy tags to snapshots\n4. Click Continue, then Apply changes" + }, + "rds_instance_backup_enabled": { + "checkTitle": "RDS instance has backup retention period greater than 0 days", + "recommendation": "Enable **automated backups** with retention > `0` aligned to RPO/RTO. Regularly test restores to validate **PITR**.\n\nApply **least privilege** to backup access, encrypt snapshots, and replicate critical backups to separate locations for **defense in depth** and resilient recovery.", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_backup_enabled", + "cli": "aws rds modify-db-instance --db-instance-identifier --backup-retention-period 1 --apply-immediately", + "nativeIaC": "```yaml\n# CloudFormation: enable automated backups on an RDS instance\nResources:\n :\n Type: AWS::RDS::DBInstance\n Properties:\n DBInstanceClass: db.t3.micro\n Engine: mysql\n AllocatedStorage: 20\n MasterUsername: admin\n MasterUserPassword: \n BackupRetentionPeriod: 1 # CRITICAL: Enables automated backups (>0 days)\n```", + "terraform": "```hcl\n# Terraform: enable automated backups on an RDS instance\nresource \"aws_db_instance\" \"\" {\n allocated_storage = 20\n engine = \"mysql\"\n instance_class = \"db.t3.micro\"\n username = \"admin\"\n password = \"\"\n backup_retention_period = 1 # CRITICAL: Enables automated backups (>0 days)\n}\n```", + "other": "1. Open the AWS Management Console and go to RDS > Databases\n2. Select the target DB instance and click Modify\n3. In Backup section, set Backup retention period to 1 day (or more)\n4. Check Apply immediately\n5. Click Continue (if shown) and then Modify DB instance" + }, + "rds_cluster_integration_cloudwatch_logs": { + "checkTitle": "RDS cluster has CloudWatch Logs export enabled", + "recommendation": "Publish RDS/Aurora logs to **CloudWatch Logs** and centralize analysis.\nSelect appropriate types (e.g., `error`, `general`, `slowquery`, `audit`), define retention, and create alarms. Limit log access with **least privilege** and integrate with SIEM for defense-in-depth monitoring.", + "recommendationUrl": "https://hub.prowler.com/check/rds_cluster_integration_cloudwatch_logs", + "cli": "aws rds modify-db-cluster --db-cluster-identifier --cloudwatch-logs-export-configuration '{\"EnableLogTypes\":[\"\"]}' --apply-immediately", + "nativeIaC": "```yaml\n# CloudFormation: Enable CloudWatch Logs export for an RDS/Aurora DB cluster\nResources:\n :\n Type: AWS::RDS::DBCluster\n Properties:\n Engine: \n MasterUsername: \n MasterUserPassword: \n EnableCloudwatchLogsExports:\n - # CRITICAL: Enables at least one supported log type (e.g., 'error' for MySQL or 'postgresql' for PostgreSQL) to pass the check\n```", + "terraform": "```hcl\n# Terraform: Enable CloudWatch Logs export for an RDS/Aurora DB cluster\nresource \"aws_rds_cluster\" \"\" {\n engine = \"\"\n master_username = \"\"\n master_password = \"\"\n\n enabled_cloudwatch_logs_exports = [\n \"\" # CRITICAL: Enables at least one supported log type (e.g., \"error\" for MySQL or \"postgresql\" for PostgreSQL) to pass the check\n ]\n}\n```", + "other": "1. In the AWS Console, go to RDS > Databases\n2. Select your DB cluster and choose Modify\n3. Under Log exports, check at least one supported log type for your engine (e.g., error/general/slowquery/audit for MySQL, postgresql for PostgreSQL)\n4. Choose Continue, then Apply immediately, and click Modify cluster" + }, + "rds_instance_iam_authentication_enabled": { + "checkTitle": "RDS instance has IAM database authentication enabled", + "recommendation": "Enable **IAM database authentication** for supported engines and apply **least privilege** with scoped IAM policies. Prefer **short-lived tokens** over static DB passwords, enforce TLS, and phase out embedded credentials.\n\nMonitor authentication activity with audit logs for **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_iam_authentication_enabled", + "cli": "aws rds modify-db-instance --db-instance-identifier --enable-iam-database-authentication --apply-immediately", + "nativeIaC": "```yaml\n# CloudFormation: enable IAM DB authentication on an RDS instance\nResources:\n :\n Type: AWS::RDS::DBInstance\n Properties:\n DBInstanceIdentifier: \n EnableIAMDatabaseAuthentication: true # Critical: enables IAM DB auth to pass the check\n```", + "terraform": "```hcl\n# Enable IAM DB authentication on an RDS instance\nresource \"aws_db_instance\" \"\" {\n iam_database_authentication_enabled = true # Critical: enables IAM DB auth to pass the check\n}\n```", + "other": "1. In the AWS console, go to RDS > Databases\n2. Select the DB instance and choose Modify\n3. Under Database authentication, select \"Password and IAM database authentication\"\n4. Choose Apply immediately and click Modify DB instance\n5. If the instance is part of an Aurora DB cluster: select the DB cluster instead, choose Modify, enable IAM database authentication, Apply immediately, then Modify" + }, + "rds_instance_transport_encrypted": { + "checkTitle": "RDS instance or cluster enforces SSL/TLS encryption for client connections", + "recommendation": "Enforce transport encryption at the database layer:\n- Enable `rds.force_ssl=1` or `require_secure_transport` in parameter groups\n- Configure clients to require certificate validation and prevent fallback\n- Use current TLS versions and trusted CAs\n- Prefer private network access as **defense in depth**", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_transport_encrypted", + "cli": "aws rds modify-db-parameter-group --region --db-parameter-group-name --parameters ParameterName='rds.force_ssl',ParameterValue='1',ApplyMethod='pending-reboot'", + "nativeIaC": "```yaml\n# CloudFormation: set required parameter to enforce SSL/TLS\nResources:\n ExampleDBParameterGroupPostgres:\n Type: AWS::RDS::DBParameterGroup\n Properties:\n Family: \n Description: Enforce SSL/TLS\n Parameters:\n rds.force_ssl: \"1\" # Critical: requires SSL/TLS for PostgreSQL/SQL Server instances\n\n ExampleDBParameterGroupMySQL:\n Type: AWS::RDS::DBParameterGroup\n Properties:\n Family: \n Description: Enforce SSL/TLS\n Parameters:\n require_secure_transport: \"1\" # Critical: requires SSL/TLS for MySQL/MariaDB instances\n\n ExampleDBClusterParameterGroupAuroraPostgres:\n Type: AWS::RDS::DBClusterParameterGroup\n Properties:\n Family: \n Description: Enforce SSL/TLS\n Parameters:\n rds.force_ssl: \"1\" # Critical: requires SSL/TLS for Aurora PostgreSQL clusters\n\n ExampleDBClusterParameterGroupAuroraMySQL:\n Type: AWS::RDS::DBClusterParameterGroup\n Properties:\n Family: \n Description: Enforce SSL/TLS\n Parameters:\n require_secure_transport: ON # Critical: requires SSL/TLS for Aurora MySQL clusters\n```", + "terraform": "```hcl\n# DB instances\nresource \"aws_db_parameter_group\" \"example_pg\" {\n name = \"\"\n family = \"\"\n\n parameter {\n name = \"rds.force_ssl\"\n value = \"1\" # Critical: requires SSL/TLS for PostgreSQL/SQL Server instances\n }\n}\n\nresource \"aws_db_parameter_group\" \"example_mysql\" {\n name = \"\"\n family = \"\"\n\n parameter {\n name = \"require_secure_transport\"\n value = \"1\" # Critical: requires SSL/TLS for MySQL/MariaDB instances\n }\n}\n\n# Aurora clusters\nresource \"aws_rds_cluster_parameter_group\" \"example_aurora_pg\" {\n name = \"\"\n family = \"\"\n\n parameter {\n name = \"rds.force_ssl\"\n value = \"1\" # Critical: requires SSL/TLS for Aurora PostgreSQL clusters\n }\n}\n\nresource \"aws_rds_cluster_parameter_group\" \"example_aurora_mysql\" {\n name = \"\"\n family = \"\"\n\n parameter {\n name = \"require_secure_transport\"\n value = \"ON\" # Critical: requires SSL/TLS for Aurora MySQL clusters\n }\n}\n```", + "other": "1. In the AWS Console, go to RDS > Parameter groups\n2. For DB instances:\n - Edit the DB parameter group attached to the instance (or create one and attach it)\n - Set rds.force_ssl = 1 for PostgreSQL/SQL Server, or require_secure_transport = 1 for MySQL/MariaDB\n - Save. If the parameter is static, reboot the instance\n3. For Aurora clusters:\n - Edit the DB cluster parameter group attached to the cluster (or create one and attach it)\n - Set rds.force_ssl = 1 for Aurora PostgreSQL, or require_secure_transport = ON for Aurora MySQL\n - Save. Reboot instances if changes are pending-reboot\n4. Verify the parameter group is associated to the target instance/cluster and status shows the new value applied" + }, + "rds_instance_enhanced_monitoring_enabled": { + "checkTitle": "RDS instance has enhanced monitoring enabled", + "recommendation": "Enable **Enhanced Monitoring** on RDS, using a `>0s` collection interval aligned to workload and cost. Assign a **least-privilege** role for log delivery, and apply **defense in depth** by centralizing logs, setting **alerts** on key OS metrics, and defining **retention** to support incident response and trend analysis.", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_enhanced_monitoring_enabled", + "cli": "aws rds modify-db-instance --db-instance-identifier --monitoring-interval 60 --monitoring-role-arn ", + "nativeIaC": "```yaml\n# CloudFormation: enable Enhanced Monitoring on an existing RDS instance\nResources:\n :\n Type: AWS::RDS::DBInstance\n Properties:\n DBInstanceIdentifier: \n MonitoringRoleArn: # CRITICAL: IAM role RDS uses to publish OS metrics to CloudWatch Logs\n MonitoringInterval: 60 # CRITICAL: >0 enables Enhanced Monitoring (seconds)\n```", + "terraform": "```hcl\n# Enable Enhanced Monitoring on an existing RDS instance\nresource \"aws_db_instance\" \"\" {\n # ...existing required configuration...\n monitoring_role_arn = \"\" # CRITICAL: Role for publishing OS metrics\n monitoring_interval = 60 # CRITICAL: >0 enables Enhanced Monitoring (seconds)\n}\n```", + "other": "1. In the AWS Console, go to RDS > Databases and select the DB instance\n2. Click Modify\n3. In Monitoring, check Enable Enhanced Monitoring and set Granularity to any non-zero value (e.g., 60 seconds)\n4. Set Monitoring role to Default (creates rds-monitoring-role) or select an existing role\n5. Click Continue, then Modify DB instance to apply" + }, + "rds_snapshots_encrypted": { + "checkTitle": "RDS DB instance snapshot or DB cluster snapshot is encrypted", + "recommendation": "Encrypt all RDS snapshots at rest using **KMS**, preferably **customer-managed keys**. Apply **least privilege** to key usage, enforce encryption via templates and automation, and prevent sharing of unencrypted backups. Use **key rotation**, separation of duties, and ensure copies and cross-account shares remain encrypted.", + "recommendationUrl": "https://hub.prowler.com/check/rds_snapshots_encrypted", + "cli": "aws rds copy-db-snapshot --source-db-snapshot-identifier --target-db-snapshot-identifier -encrypted --kms-key-id ", + "nativeIaC": null, + "terraform": "```hcl\nresource \"aws_db_snapshot_copy\" \"\" {\n source_db_snapshot_identifier = \"\"\n target_db_snapshot_identifier = \"-encrypted\"\n kms_key_id = \"\" # Critical: encrypts the copied snapshot using the specified KMS key\n}\n```", + "other": "1. In the AWS Console, go to RDS > Snapshots\n2. Select the unencrypted snapshot (for clusters, use the DB cluster snapshots tab)\n3. Click Actions > Copy snapshot\n4. Check Enable encryption and choose a KMS key\n5. Click Copy snapshot and wait for completion\n6. After verifying the new encrypted snapshot, delete the original unencrypted snapshot (Actions > Delete snapshot)" + }, + "rds_instance_extended_support": { + "checkTitle": "RDS instance is not enrolled in RDS Extended Support", + "recommendation": "Upgrade enrolled DB instances to an engine version covered under standard support to stop Extended Support charges. For new DB instances and restores created via automation, explicitly set the engine lifecycle support option to avoid unintended enrollment in RDS Extended Support when that is your policy.", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_extended_support", + "cli": "aws rds modify-db-instance --db-instance-identifier --engine-version --allow-major-version-upgrade --apply-immediately\n# For new DB instances created via automation, prevent enrollment by setting the lifecycle option:\naws rds create-db-instance ... --engine-lifecycle-support open-source-rds-extended-support-disabled", + "nativeIaC": "```yaml\n# CloudFormation: upgrade RDS engine version for an existing instance\nResources:\n :\n Type: AWS::RDS::DBInstance\n Properties:\n DBInstanceIdentifier: \n Engine: \n DBInstanceClass: db.t3.micro\n EngineVersion: # CRITICAL: move to a supported engine version\n AllowMajorVersionUpgrade: true # CRITICAL: required if upgrading major version\n ApplyImmediately: true # CRITICAL: apply change now to pass the check\n```", + "terraform": "```hcl\n# Upgrade RDS engine version\nresource \"aws_db_instance\" \"\" {\n identifier = \"\"\n engine = \"\"\n instance_class = \"db.t3.micro\"\n allocated_storage = 20\n\n engine_version = \"\" # CRITICAL: use a supported version\n allow_major_version_upgrade = true # CRITICAL: needed for major upgrades\n apply_immediately = true # CRITICAL: apply now to pass the check\n}\n```", + "other": "If your automation (CloudFormation/Terraform/SDK) creates or restores DB instances, set EngineLifecycleSupport/LifeCycleSupport to open-source-rds-extended-support-disabled where supported, and ensure your upgrade process keeps engines within standard support." + }, + "rds_instance_certificate_expiration": { + "checkTitle": "RDS instance SSL/TLS certificate has more than 3 months of validity remaining", + "recommendation": "Establish a **certificate lifecycle** for RDS:\n- Rotate server/CA certs well before expiry; avoid pinned or outdated CAs\n- Keep client trust stores current and enforce TLS with validation\n- Monitor expiry and automate alerts/rotation\n- For custom certs, apply **least privilege**, **separation of duties**, and periodic key rotation; test changes", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_certificate_expiration", + "cli": "aws rds modify-db-instance --db-instance-identifier --ca-certificate-identifier rds-ca-rsa2048-g1 --apply-immediately", + "nativeIaC": "```yaml\n# CloudFormation: update RDS instance CA to a current certificate\nResources:\n :\n Type: AWS::RDS::DBInstance\n Properties:\n CACertificateIdentifier: rds-ca-rsa2048-g1 # CRITICAL: rotates to a valid CA to restore >3 months certificate validity\n```", + "terraform": "```hcl\n# Set a current CA on the RDS instance\nresource \"aws_db_instance\" \"\" {\n identifier = \"\"\n ca_cert_identifier = \"rds-ca-rsa2048-g1\" # CRITICAL: rotates to a valid CA to ensure certificate validity >3 months\n}\n```", + "other": "1. In the AWS Console, go to RDS > Databases and select the DB instance\n2. Click Modify\n3. In Connectivity (or Certificate authority), select rds-ca-rsa2048-g1\n4. Check Apply immediately\n5. Click Continue (if shown) and then Modify DB instance" + }, + "rds_cluster_default_admin": { + "checkTitle": "RDS cluster master username is not admin or postgres", + "recommendation": "Create databases with a **unique, non-default admin username** that doesn't reveal environment or org. Apply **least privilege** by using separate, non-admin accounts for applications. Prefer **IAM database authentication** and manage secrets centrally with rotation. Restrict admin access and monitor login attempts.", + "recommendationUrl": "https://hub.prowler.com/check/rds_cluster_default_admin", + "cli": null, + "nativeIaC": "```yaml\n# Create an RDS DB cluster with a custom admin username\nResources:\n :\n Type: AWS::RDS::DBCluster\n Properties:\n Engine: aurora-mysql\n MasterUsername: # CRITICAL: use a non-default username (not \"admin\" or \"postgres\")\n MasterUserPassword: \n```", + "terraform": "```hcl\n# Create an RDS DB cluster with a custom admin username\nresource \"aws_rds_cluster\" \"\" {\n cluster_identifier = \"\"\n engine = \"aurora-mysql\"\n master_username = \"\" # CRITICAL: non-default username (not admin/postgres)\n master_password = \"\"\n}\n```", + "other": "1. In the AWS console, go to RDS > Databases > Create database\n2. Choose Amazon Aurora and the same engine family as your current cluster\n3. Under Settings, set Master username to a custom value that is not \"admin\" or \"postgres\" (CRITICAL)\n4. Create the new cluster, migrate your workload to it, update application connections, then delete the old cluster" + }, + "rds_instance_critical_event_subscription": { + "checkTitle": "RDS instance event subscription is enabled for maintenance, configuration change, and failure categories", + "recommendation": "Establish and sustain **RDS event subscriptions** for `db-instance` that include `maintenance`, `configuration change`, and `failure`.\n- Deliver to monitored channels (ticketing/chat/paging)\n- Enforce **least privilege** on topics\n- Test alert delivery and runbooks\n- Periodically review coverage across Regions", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_critical_event_subscription", + "cli": "aws rds create-event-subscription --subscription-name --sns-topic-arn --source-type db-instance --event-categories \"maintenance\" \"configuration change\" \"failure\"", + "nativeIaC": "```yaml\n# CloudFormation: RDS DB instance event subscription\nResources:\n :\n Type: AWS::RDS::EventSubscription\n Properties:\n SnsTopicArn: # critical: SNS topic to receive notifications\n SourceType: db-instance # critical: subscribe to DB instance events\n EventCategories: # critical: required categories for PASS\n - maintenance\n - configuration change\n - failure\n```", + "terraform": "```hcl\n# Terraform: RDS DB instance event subscription\nresource \"aws_db_event_subscription\" \"\" {\n name = \"\"\n sns_topic = \"\"\n source_type = \"db-instance\" # critical: DB instance events\n event_categories = [\"maintenance\", \"configuration change\", \"failure\"] # critical: required categories\n}\n```", + "other": "1. Open the AWS Console and go to RDS\n2. In the left menu, select Event subscriptions > Create event subscription\n3. Send notifications to: select an existing SNS topic ()\n4. Source type: choose Instances\n5. Event categories: select Maintenance, Configuration change, and Failure\n6. Create the subscription" + }, + "rds_instance_integration_cloudwatch_logs": { + "checkTitle": "RDS instance exports logs to CloudWatch Logs", + "recommendation": "Enable export of relevant RDS logs to **CloudWatch Logs** (`error`, `general`, `slowquery`, `audit`) and standardize across engines. Enforce **least privilege** on log access, set retention, and define metrics/alarms for critical patterns. Integrate with a SIEM. Apply **separation of duties** and **defense in depth** to protect log integrity and monitoring.", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_integration_cloudwatch_logs", + "cli": "aws rds modify-db-instance --db-instance-identifier --cloudwatch-logs-export-configuration '{\"EnableLogTypes\":[\"\"]}'", + "nativeIaC": "```yaml\nResources:\n :\n Type: AWS::RDS::DBInstance\n Properties:\n DBInstanceIdentifier: \n # Critical: enabling at least one log type exports it to CloudWatch Logs and makes the check PASS\n EnableCloudwatchLogsExports:\n - \n```", + "terraform": "```hcl\nresource \"aws_db_instance\" \"\" {\n identifier = \"\"\n # Critical: export at least one supported log type to CloudWatch Logs to pass the check\n enabled_cloudwatch_logs_exports = [\"\"]\n}\n```", + "other": "1. Open AWS Console > RDS > Databases\n2. Select your DB instance and choose Modify\n3. In Log exports, select at least one supported log type (e.g., error/general/slowquery/audit/postgresql/alert)\n4. Choose Continue, then Modify DB instance" + }, + "rds_cluster_deletion_protection": { + "checkTitle": "RDS cluster has deletion protection enabled", + "recommendation": "Enable **deletion protection** (`deletion_protection=true`) on production and other critical clusters. Enforce via IaC and organizational guardrails; apply **least privilege** to delete/modify actions; require **change control** and approvals. Maintain reliable **backups** to restore when protection must be lifted.", + "recommendationUrl": "https://hub.prowler.com/check/rds_cluster_deletion_protection", + "cli": "aws rds modify-db-cluster --db-cluster-identifier --deletion-protection", + "nativeIaC": "```yaml\n# CloudFormation: Enable deletion protection on an RDS DB Cluster\nResources:\n :\n Type: AWS::RDS::DBCluster\n Properties:\n Engine: aurora-mysql\n DeletionProtection: true # Critical: prevents cluster deletion and passes the check\n```", + "terraform": "```hcl\n# Enable deletion protection on an existing RDS DB Cluster\nresource \"aws_rds_cluster\" \"\" {\n deletion_protection = true # Critical: prevents cluster deletion and passes the check\n}\n```", + "other": "1. In the AWS Console, go to RDS > Databases\n2. Select the DB cluster (type: DB cluster)\n3. Click Modify\n4. Enable Deletion protection\n5. Choose Apply immediately and click Modify cluster" + }, + "rds_cluster_backtrack_enabled": { + "checkTitle": "RDS Aurora MySQL cluster has Backtrack enabled", + "recommendation": "Enable **Backtrack** on Aurora MySQL clusters and set `BacktrackWindow` to meet RTO while balancing cost and workload. Use it with automated backups for **defense in depth** and resilience.\n\n*For clusters without Backtrack*, provision a clone or new cluster with it enabled; monitor usage and adjust the window as change rates evolve.", + "recommendationUrl": "https://hub.prowler.com/check/rds_cluster_backtrack_enabled", + "cli": "aws rds restore-db-cluster-to-point-in-time --source-db-cluster-identifier --db-cluster-identifier --use-latest-restorable-time --backtrack-window 3600", + "nativeIaC": "```yaml\n# CloudFormation: Create Aurora MySQL cluster with Backtrack enabled\nResources:\n :\n Type: AWS::RDS::DBCluster\n Properties:\n Engine: aurora-mysql\n MasterUsername: \"\"\n MasterUserPassword: \"\"\n BacktrackWindow: 3600 # CRITICAL: Enables Backtrack (seconds) so the check passes\n```", + "terraform": "```hcl\n# Terraform: Aurora MySQL cluster with Backtrack enabled\nresource \"aws_rds_cluster\" \"\" {\n engine = \"aurora-mysql\"\n master_username = \"\"\n master_password = \"\"\n backtrack_window = 3600 # CRITICAL: Enables Backtrack (seconds) so the check passes\n}\n```", + "other": "1. In the AWS Console, go to RDS > Databases and select the Aurora MySQL cluster\n2. Click Actions > Restore to point in time\n3. Choose Use latest restorable time\n4. Set Backtrack window (seconds) to a value > 0 (e.g., 3600)\n5. Enter a new DB cluster identifier and click Restore DB cluster\n6. Cut over applications to the new cluster" + }, + "rds_instance_deletion_protection": { + "checkTitle": "RDS instance has deletion protection enabled", + "recommendation": "Enable `deletion protection` on production RDS instances and Aurora clusters. Enforce **least privilege** for delete/modify actions and require change control to disable protection. Use **defense in depth** with reliable backups and tested restores to limit impact if a deletion occurs.", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_deletion_protection", + "cli": "aws rds modify-db-instance --db-instance-identifier --deletion-protection --apply-immediately", + "nativeIaC": "```yaml\n# CloudFormation: enable deletion protection\nResources:\n :\n Type: AWS::RDS::DBInstance\n Properties:\n DeletionProtection: true # Critical: enables deletion protection for standalone instances\n\n :\n Type: AWS::RDS::DBCluster\n Properties:\n DeletionProtection: true # Critical: enables deletion protection at cluster level (required for Aurora members)\n```", + "terraform": "```hcl\n# Enable deletion protection on a standalone RDS instance\nresource \"aws_db_instance\" \"\" {\n deletion_protection = true # Critical: prevents instance deletion\n}\n\n# Enable deletion protection on an RDS/Aurora cluster\nresource \"aws_rds_cluster\" \"\" {\n deletion_protection = true # Critical: prevents cluster deletion (required for instances in this cluster)\n}\n```", + "other": "1. In the AWS console, go to RDS > Databases\n2. For a standalone DB instance: select the instance > Modify > enable Deletion protection > Continue > Apply immediately > Modify DB instance\n3. For an Aurora/clustered instance: switch to the cluster (Writer) > Modify > enable Deletion protection > Continue > Apply immediately > Modify cluster" + }, + "rds_instance_protected_by_backup_plan": { + "checkTitle": "RDS instance is protected by an AWS Backup plan", + "recommendation": "Assign all non-Aurora RDS to an **AWS Backup plan** aligned to business `RPO/RTO`. Use **tags** for automatic coverage, define retention and lifecycle, and store backups in **immutable** vaults where possible. Regularly perform restore tests. Enforce **least privilege** and **separation of duties** for backup administration.", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_protected_by_backup_plan", + "cli": "aws backup create-backup-selection --backup-plan-id --backup-selection '{\"SelectionName\":\"\",\"IamRoleArn\":\"\",\"Resources\":[\"\"]}'", + "nativeIaC": "```yaml\n# CloudFormation: assign an RDS instance to an AWS Backup plan\nResources:\n :\n Type: AWS::Backup::BackupSelection\n Properties:\n BackupPlanId: # CRITICAL: targets the backup plan to protect the instance\n BackupSelection:\n SelectionName: \n IamRoleArn: # CRITICAL: role AWS Backup uses to back up the resource\n Resources:\n - # CRITICAL: assigns the RDS instance to the plan\n```", + "terraform": "```hcl\n# Assign an RDS instance to an AWS Backup plan\nresource \"aws_backup_selection\" \"\" {\n name = \"\"\n plan_id = \"\" # CRITICAL: attaches to the backup plan\n iam_role_arn = \"\" # CRITICAL: role AWS Backup uses\n resources = [\"\"] # CRITICAL: RDS instance protected by the plan\n}\n```", + "other": "1. In the AWS Console, open AWS Backup\n2. Go to Settings > Service opt-in and enable Amazon RDS (if not already)\n3. Go to Backup plans and select an existing plan (or Create backup plan with defaults)\n4. Click Assign resources\n5. Enter a name, select an IAM role, and add the RDS instance (by ARN or resource picker)\n6. Click Assign resources to save" + }, + "rds_cluster_non_default_port": { + "checkTitle": "RDS cluster uses a non-default port for its database engine", + "recommendation": "Use a **non-default port** and enforce **least-privilege** network access:\n- Allow only approved sources\n- Keep databases in private subnets\n- Require TLS and strong, centralized auth\n- Monitor failed connections\n\nUpdate application connection strings to the new `port` as part of defense-in-depth.", + "recommendationUrl": "https://hub.prowler.com/check/rds_cluster_non_default_port", + "cli": "aws rds modify-db-cluster --db-cluster-identifier --port --apply-immediately", + "nativeIaC": "```yaml\n# CloudFormation: Set a non-default port on an RDS/Aurora DB cluster\nResources:\n :\n Type: AWS::RDS::DBCluster\n Properties:\n Engine: \n MasterUsername: \n MasterUserPassword: \n Port: # Critical: change to a non-default engine port to pass the check\n```", + "terraform": "```hcl\n# Terraform: Set a non-default port on an RDS/Aurora DB cluster\nresource \"aws_rds_cluster\" \"\" {\n engine = \"\"\n master_username = \"\"\n master_password = \"\"\n port = # Critical: use a non-default engine port to pass the check\n}\n```", + "other": "1. In the AWS Console, go to RDS > Databases\n2. Select your DB cluster (not an individual instance)\n3. Click Modify\n4. Set Database port to a non-default value for the engine\n5. Check Apply immediately\n6. Click Continue, then Modify cluster" + }, + "rds_instance_no_public_access": { + "checkTitle": "RDS instance is not publicly exposed to the Internet", + "recommendation": "Keep databases private by applying **least privilege** at the network layer:\n- Set `PubliclyAccessible` to `false`\n- Place instances in private subnets\n- Deny `0.0.0.0/0` and `::/0` on the DB port\n- Expose access via private endpoints, VPN, or an application tier/DB proxy\n\nAdopt **defense in depth** with monitoring and strong auth.", + "recommendationUrl": "https://hub.prowler.com/check/rds_instance_no_public_access", + "cli": "aws rds modify-db-instance --db-instance-identifier --no-publicly-accessible --apply-immediately", + "nativeIaC": "```yaml\n# CloudFormation: make the RDS instance private\nResources:\n :\n Type: AWS::RDS::DBInstance\n Properties:\n DBInstanceIdentifier: \n PubliclyAccessible: false # CRITICAL: disables public access so the instance is not Internet-facing\n```", + "terraform": "```hcl\n# Ensure the RDS instance is not publicly accessible\nresource \"aws_db_instance\" \"\" {\n publicly_accessible = false # CRITICAL: disables public access (no public endpoint)\n}\n```", + "other": "1. In the AWS console, go to RDS > Databases and select your DB instance\n2. Click Modify\n3. In Connectivity (Connectivity & security), set Public access to No (Not publicly accessible)\n4. Choose Continue, select Apply immediately (or during the next window), then click Modify DB instance" + }, + "rds_snapshots_public_access": { + "checkTitle": "RDS snapshot is not publicly shared", + "recommendation": "Keep **RDS snapshots** and **cluster snapshots** private. Share only with explicit AWS account IDs using **least privilege** and time-bound access.\n\nEnforce guardrails to block `public` visibility, require approvals for sharing, and audit snapshot permissions. Use encryption with strict key policies to control who can restore data.", + "recommendationUrl": "https://hub.prowler.com/check/rds_snapshots_public_access", + "cli": "aws rds modify-db-snapshot-attribute --db-snapshot-identifier --attribute-name restore --values-to-remove all", + "nativeIaC": null, + "terraform": null, + "other": "1. Open the Amazon RDS console and go to Snapshots\n2. Select the public snapshot (DB snapshot or DB cluster snapshot)\n3. Click Actions > Share snapshot\n4. Set visibility to Private (remove \"All\" from permissions) and click Save" + }, + "transfer_server_in_transit_encryption_enabled": { + "checkTitle": "Transfer Family server has encryption in transit enabled", + "recommendation": "Remove `FTP`; permit only **SFTP**, **FTPS**, or **AS2** to enforce **encryption in transit**.\n\nApply defense in depth: restrict by network location (allowlists/VPC), enforce strong cryptographic policies, and use least-privilege roles with monitoring.", + "recommendationUrl": "https://hub.prowler.com/check/transfer_server_in_transit_encryption_enabled", + "cli": "aws transfer update-server --server-id --protocols SFTP", + "nativeIaC": "```yaml\n# CloudFormation: ensure FTP is not enabled\nResources:\n :\n Type: AWS::Transfer::Server\n Properties:\n Protocols:\n - SFTP # CRITICAL: Use SFTP only; excludes FTP (unencrypted)\n```", + "terraform": "```hcl\n# Ensure FTP is not enabled\nresource \"aws_transfer_server\" \"\" {\n protocols = [\"SFTP\"] # CRITICAL: Excludes FTP to enforce encryption in transit\n}\n```", + "other": "1. Open AWS Console > AWS Transfer Family\n2. Go to Servers and select the server ()\n3. Click Edit next to Protocols\n4. Uncheck FTP and ensure at least SFTP (or FTPS/AS2) is selected\n5. Save" + }, + "lightsail_static_ip_unused": { + "checkTitle": "Lightsail static IP is associated with an instance", + "recommendation": "Release unused static IPs or attach them to the intended instance.\n\nApply **least privilege** for IP allocation, enforce tagging and ownership, and run periodic audits with alerts for unattached addresses. *If reservation is required*, document purpose and set a time limit to prevent drift and cost.", + "recommendationUrl": "https://hub.prowler.com/check/lightsail_static_ip_unused", + "cli": "aws lightsail attach-static-ip --static-ip-name --instance-name ", + "nativeIaC": "```yaml\nResources:\n AttachStaticIp:\n Type: AWS::Lightsail::StaticIpAttachment\n Properties:\n InstanceName: # Critical: instance to attach to; marks IP as attached\n StaticIpName: # Critical: static IP to attach; fixes FAIL by associating it\n```", + "terraform": "```hcl\nresource \"aws_lightsail_static_ip_attachment\" \"attach\" {\n static_ip_name = \"\" # Critical: specify the static IP to attach\n instance_name = \"\" # Critical: target instance; association makes check PASS\n}\n```", + "other": "1. In the AWS Console, go to Lightsail > Networking > Static IPs\n2. Select the unused static IP and click \"Attach to instance\"\n3. Choose the target instance and confirm\n4. Verify the static IP now shows as attached" + }, + "lightsail_instance_public": { + "checkTitle": "Lightsail instance has no publicly accessible ports", + "recommendation": "Apply **least privilege** network access: close unused ports, restrict sources (avoid `0.0.0.0/0`), and review IPv4/IPv6 rules. Use a **VPN** or **bastion host** for administration. Place services behind private networking or load balancers, and harden/monitor any required public endpoints.", + "recommendationUrl": "https://hub.prowler.com/check/lightsail_instance_public", + "cli": "aws lightsail put-instance-public-ports --instance-name --port-infos '[]'", + "nativeIaC": "```yaml\n# CloudFormation: remove all public ports from a Lightsail instance\nResources:\n ClosePublicPorts:\n Type: AWS::Lightsail::InstancePublicPorts\n Properties:\n InstanceName: \n PortInfos: [] # Critical: empty list clears all public ports so the instance is not publicly exposed\n```", + "terraform": "```hcl\n# Terraform: ensure no public ports are open on the Lightsail instance\nresource \"aws_lightsail_instance_public_ports\" \"\" {\n instance_name = \"\"\n\n # Critical: no port_info blocks -> no public ports are configured (closes all)\n dynamic \"port_info\" {\n for_each = []\n content {\n from_port = 0\n to_port = 0\n protocol = \"tcp\"\n }\n }\n}\n```", + "other": "1. Sign in to the AWS Lightsail console\n2. Go to Instances and select \n3. Open the Networking tab\n4. In IPv4 Firewall, delete all existing rules, then Save\n5. If IPv6 is enabled, in IPv6 Firewall, delete all existing rules, then Save" + }, + "lightsail_database_public": { + "checkTitle": "Lightsail database public access disabled", + "recommendation": "Disable **public mode** and keep the database reachable only from trusted, private networks.\n\n- Enforce **least privilege** and network segmentation\n- Use bastion hosts, tunnels, or private endpoints for admin access\n- If exposure is unavoidable, restrict by IP, rotate credentials, and monitor connections for **defense in depth**", + "recommendationUrl": "https://hub.prowler.com/check/lightsail_database_public", + "cli": "aws lightsail update-relational-database --relational-database-name --no-publicly-accessible", + "nativeIaC": "```yaml\n# CloudFormation: disable public access on an existing Lightsail database\nResources:\n :\n Type: AWS::Lightsail::Database\n Properties:\n RelationalDatabaseName: \n PubliclyAccessible: false # Critical: turns off public mode so the database is not publicly accessible\n```", + "terraform": "```hcl\n# Disable public access for a Lightsail database\nresource \"aws_lightsail_database\" \"\" {\n name = \"\"\n availability_zone = \"\"\n blueprint_id = \"\"\n bundle_id = \"\"\n master_database_name = \"\"\n master_username = \"\"\n master_password = \"\"\n\n publicly_accessible = false # Critical: ensures the database is not publicly accessible\n}\n```", + "other": "1. In the AWS Console, go to Lightsail > Databases\n2. Select \n3. Open the Networking tab\n4. In Public mode, toggle Off\n5. Wait until status returns to Available" + }, + "lightsail_instance_automated_snapshots": { + "checkTitle": "Lightsail instance has automated snapshots enabled", + "recommendation": "Enable **automatic snapshots** on Lightsail instances and align the schedule with low-traffic windows. Apply **least privilege** to snapshot create/delete, and regularly test restores. Use **defense in depth**: retain multiple versions and replicate backups *for critical workloads* across regions or accounts.", + "recommendationUrl": "https://hub.prowler.com/check/lightsail_instance_automated_snapshots", + "cli": "aws lightsail enable-add-on --region --resource-name --add-on-request addOnType=AutoSnapshot", + "nativeIaC": "```yaml\n# CloudFormation: Enable automatic snapshots for a Lightsail instance\nResources:\n :\n Type: AWS::Lightsail::Instance\n Properties:\n InstanceName: \n AvailabilityZone: \n BlueprintId: \n BundleId: \n AddOns:\n - AddOnType: AutoSnapshot # Critical: enables automatic snapshots for the instance\n```", + "terraform": "```hcl\n# Enable automatic snapshots for a Lightsail instance\nresource \"aws_lightsail_instance\" \"\" {\n name = \"\"\n availability_zone = \"\"\n blueprint_id = \"\"\n bundle_id = \"\"\n\n add_on {\n type = \"AutoSnapshot\" # Critical: enables automatic snapshots\n status = \"Enabled\" # Critical: turns the add-on on\n }\n}\n```", + "other": "1. Open the AWS Management Console and go to Lightsail\n2. Click Instances and select \n3. Open the Snapshots tab\n4. In Automatic snapshots, toggle On and confirm\n5. (Optional) Set a snapshot time if needed; otherwise the default time is used" + }, + "trustedadvisor_errors_and_warnings": { + "checkTitle": "Trusted Advisor check has no errors or warnings", + "recommendation": "Adopt a continuous process to remediate Trusted Advisor findings:\n- Prioritize **`error`** then `warning`\n- Assign ownership and SLAs\n- Integrate alerts with workflows\n- Enforce **least privilege**, segmentation, encryption, MFA, and tested backups\n- Reassess regularly to confirm fixes and prevent regression", + "recommendationUrl": "https://hub.prowler.com/check/trustedadvisor_errors_and_warnings", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. Sign in to the AWS Console and open Trusted Advisor\n2. Go to Checks and filter Status to Warning and Error\n3. Open each failing check and click View details/Recommended actions\n4. Apply the listed fix to the affected resources\n5. Click Refresh on the check and repeat until all checks show OK" + }, + "trustedadvisor_premium_support_plan_subscribed": { + "checkTitle": "AWS account is subscribed to an AWS Premium Support plan", + "recommendation": "Adopt **Business** or higher for production and mission-critical accounts.\n- Integrate Support into IR with defined contacts/severity\n- Enforce **least privilege** for case access\n- Use Trusted Advisor for proactive hardening\n- If opting out, ensure an equivalent 24/7 support and escalation path", + "recommendationUrl": "https://hub.prowler.com/check/trustedadvisor_premium_support_plan_subscribed", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. Sign in to the AWS Management Console as the account root user\n2. Open https://console.aws.amazon.com/support/home#/plans\n3. Click \"Change plan\"\n4. Select \"Business Support\" (or higher) and click \"Continue\"\n5. Review and confirm the upgrade" + }, + "codeartifact_packages_external_public_publishing_disabled": { + "checkTitle": "Internal CodeArtifact package does not allow publishing versions already present in external public sources", + "recommendation": "Enforce **Package Origin Controls** so internal packages use `upstream=BLOCK` and only trusted publish paths. Apply **least privilege** with package groups and private namespaces, pin versions, and prefer private endpoints. Add artifact signing and CI isolation, and monitor package events for unexpected source changes.", + "recommendationUrl": "https://hub.prowler.com/check/codeartifact_packages_external_public_publishing_disabled", + "cli": "aws codeartifact put-package-origin-configuration --domain --repository --format --package --restrictions publish=ALLOW,upstream=BLOCK", + "nativeIaC": null, + "terraform": null, + "other": "1. In the AWS Console, go to CodeArtifact > Repositories and select \n2. In Packages, open the internal package \n3. Under Origin controls, choose Edit\n4. Set Upstream to Block (leave Publish as Allow if required)\n5. Save" + }, + "sns_topics_kms_encryption_at_rest_enabled": { + "checkTitle": "SNS topic is encrypted at rest with KMS", + "recommendation": "Enable **server-side encryption** on all SNS topics with **AWS KMS**; prefer **customer-managed keys** for control.\n\nApply **least privilege** on key use, enforce rotation, and monitor key/access logs. Minimize sensitive data in messages and use end-to-end encryption *where feasible* to add defense in depth.", + "recommendationUrl": "https://hub.prowler.com/check/sns_topics_kms_encryption_at_rest_enabled", + "cli": "aws sns set-topic-attributes --topic-arn --attribute-name KmsMasterKeyId --attribute-value alias/aws/sns", + "nativeIaC": "```yaml\n# CloudFormation: Enable SSE for an SNS topic\nResources:\n :\n Type: AWS::SNS::Topic\n Properties:\n KmsMasterKeyId: alias/aws/sns # Critical: Enables encryption at rest with AWS managed KMS key\n```", + "terraform": "```hcl\n# Enable SSE for an SNS topic\nresource \"aws_sns_topic\" \"\" {\n name = \"\"\n kms_master_key_id = \"alias/aws/sns\" # Critical: Enables encryption at rest\n}\n```", + "other": "1. Open the AWS Console and go to Amazon SNS > Topics\n2. Select the topic and click Edit\n3. Under Encryption, enable encryption and choose the AWS managed key for SNS (alias/aws/sns)\n4. Click Save changes" + }, + "sns_topics_not_publicly_accessible": { + "checkTitle": "SNS topic is not publicly accessible", + "recommendation": "Restrict the **topic policy** to specific principals and minimal actions:\n- Avoid `Principal:*`\n- Allow only needed actions (e.g., `sns:Publish`)\n- Add conditions like `aws:SourceArn`, `aws:SourceAccount`, `aws:PrincipalOrgID`, or `sns:Endpoint`\nApply **least privilege**, separate duties, and review policies regularly.", + "recommendationUrl": "https://hub.prowler.com/check/sns_topics_not_publicly_accessible", + "cli": "aws sns set-topic-attributes --topic-arn --attribute-name Policy --attribute-value '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam:::root\"},\"Action\":\"sns:Publish\",\"Resource\":\"\"}]}'", + "nativeIaC": "```yaml\n# CloudFormation: restrict SNS topic policy to the account (not public)\nResources:\n :\n Type: AWS::SNS::TopicPolicy\n Properties:\n Topics:\n - arn:aws:sns:::\n PolicyDocument:\n Version: '2012-10-17'\n Statement:\n - Effect: Allow\n Action: sns:Publish\n Resource: arn:aws:sns:::\n Principal:\n AWS: arn:aws:iam:::root # Critical: restrict to account root to remove public access\n```", + "terraform": "```hcl\n# Restrict SNS topic policy to the account (not public)\nresource \"aws_sns_topic_policy\" \"\" {\n arn = \"\"\n policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [{\n Effect = \"Allow\"\n Action = \"sns:Publish\"\n Resource = \"\"\n Principal = { AWS = \"arn:aws:iam:::root\" } # Critical: restrict principal to the account to remove public access\n }]\n })\n}\n```", + "other": "1. Open the Amazon SNS console and select Topics\n2. Choose the topic and go to the Access policy tab\n3. Edit the policy and remove any Principal set to \"*\" (Everyone/Public)\n4. Add a statement allowing only your account root: Principal = arn:aws:iam:::root with Action sns:Publish and Resource set to the topic ARN\n5. Save changes" + }, + "sns_subscription_not_using_http_endpoints": { + "checkTitle": "SNS subscription uses an HTTPS endpoint", + "recommendation": "Require **HTTPS** for all SNS subscription endpoints. Prefer domain-based endpoints, verify SNS message signatures, and apply **least privilege**. Enforce TLS using IAM conditions like `aws:SecureTransport`, and use private connectivity (VPC endpoints) where possible for defense in depth.", + "recommendationUrl": "https://hub.prowler.com/check/sns_subscription_not_using_http_endpoints", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: Ensure SNS subscription uses HTTPS\nResources:\n :\n Type: AWS::SNS::Subscription\n Properties:\n TopicArn: \n Protocol: https # Critical: use HTTPS protocol to remediate HTTP usage\n Endpoint: https:// # Critical: HTTPS endpoint URL\n```", + "terraform": "```hcl\n# Terraform: Ensure SNS subscription uses HTTPS\nresource \"aws_sns_topic_subscription\" \"\" {\n topic_arn = \"\"\n protocol = \"https\" # Critical: enforce HTTPS protocol\n endpoint = \"https://\" # Critical: HTTPS endpoint URL\n}\n```", + "other": "1. Open the Amazon SNS console and go to Subscriptions\n2. Select the subscription with Protocol set to HTTP and click Delete\n3. Click Create subscription\n4. Choose the same Topic ARN, set Protocol to HTTPS, and enter your HTTPS endpoint URL\n5. Create the subscription and confirm it from your endpoint if required" + }, + "account_security_questions_are_registered_in_the_aws_account": { + "checkTitle": "[DEPRECATED] AWS root user has security challenge questions configured", + "recommendation": "Favor stronger recovery instead of KBA:\n- Enforce **MFA for root** and minimize root use\n- Keep **alternate contacts** and root email current and protected\n- Establish a tightly controlled **break-glass role**, applying least privilege and separation of duties\n- Document and test recovery procedures; monitor root activity", + "recommendationUrl": "https://hub.prowler.com/check/account_security_questions_are_registered_in_the_aws_account", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. Sign in to the AWS Management Console\n2. Navigate to your AWS account settings page at https://console.aws.amazon.com/billing/home?#/account/\n3. Scroll down to Configure Security Challenge Questions section and click the Edit link\n4. Select three different questions made available by Amazon and provide appropriate answers\n5. Store the answers in a secure but accessible location\n6. Click the Update button to save the changes" + }, + "account_maintain_different_contact_details_to_security_billing_and_operations": { + "checkTitle": "AWS account has distinct Security, Billing, and Operations contact details, different from each other and from the root contact", + "recommendation": "Maintain distinct, monitored **Security**, **Billing**, and **Operations** alternate contacts that differ from the root contact.\n- Use team aliases and 24x7 phones\n- Review and test contact paths regularly\n- Centralize at org level for consistency\n\nApplies **operational resilience** and **separation of duties**.", + "recommendationUrl": "https://hub.prowler.com/check/account_maintain_different_contact_details_to_security_billing_and_operations", + "cli": null, + "nativeIaC": null, + "terraform": "```hcl\n# Set distinct alternate contacts for SECURITY, BILLING, and OPERATIONS\n# Critical: each resource sets a required contact type to satisfy the check\n\nresource \"aws_account_alternate_contact\" \"security\" {\n alternate_contact_type = \"SECURITY\" # Sets SECURITY contact\n email_address = \"security@example.com\"\n name = \"Security Team\"\n phone_number = \"+1-555-0100\"\n title = \"Security\"\n}\n\nresource \"aws_account_alternate_contact\" \"billing\" {\n alternate_contact_type = \"BILLING\" # Sets BILLING contact\n email_address = \"billing@example.com\"\n name = \"Billing Team\"\n phone_number = \"+1-555-0101\"\n title = \"Billing\"\n}\n\nresource \"aws_account_alternate_contact\" \"operations\" {\n alternate_contact_type = \"OPERATIONS\" # Sets OPERATIONS contact\n email_address = \"operations@example.com\"\n name = \"Operations Team\"\n phone_number = \"+1-555-0102\"\n title = \"Operations\"\n}\n```", + "other": "1. Sign in to the AWS Management Console with a user that can edit account contacts (root, or IAM with account:PutAlternateContact)\n2. In the upper right, click your account name > Account\n3. Scroll to \"Alternate contacts\" and click Edit\n4. Add all three contacts with unique details:\n - Billing contact (distinct name, email, phone)\n - Operations contact (distinct name, email, phone)\n - Security contact (distinct name, email, phone)\n5. Ensure each contact’s email/phone differs from each other and from the primary (root) contact, then click Update" + }, + "account_maintain_current_contact_details": { + "checkTitle": "AWS account contact information is current", + "recommendation": "Adopt:\n- **Primary** and **alternate contacts** for `security`, `billing`, `operations`\n- Shared, monitored aliases and SMS-capable phone numbers (non-personal)\n- Centralized management across accounts with periodic reviews\n- **Least privilege** for who can modify contact data\n- Regular reachability tests and documented ownership", + "recommendationUrl": "https://hub.prowler.com/check/account_maintain_current_contact_details", + "cli": "aws account put-alternate-contact --alternate-contact-type=SECURITY --email-address= --name=\"\" --phone-number=\"\" --title=\"Security\"", + "nativeIaC": null, + "terraform": "```hcl\n# Set the Security alternate contact for the AWS account\nresource \"aws_account_alternate_contact\" \"\" {\n alternate_contact_type = \"SECURITY\" # Critical: ensures AWS can reach your security team\n email_address = \"\" # Critical: contact destination\n name = \"\"\n phone_number = \"\"\n title = \"Security\"\n}\n```", + "other": "1. Sign in to the AWS Management Console\n2. Open Billing and Cost Management, then click your account name > Account\n3. Under Contact information, click Edit, update details, then Update\n4. Under Alternate contacts, click Edit\n5. Enter Security contact name, email, and phone (use a team alias), then Update\n6. Repeat for Billing and Operations if needed" + }, + "account_security_contact_information_is_registered": { + "checkTitle": "AWS account has security alternate contact registered", + "recommendation": "Define and maintain a **Security alternate contact**:\n- Use a monitored alias (e.g., `security@domain`) and team phone\n- Apply to every account (prefer Org-wide automation)\n- Review after org/personnel changes and test delivery\n- Document ownership and escalation paths\nAlign with **incident response** and **least privilege** principles.", + "recommendationUrl": "https://hub.prowler.com/check/account_security_contact_information_is_registered", + "cli": "aws account put-alternate-contact --alternate-contact-type SECURITY --email-address --name --phone-number ", + "nativeIaC": null, + "terraform": "```hcl\n# Set the SECURITY alternate contact for the current AWS account\nresource \"aws_account_alternate_contact\" \"\" {\n alternate_contact_type = \"SECURITY\" # Critical: sets Security contact type\n email_address = \"security@example.com\" # Contact email\n name = \"Security Team\" # Contact name\n phone_number = \"+1-555-0100\" # Contact phone\n}\n```", + "other": "1. Sign in to the AWS Management Console as the root user or an admin with account:PutAlternateContact\n2. Click your account name (top-right) and select My Account (or Account)\n3. Scroll to Alternate Contacts and click Edit in the Security section\n4. Enter Security Email, Name, and Phone Number\n5. Click Update (or Save changes)" + }, + "config_recorder_all_regions_enabled": { + "checkTitle": "AWS Config recorder is enabled and not in failure state or disabled", + "recommendation": "Enable **AWS Config** in every Region with continuous recording and maintain healthy recorder status.", + "recommendationUrl": "https://hub.prowler.com/check/config_recorder_all_regions_enabled", + "cli": null, + "nativeIaC": "```yaml\nResources:\n example_resource_recorder:\n Type: AWS::Config::ConfigurationRecorder\n Properties:\n Name: example_resource\n RoleARN: !Sub arn:aws:iam::${AWS::AccountId}:role/aws-service-role/config.amazonaws.com/AWSServiceRoleForConfig\n\n example_resource_channel:\n Type: AWS::Config::DeliveryChannel\n Properties:\n S3BucketName: example_resource\n\n example_resource_status:\n Type: AWS::Config::ConfigurationRecorderStatus\n Properties:\n Name: example_resource\n Recording: true # This line fixes the security issue\n DependsOn:\n - example_resource_channel\n```", + "terraform": "```hcl\nresource \"aws_iam_service_linked_role\" \"example_resource\" {\n aws_service_name = \"config.amazonaws.com\"\n}\n\nresource \"aws_config_configuration_recorder\" \"example_resource\" {\n name = \"example_resource\"\n role_arn = aws_iam_service_linked_role.example_resource.arn\n}\n\nresource \"aws_config_delivery_channel\" \"example_resource\" {\n s3_bucket_name = \"example_resource\"\n}\n\nresource \"aws_config_configuration_recorder_status\" \"example_resource\" {\n name = aws_config_configuration_recorder.example_resource.name\n is_recording = true # This line fixes the security issue\n depends_on = [aws_config_delivery_channel.example_resource]\n}\n```", + "other": "1. In the AWS Console, go to Config\n2. Click Set up AWS Config (or Settings)\n3. Select a resource recording option (any) and choose an existing S3 bucket for delivery\n4. Keep the default AWSServiceRoleForConfig role\n5. Click Confirm/Turn on to start recording\n6. Verify on the Settings page that Status shows Recording and not Failure" + }, + "config_recorder_using_aws_service_role": { + "checkTitle": "AWS Config recorder uses the AWSServiceRoleForConfig service-linked role", + "recommendation": "Use the AWS‑managed service‑linked role `AWSServiceRoleForConfig` for all recorders to enforce **least privilege** and consistent trust.\n\nAvoid custom roles; restrict who can modify the recorder or role; monitor for drift and ensure recording remains enabled as part of **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/config_recorder_using_aws_service_role", + "cli": "aws configservice put-configuration-recorder --configuration-recorder name=,roleARN=arn::iam:::role/aws-service-role/config.amazonaws.com/AWSServiceRoleForConfig", + "nativeIaC": "```yaml\nResources:\n example_resource:\n Type: AWS::Config::ConfigurationRecorder\n Properties:\n Name: example_resource\n RoleARN: arn::iam:::role/aws-service-role/config.amazonaws.com/AWSServiceRoleForConfig # This line fixes the security issue\n```", + "terraform": "```hcl\nresource \"aws_config_configuration_recorder\" \"example_resource\" {\n name = \"example_resource\"\n role_arn = \"arn::iam:::role/aws-service-role/config.amazonaws.com/AWSServiceRoleForConfig\" # This line fixes the security issue\n}\n```", + "other": "1. Open the AWS Console and go to AWS Config\n2. Choose Settings (or Recording) and click Edit\n3. For IAM role, select Use service-linked role (AWSServiceRoleForConfig)\n4. Save changes" + }, + "elasticache_redis_cluster_automatic_failover_enabled": { + "checkTitle": "ElastiCache Redis cluster has automatic failover enabled", + "recommendation": "Enable **automatic failover** with **Multi-AZ**, keeping at least one replica per shard in a different AZ. Regularly *test failover* and monitor replication lag.\n\nArchitect clients for resilience with retries and backoff to tolerate brief role changes, aligning with **fault tolerance** and **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/elasticache_redis_cluster_automatic_failover_enabled", + "cli": "aws elasticache modify-replication-group --replication-group-id --automatic-failover-enabled --apply-immediately", + "nativeIaC": "```yaml\n# CloudFormation: enable automatic failover for a Redis replication group\nResources:\n :\n Type: AWS::ElastiCache::ReplicationGroup\n Properties:\n ReplicationGroupId: \n ReplicationGroupDescription: \"\"\n NumCacheClusters: 2\n AutomaticFailoverEnabled: true # Critical: turns on automatic failover so the check passes\n Engine: redis\n```", + "terraform": "```hcl\n# Terraform: enable automatic failover for a Redis replication group\nresource \"aws_elasticache_replication_group\" \"\" {\n replication_group_id = \"\"\n replication_group_description = \"\"\n node_type = \"cache.t3.small\"\n number_cache_clusters = 2\n automatic_failover_enabled = true # Critical: turns on automatic failover so the check passes\n}\n```", + "other": "1. Open the AWS Console and go to ElastiCache\n2. Select your Redis replication group ()\n3. Click Modify\n4. Set Auto failover to Enabled\n5. Check Apply immediately\n6. Click Save changes" + }, + "elasticache_redis_cluster_auto_minor_version_upgrades": { + "checkTitle": "ElastiCache Redis cache cluster has automatic minor version upgrades enabled", + "recommendation": "Enable `AutoMinorVersionUpgrade` for Redis replication groups and govern updates with a maintenance window. Apply **patch management** and **defense in depth**: validate in staging, keep recent backups, use Multi-AZ for resilience, and monitor release notes to ensure timely, low-impact updates.", + "recommendationUrl": "https://hub.prowler.com/check/elasticache_redis_cluster_auto_minor_version_upgrades", + "cli": "aws elasticache modify-replication-group --replication-group-id --auto-minor-version-upgrade --apply-immediately", + "nativeIaC": "```yaml\n# CloudFormation: enable auto minor version upgrades on a Replication Group\nResources:\n :\n Type: AWS::ElastiCache::ReplicationGroup\n Properties:\n ReplicationGroupDescription: \"\"\n CacheNodeType: \"\"\n NumCacheClusters: 1\n AutoMinorVersionUpgrade: true # CRITICAL: turns on automatic minor version upgrades\n # This ensures new minor engine versions are applied automatically\n```", + "terraform": "```hcl\n# Enable auto minor version upgrades on an ElastiCache replication group\nresource \"aws_elasticache_replication_group\" \"\" {\n replication_group_id = \"\"\n description = \"\"\n node_type = \"\"\n num_cache_clusters = 1\n auto_minor_version_upgrade = true # CRITICAL: automatically applies minor engine upgrades\n}\n```", + "other": "1. Open the AWS console and go to ElastiCache\n2. Select Replication groups, choose the target group\n3. Click Modify\n4. Enable Automatic minor version upgrade\n5. Check Apply immediately and click Modify to save" + }, + "elasticache_redis_cluster_backup_enabled": { + "checkTitle": "ElastiCache Redis cache cluster has automated snapshot backups enabled with retention of at least 7 days", + "recommendation": "Enable **automated backups** and set **retention** to meet RPO/RTO (typically `7` days).\n- Define a consistent `snapshot window`\n- Test restores regularly\n- Protect backup storage with **least privilege** and immutability\n- Monitor backup status for failures\n- Apply **defense in depth** with replicas/Multi-AZ", + "recommendationUrl": "https://hub.prowler.com/check/elasticache_redis_cluster_backup_enabled", + "cli": "aws elasticache modify-replication-group --replication-group-id --snapshot-retention-limit 7 --apply-immediately", + "nativeIaC": "```yaml\n# CloudFormation: set automated snapshot retention for a Redis replication group\nResources:\n :\n Type: AWS::ElastiCache::ReplicationGroup\n Properties:\n ReplicationGroupDescription: example\n SnapshotRetentionLimit: 7 # Critical: enables automatic snapshots and retains them for >=7 days\n```", + "terraform": "```hcl\nresource \"aws_elasticache_replication_group\" \"\" {\n replication_group_id = \"\"\n replication_group_description = \"\"\n snapshot_retention_limit = 7 # Critical: enable automated backups and keep them for >=7 days\n}\n```", + "other": "1. In the AWS Console, open ElastiCache\n2. Go to Redis > Replication groups\n3. Select and click Modify\n4. Set Snapshot retention (days) to 7 or higher\n5. Check Apply immediately\n6. Click Modify to save" + }, + "elasticache_redis_cluster_multi_az_enabled": { + "checkTitle": "ElastiCache Redis replication group has Multi-AZ enabled", + "recommendation": "Enable **Multi-AZ with automatic failover** (`MultiAZ: enabled`) on Redis replication groups and place replicas in separate AZs. Use clients that follow primary/reader endpoints, monitor replication lag, and regularly test failover. Pair with snapshots for recovery; this enforces high **availability** and **resilience**.", + "recommendationUrl": "https://hub.prowler.com/check/elasticache_redis_cluster_multi_az_enabled", + "cli": "aws elasticache modify-replication-group --replication-group-id --multi-az-enabled --automatic-failover-enabled --apply-immediately", + "nativeIaC": "```yaml\n# CloudFormation: Enable Multi-AZ on an ElastiCache Redis replication group\nResources:\n :\n Type: AWS::ElastiCache::ReplicationGroup\n Properties:\n ReplicationGroupDescription: \"\"\n Engine: redis\n CacheNodeType: cache.t4g.small\n NumCacheClusters: 2\n MultiAZEnabled: true # CRITICAL: Enables Multi-AZ for the replication group\n```", + "terraform": "```hcl\n# Enable Multi-AZ on an ElastiCache Redis replication group\nresource \"aws_elasticache_replication_group\" \"\" {\n replication_group_id = \"\"\n description = \"\"\n engine = \"redis\"\n node_type = \"cache.t4g.small\"\n number_cache_clusters = 2\n\n multi_az_enabled = true # CRITICAL: Enables Multi-AZ\n automatic_failover_enabled = true # Required for Multi-AZ failover\n}\n```", + "other": "1. In the AWS Console, go to ElastiCache > Redis\n2. Select the target replication group\n3. Click Modify\n4. Enable Multi-AZ (and Automatic failover if prompted)\n5. Check Apply immediately and click Modify" + }, + "elasticache_redis_cluster_in_transit_encryption_enabled": { + "checkTitle": "ElastiCache Redis cache cluster has in-transit encryption enabled", + "recommendation": "Enable **TLS** by setting `TransitEncryptionEnabled=true` and enforce a strict mode (require TLS 1.2+).\n\nEnsure clients validate certificates, restrict network paths, and pair with **least privilege** plus Redis AUTH/RBAC for defense in depth.", + "recommendationUrl": "https://hub.prowler.com/check/elasticache_redis_cluster_in_transit_encryption_enabled", + "cli": "aws elasticache modify-replication-group --replication-group-id --transit-encryption-enabled --transit-encryption-mode preferred --apply-immediately", + "nativeIaC": "```yaml\n# CloudFormation: enable in-transit encryption for a Redis replication group\nResources:\n :\n Type: AWS::ElastiCache::ReplicationGroup\n Properties:\n ReplicationGroupId: \"\"\n ReplicationGroupDescription: \"\"\n NumCacheClusters: 1\n CacheSubnetGroupName: \"\"\n TransitEncryptionEnabled: true # CRITICAL: enables TLS in-transit to pass the check\n```", + "terraform": "```hcl\n# Enable in-transit encryption for a Redis replication group\nresource \"aws_elasticache_replication_group\" \"\" {\n replication_group_id = \"\"\n description = \"\"\n node_type = \"cache.t3.micro\"\n num_cache_clusters = 1\n subnet_group_name = \"\"\n transit_encryption_enabled = true # CRITICAL: enables TLS in-transit to pass the check\n}\n```", + "other": "1. In the AWS Console, go to ElastiCache > Redis OSS (or Valkey) replication groups\n2. Select the replication group and click Actions > Modify\n3. Under Security, enable Encryption in transit and set Transit encryption mode to Preferred\n4. Check Apply immediately and Save changes" + }, + "elasticache_redis_replication_group_auth_enabled": { + "checkTitle": "ElastiCache Redis replication group with engine version < 6.0 has Redis OSS AUTH enabled", + "recommendation": "Apply defense in depth:\n- For versions < `6.0`, enable **AUTH** with strong, rotated tokens and require in-transit encryption.\n- For `6.0+`, prefer **RBAC/ACLs** with least-privilege, deny-by-default roles.\n- Restrict network access to trusted sources and audit access regularly.", + "recommendationUrl": "https://hub.prowler.com/check/elasticache_redis_replication_group_auth_enabled", + "cli": "aws elasticache modify-replication-group --replication-group-id --auth-token --auth-token-update-strategy SET --apply-immediately", + "nativeIaC": "```yaml\n# CloudFormation: enable Redis AUTH on an existing replication group\nResources:\n :\n Type: AWS::ElastiCache::ReplicationGroup\n Properties:\n ReplicationGroupId: \n ReplicationGroupDescription: enable-auth\n TransitEncryptionEnabled: true # CRITICAL: required to use AUTH\n AuthToken: # CRITICAL: enables Redis AUTH\n AuthTokenUpdateStrategy: SET # CRITICAL: adds token; enables AUTH\n```", + "terraform": "```hcl\n# Terraform: enable Redis AUTH on an existing replication group\nresource \"aws_elasticache_replication_group\" \"\" {\n replication_group_id = \"\"\n description = \"enable-auth\"\n transit_encryption_enabled = true # CRITICAL: required to use AUTH\n auth_token = \"\" # CRITICAL: enables Redis AUTH\n auth_token_update_strategy = \"SET\" # CRITICAL: adds token; enables AUTH\n}\n```", + "other": "1. In the AWS Console, go to ElastiCache > Redis replication groups\n2. Select the replication group and click Modify\n3. Under Access control, choose Redis OSS AUTH and enter \n4. Check Apply immediately and click Modify\n5. Wait for status to return to Available; AUTH is now enabled" + }, + "elasticache_redis_cluster_rest_encryption_enabled": { + "checkTitle": "ElastiCache Redis cache cluster has at rest encryption enabled", + "recommendation": "Enable **encryption at rest** on all Redis replication groups. Use **customer-managed KMS keys**, apply least-privilege access to keys, and audit key usage. Plan a controlled migration since at-rest encryption is enabled at creation (backup, restore, replace). Pair with **in-transit encryption** and authentication for defense in depth.", + "recommendationUrl": "https://hub.prowler.com/check/elasticache_redis_cluster_rest_encryption_enabled", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: enable at-rest encryption for an ElastiCache Redis replication group\nResources:\n :\n Type: AWS::ElastiCache::ReplicationGroup\n Properties:\n ReplicationGroupId: \n ReplicationGroupDescription: Enable at-rest encryption\n Engine: redis\n CacheNodeType: cache.t3.micro\n NumCacheClusters: 1\n AtRestEncryptionEnabled: true # CRITICAL: turns on encryption at rest for the replication group\n```", + "terraform": "```hcl\n# Terraform: enable at-rest encryption for an ElastiCache Redis replication group\nresource \"aws_elasticache_replication_group\" \"\" {\n replication_group_id = \"\"\n description = \"Enable at-rest encryption\"\n node_type = \"cache.t3.micro\"\n number_cache_clusters = 1\n at_rest_encryption_enabled = true # CRITICAL: turns on encryption at rest for the replication group\n}\n```", + "other": "1. In the AWS Console, go to ElastiCache > Redis\n2. Select the non-encrypted replication group, click Actions > Backup and create a manual backup\n3. After the backup completes, click Backups, select it, then Restore\n4. In restore settings, check/enable Encryption at rest (use default KMS key) and create the new replication group\n5. Update your application to use the new replication group endpoint\n6. Verify connectivity and data, then delete the old (non-encrypted) replication group" + }, + "elasticache_cluster_uses_public_subnet": { + "checkTitle": "ElastiCache cluster is not using public subnets", + "recommendation": "Place caches in **private subnets** only and ensure route tables lack Internet egress. Apply **least privilege** with tight **security groups** limited to required ports and trusted sources.\n\nFor external access, use **VPC peering**, **VPN**, or **PrivateLink**. Enable encryption in transit and Redis `AUTH` for layered controls.", + "recommendationUrl": "https://hub.prowler.com/check/elasticache_cluster_uses_public_subnet", + "cli": "aws elasticache modify-cache-cluster --cache-cluster-id --cache-subnet-group-name --apply-immediately", + "nativeIaC": "```yaml\n# CloudFormation: move ElastiCache into private subnets via a private subnet group\nResources:\n PrivateCacheSubnetGroup:\n Type: AWS::ElastiCache::SubnetGroup\n Properties:\n Description: Private subnets only\n SubnetIds:\n - # private subnet\n - # private subnet\n\n CacheCluster:\n Type: AWS::ElastiCache::CacheCluster\n Properties:\n CacheClusterId: \n Engine: redis\n CacheNodeType: cache.t3.micro\n NumCacheNodes: 1\n CacheSubnetGroupName: !Ref PrivateCacheSubnetGroup # CRITICAL: forces the cluster to use only private subnets\n```", + "terraform": "```hcl\n# Terraform: ensure the cluster uses a subnet group with only private subnets\nresource \"aws_elasticache_subnet_group\" \"private\" {\n name = \"\"\n subnet_ids = [\"\", \"\"] # private subnets only\n}\n\nresource \"aws_elasticache_cluster\" \"cache\" {\n cluster_id = \"\"\n engine = \"redis\"\n node_type = \"cache.t3.micro\"\n num_cache_nodes = 1\n subnet_group_name = aws_elasticache_subnet_group.private.name # CRITICAL: restricts cluster to private subnets\n}\n```", + "other": "1. In the AWS Console, go to ElastiCache > Subnet groups\n2. Click Create cache subnet group and select only private subnets (no route to an Internet Gateway)\n3. Go to ElastiCache > Redis or Memcached, select your cluster\n4. Click Modify, set Subnet group to the private subnet group\n5. Check Apply immediately and click Modify to save" + }, + "shield_advanced_protection_in_internet_facing_load_balancers": { + "checkTitle": "Internet-facing Application Load Balancer is protected by AWS Shield Advanced", + "recommendation": "Register internet-facing ALBs as **Shield Advanced protected resources** to strengthen **availability**. Use defense-in-depth: pair with **AWS WAF** for L7 filtering and rate limits, group related assets, enable health-based detection and proactive engagement, and enforce least-privilege IAM with continuous monitoring.", + "recommendationUrl": "https://hub.prowler.com/check/shield_advanced_protection_in_internet_facing_load_balancers", + "cli": "aws shield create-protection --name --resource-arn ", + "nativeIaC": "```yaml\nResources:\n ShieldProtection:\n Type: AWS::Shield::Protection\n Properties:\n Name: \"\"\n ResourceArn: \"\" # CRITICAL: Set to the ALB ARN to enable Shield Advanced protection for it\n```", + "terraform": "```hcl\nresource \"aws_shield_protection\" \"protect\" {\n name = \"\"\n resource_arn = \"\" # CRITICAL: ALB ARN; creating this enables Shield Advanced on the ALB\n}\n```", + "other": "1. In the AWS Console, open AWS WAF & Shield\n2. Go to Shield > Protected resources\n3. Click Add resources to protect\n4. Select the Region and resource type Application Load Balancer\n5. Select your internet-facing ALB\n6. Click Protect with Shield Advanced" + }, + "shield_advanced_protection_in_cloudfront_distributions": { + "checkTitle": "CloudFront distribution is protected by AWS Shield Advanced", + "recommendation": "Enroll critical CloudFront distributions in **AWS Shield Advanced** and keep them listed as protected resources.\n\nAdopt layered defense: **AWS WAF**, rate limiting, and continuous monitoring. Maintain DDoS runbooks and use DRT support. Apply **least privilege** to who can modify protections.", + "recommendationUrl": "https://hub.prowler.com/check/shield_advanced_protection_in_cloudfront_distributions", + "cli": "aws shield create-protection --region us-east-1 --name --resource-arn ", + "nativeIaC": "```yaml\n# CloudFormation: Add Shield Advanced protection to a CloudFront distribution\nResources:\n ShieldProtection:\n Type: AWS::Shield::Protection\n Properties:\n Name: \n ResourceArn: # Critical: associates Shield Advanced protection with the CloudFront distribution ARN\n```", + "terraform": "```hcl\n# Add Shield Advanced protection to a CloudFront distribution\nresource \"aws_shield_protection\" \"example\" {\n name = \"\"\n resource_arn = \"\" # Critical: associates Shield Advanced protection with the CloudFront distribution ARN\n}\n```", + "other": "1. In the AWS Console, open WAF & Shield\n2. Go to AWS Shield > Protected resources\n3. Click Add resources to protect\n4. Set Scope to Global and select CloudFront distributions, then Load resources\n5. Select the target distribution\n6. Click Protect with Shield Advanced" + }, + "shield_advanced_protection_in_route53_hosted_zones": { + "checkTitle": "Route53 hosted zone is protected by AWS Shield Advanced", + "recommendation": "Add critical **Route 53 hosted zones** as **Shield Advanced protected resources** to apply managed DDoS safeguards. Follow **defense in depth**: limit DNS exposure, enforce least-privilege for protection changes, monitor traffic baselines, and prepare incident runbooks with clear escalation to speed response.", + "recommendationUrl": "https://hub.prowler.com/check/shield_advanced_protection_in_route53_hosted_zones", + "cli": "aws shield create-protection --name --resource-arn arn:aws:route53:::hostedzone/", + "nativeIaC": "```yaml\n# CloudFormation: Add Shield Advanced protection to a Route53 hosted zone\nResources:\n :\n Type: AWS::Shield::Protection\n Properties:\n ResourceArn: arn:aws:route53:::hostedzone/ # Critical: Protects the hosted zone with Shield Advanced\n```", + "terraform": "```hcl\n# Add Shield Advanced protection to a Route53 hosted zone\nresource \"aws_shield_protection\" \"\" {\n name = \"\"\n resource_arn = \"arn:aws:route53:::hostedzone/\" # Critical: Protects the hosted zone with Shield Advanced\n}\n```", + "other": "1. Open the AWS WAF & Shield console\n2. Go to AWS Shield > Protected resources\n3. Click Add resources to protect\n4. Set Scope to Global and select resource type: Amazon Route 53 Hosted Zone\n5. Select the hosted zone and click Protect with Shield Advanced" + }, + "shield_advanced_protection_in_associated_elastic_ips": { + "checkTitle": "Elastic IP address is protected by AWS Shield Advanced", + "recommendation": "Register critical EIPs as **Shield Advanced protected resources**.\n\nApply **defense in depth**: minimize public exposure, use application-layer controls (WAF, rate limiting), monitor telemetry, and review protections regularly, aligning network access with **least privilege**.", + "recommendationUrl": "https://hub.prowler.com/check/shield_advanced_protection_in_associated_elastic_ips", + "cli": "aws shield create-protection --name --resource-arn arn:aws:ec2:::elastic-ip/eipalloc-", + "nativeIaC": "```yaml\n# CloudFormation: Add Shield Advanced protection to an Elastic IP\nResources:\n Protection:\n Type: AWS::Shield::Protection\n Properties:\n Name: \n ResourceArn: arn:aws:ec2:::elastic-ip/eipalloc- # Critical: ARN of the Elastic IP to protect\n```", + "terraform": "```hcl\n# Terraform: Add Shield Advanced protection to an Elastic IP\nresource \"aws_shield_protection\" \"\" {\n name = \"\"\n resource_arn = \"arn:aws:ec2:::elastic-ip/eipalloc-\" # Critical: ARN of the Elastic IP to protect\n}\n```", + "other": "1. Open the AWS WAF & Shield console\n2. Go to AWS Shield > Protected resources\n3. Click Add resources to protect\n4. Select the Region and resource type: EC2 Elastic IP, then Load resources\n5. Select the target Elastic IP\n6. Click Protect with Shield Advanced" + }, + "shield_advanced_protection_in_global_accelerators": { + "checkTitle": "Global Accelerator accelerator is protected by AWS Shield Advanced", + "recommendation": "Add each Global Accelerator as a `protected resource` in **Shield Advanced**. Apply **defense in depth** with AWS WAF where applicable, enable proactive monitoring and alerting, and use **Firewall Manager** to enforce coverage across accounts. Follow **least privilege** to restrict who can modify protections.", + "recommendationUrl": "https://hub.prowler.com/check/shield_advanced_protection_in_global_accelerators", + "cli": "aws shield create-protection --name --resource-arn ", + "nativeIaC": "```yaml\n# CloudFormation: Add Shield Advanced protection to a Global Accelerator accelerator\nResources:\n ShieldProtection:\n Type: AWS::Shield::Protection\n Properties:\n Name: \n ResourceArn: # Critical: ARN of the Global Accelerator accelerator to protect\n```", + "terraform": "```hcl\n# Enable Shield Advanced protection for a Global Accelerator accelerator\nresource \"aws_shield_protection\" \"protection\" {\n name = \"\"\n resource_arn = \"\" # Critical: ARN of the Global Accelerator accelerator to protect\n}\n```", + "other": "1. In the AWS Console, open AWS WAF & Shield\n2. Under AWS Shield, select Protected resources\n3. Click Add resources to protect\n4. Set Scope to Global and select the Global Accelerator resource type\n5. Select the target accelerator and click Protect with Shield Advanced" + }, + "shield_advanced_protection_in_classic_load_balancers": { + "checkTitle": "Classic Load Balancer is protected by AWS Shield Advanced", + "recommendation": "Add internet-facing **Classic Load Balancers** as protected resources in **Shield Advanced** to strengthen DDoS resilience and cost protection.\n\nApply defense-in-depth: minimize public exposure, enforce least-privilege network access, enable health-based detection, and use protection groups.", + "recommendationUrl": "https://hub.prowler.com/check/shield_advanced_protection_in_classic_load_balancers", + "cli": "aws shield create-protection --name --resource-arn ", + "nativeIaC": "```yaml\n# CloudFormation: Add Shield Advanced protection to a Classic Load Balancer\nResources:\n :\n Type: AWS::Shield::Protection\n Properties:\n Name: \n ResourceArn: # Critical: ARN of the Classic Load Balancer to protect\n```", + "terraform": "```hcl\n# Add Shield Advanced protection to a Classic Load Balancer\nresource \"aws_shield_protection\" \"\" {\n name = \"\"\n resource_arn = \"\" # Critical: ARN of the Classic Load Balancer to protect\n}\n```", + "other": "1. In the AWS Console, open AWS WAF & Shield\n2. Go to Shield > Protected resources and click Add resources to protect\n3. Select the Region and resource type Classic Load Balancer, then Load resources\n4. Select your Classic Load Balancer and click Protect with Shield Advanced\n5. Confirm to create the protection" + }, + "apigateway_restapi_authorizers_enabled": { + "checkTitle": "API Gateway REST API has an authorizer at API level or all methods are authorized", + "recommendation": "Require **authentication** on every method: use **Cognito user pools**, **Lambda authorizers**, or **IAM**; avoid `NONE`.\n- Enforce **least privilege** with scoped policies\n- Use **private endpoints** or resource policies for internal APIs\n- Add **rate limiting** and **WAF** for defense in depth", + "recommendationUrl": "https://hub.prowler.com/check/apigateway_restapi_authorizers_enabled", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: set method authorization so it's not public\nResources:\n :\n Type: AWS::ApiGateway::Method\n Properties:\n RestApiId: \n ResourceId: \n HttpMethod: GET\n AuthorizationType: AWS_IAM # Critical: authorizes the method (not NONE)\n```", + "terraform": "```hcl\n# Terraform: set method authorization so it's not public\nresource \"aws_api_gateway_method\" \"\" {\n rest_api_id = \"\"\n resource_id = \"\"\n http_method = \"GET\"\n authorization = \"AWS_IAM\" # Critical: authorizes the method (not NONE)\n}\n```", + "other": "1. In the AWS Console, go to API Gateway > APIs (REST) and select your API\n2. Open Resources, select a resource, then select a method (e.g., GET)\n3. Click Method Request\n4. Set Authorization to AWS_IAM (or an existing Cognito/Lambda authorizer)\n5. Repeat for every method so none show Authorization = NONE\n6. Deploy the API to apply changes" + }, + "apigateway_restapi_logging_enabled": { + "checkTitle": "API Gateway REST API stage has logging enabled", + "recommendation": "Enable **CloudWatch Logs** for all API Gateway stages, using `ERROR` or `INFO` as appropriate. Include request IDs (e.g., `$context.requestId`). Enforce **least privilege** on logs, set **retention** and **alerts** for anomalies. Avoid sensitive data in logs and use **defense in depth** with tracing.", + "recommendationUrl": "https://hub.prowler.com/check/apigateway_restapi_logging_enabled", + "cli": "aws apigateway update-stage --rest-api-id --stage-name --patch-operations op=replace,path='/*/*/logging/loglevel',value=ERROR", + "nativeIaC": "```yaml\n# CloudFormation: enable execution logging on a REST API stage\nResources:\n :\n Type: AWS::ApiGateway::Stage\n Properties:\n StageName: \n RestApiId: \n DeploymentId: \n MethodSettings:\n - ResourcePath: \"/*\"\n HttpMethod: \"*\"\n LoggingLevel: ERROR # CRITICAL: turns on execution logging for all methods\n```", + "terraform": "```hcl\n# Enable execution logging for all methods in a REST API stage\nresource \"aws_api_gateway_method_settings\" \"\" {\n rest_api_id = \"\"\n stage_name = \"\"\n method_path = \"*/*\"\n settings {\n logging_level = \"ERROR\" # CRITICAL: enables stage execution logging\n }\n}\n```", + "other": "1. In the API Gateway console, open Settings and set CloudWatch log role ARN if prompted\n2. Go to APIs > select your REST API > Stages > select the stage\n3. Click Logs and tracing > CloudWatch Logs > choose Errors only (or Errors and info)\n4. Save changes" + }, + "apigateway_restapi_client_certificate_enabled": { + "checkTitle": "API Gateway REST API stage has client certificate enabled", + "recommendation": "Enable **mutual TLS** from API Gateway to the backend with a **client certificate**, and configure the backend to trust only that identity. Apply **zero trust** and **least privilege**: block public access to the backend, restrict networks, rotate certificates, and monitor authentication failures.", + "recommendationUrl": "https://hub.prowler.com/check/apigateway_restapi_client_certificate_enabled", + "cli": "aws apigateway update-stage --rest-api-id --stage-name --patch-operations op=replace,path=/clientCertificateId,value=", + "nativeIaC": "```yaml\n# CloudFormation: attach a client certificate to a REST API stage\nResources:\n ClientCert:\n Type: AWS::ApiGateway::ClientCertificate\n\n ApiStage:\n Type: AWS::ApiGateway::Stage\n Properties:\n StageName: \n RestApiId: \n DeploymentId: \n ClientCertificateId: !Ref ClientCert # Critical: enables client certificate on the stage\n```", + "terraform": "```hcl\n# Terraform: attach a client certificate to a REST API stage\nresource \"aws_api_gateway_client_certificate\" \"example\" {}\n\nresource \"aws_api_gateway_stage\" \"\" {\n stage_name = \"\"\n rest_api_id = \"\"\n deployment_id = \"\"\n client_certificate_id = aws_api_gateway_client_certificate.example.id # Critical: enables client certificate on the stage\n}\n```", + "other": "1. In the AWS Console, go to API Gateway > REST APIs and select your API\n2. In the left menu, click Client Certificates and create one (Generate)\n3. In the left menu, click Stages and select the target stage\n4. In Settings, find Client certificate and select the created certificate\n5. Click Save Changes" + }, + "apigateway_restapi_public_with_authorizer": { + "checkTitle": "API Gateway REST API with a public endpoint has an authorizer configured", + "recommendation": "Enforce **authentication** on all Internet-facing APIs by attaching an **authorizer** (Cognito user pool or Lambda) that validates tokens and scopes.\n\nApply defense in depth:\n- Restrictive resource policies and IP controls\n- WAF, throttling, quotas, rate limits\n- Least-privilege backend access and comprehensive logging", + "recommendationUrl": "https://hub.prowler.com/check/apigateway_restapi_public_with_authorizer", + "cli": "aws apigateway create-authorizer --rest-api-id --name --type TOKEN --authorizer-uri arn:aws:apigateway::lambda:path/2015-03-31/functions/arn:aws:lambda:::function:/invocations --identity-source 'method.request.header.Authorization'", + "nativeIaC": "```yaml\n# CloudFormation: Create a minimal Lambda TOKEN authorizer for a public REST API\nResources:\n :\n Type: AWS::ApiGateway::Authorizer\n Properties:\n Name: \n RestApiId: \n Type: TOKEN # Critical: adds an authorizer to the REST API\n IdentitySource: method.request.header.Authorization # Critical: header to read token from\n AuthorizerUri: arn:aws:apigateway::lambda:path/2015-03-31/functions/arn:aws:lambda:::function//invocations # Critical: Lambda authorizer function URI\n```", + "terraform": "```hcl\n# Terraform: Minimal Lambda TOKEN authorizer for API Gateway REST API\nresource \"aws_api_gateway_authorizer\" \"\" {\n name = \"\"\n rest_api_id = \"\"\n type = \"TOKEN\" # Critical: enables a Lambda authorizer on the REST API\n identity_source = \"method.request.header.Authorization\" # Critical: header to read token\n authorizer_uri = \"arn:aws:apigateway::lambda:path/2015-03-31/functions/arn:aws:lambda:::function//invocations\" # Critical: Lambda authorizer function URI\n}\n```", + "other": "1. In the AWS Console, open API Gateway and select your REST API\n2. In the left pane, click Authorizers > Create authorizer\n3. Choose Lambda (TOKEN) or Cognito User Pool\n4. For Lambda: select the function and set Identity source to method.request.header.Authorization; for Cognito: select the user pool\n5. Click Create authorizer to add it to the API" + }, + "apigateway_restapi_tracing_enabled": { + "checkTitle": "API Gateway REST API stage has X-Ray tracing enabled", + "recommendation": "Enable **X-Ray active tracing** on all API Gateway stages and propagate trace context through downstream services.\n\nUse prudent sampling, correlate traces with logs/metrics, and alert on errors/latency. Apply **least privilege** to X-Ray access and use **defense in depth** for observability.", + "recommendationUrl": "https://hub.prowler.com/check/apigateway_restapi_tracing_enabled", + "cli": "aws apigateway update-stage --rest-api-id --stage-name --patch-operations op=replace,path=/tracingEnabled,value=true", + "nativeIaC": "```yaml\n# CloudFormation: Enable X-Ray tracing on an API Gateway REST API stage\nResources:\n :\n Type: AWS::ApiGateway::Stage\n Properties:\n RestApiId: \n DeploymentId: \n StageName: \n TracingEnabled: true # Critical: enables AWS X-Ray tracing for this stage\n```", + "terraform": "```hcl\n# Enable X-Ray tracing on an API Gateway REST API stage\nresource \"aws_api_gateway_stage\" \"example\" {\n rest_api_id = \"\"\n deployment_id = \"\"\n stage_name = \"\"\n xray_tracing_enabled = true # Critical: enables AWS X-Ray tracing for this stage\n}\n```", + "other": "1. Open the AWS Console and go to API Gateway\n2. Select your REST API and choose Stages\n3. Select the target stage\n4. Open the Logs/Tracing tab, check Enable X-Ray Tracing\n5. Click Save" + }, + "apigateway_restapi_cache_encrypted": { + "checkTitle": "API Gateway REST API stage cache data is encrypted at rest", + "recommendation": "- Enable **encryption at rest** for any cached stage (`Encrypt cache data`).\n- Apply **least privilege** to stage administration and cache invalidation.\n- Avoid caching sensitive endpoints; use short TTLs and scheduled cache flushes for **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/apigateway_restapi_cache_encrypted", + "cli": "aws apigateway update-stage --rest-api-id --stage-name --patch-operations op=replace,path=/*/*/caching/dataEncrypted,value=true", + "nativeIaC": "```yaml\n# CloudFormation: enable encryption for all cached methods in a stage\nResources:\n :\n Type: AWS::ApiGateway::Stage\n Properties:\n StageName: \n RestApiId: \n DeploymentId: \n MethodSettings:\n - ResourcePath: /*\n HttpMethod: \"*\"\n CacheDataEncrypted: true # Critical: encrypt cached responses at rest for all methods\n```", + "terraform": "```hcl\n# Enable encryption for all cached methods in the stage\nresource \"aws_api_gateway_stage\" \"\" {\n rest_api_id = \"\"\n stage_name = \"\"\n deployment_id = \"\"\n\n method_settings {\n resource_path = \"/*\"\n http_method = \"*\"\n cache_data_encrypted = true # Critical: encrypt cached responses at rest\n }\n}\n```", + "other": "1. Open the AWS Console and go to API Gateway\n2. Select your REST API, then click Stages and choose the affected stage\n3. In Method overrides (or Cache settings), enable Encrypt cache data\n4. Save changes" + }, + "apigateway_restapi_public": { + "checkTitle": "API Gateway REST API endpoint is private", + "recommendation": "Prefer **private** REST APIs reachable via interface VPC endpoints (`PRIVATE`).\n\n*If public access is required*, apply **least privilege** and **defense in depth**:\n- Restrict with resource policies (`aws:SourceVpc`/`aws:SourceVpce`)\n- Enforce strong auth (IAM, Cognito, or authorizers)\n- Add AWS WAF, throttling, usage plans, and comprehensive logging", + "recommendationUrl": "https://hub.prowler.com/check/apigateway_restapi_public", + "cli": "aws apigateway update-rest-api --rest-api-id --patch-operations op=replace,path=/endpointConfiguration/types/0,value=PRIVATE", + "nativeIaC": "```yaml\nResources:\n :\n Type: AWS::ApiGateway::RestApi\n Properties:\n Name: \n EndpointConfiguration:\n Types:\n - PRIVATE # Critical: sets the REST API endpoint to Private, removing public access\n```", + "terraform": "```hcl\nresource \"aws_api_gateway_rest_api\" \"\" {\n name = \"\"\n\n endpoint_configuration {\n types = [\"PRIVATE\"] # Critical: makes the REST API private\n }\n}\n```", + "other": "1. Open the AWS console and go to API Gateway\n2. Under REST APIs, select your API\n3. In the left menu, click Settings\n4. Set Endpoint Type to Private\n5. Click Save changes" + }, + "apigateway_restapi_waf_acl_attached": { + "checkTitle": "API Gateway stage has a WAF Web ACL attached", + "recommendation": "Attach an **AWS WAF web ACL** to each exposed stage and apply **defense in depth**:\n- Use managed rule groups and tailored allow/deny lists\n- Apply rate limiting to throttle abuse\n- Enforce least-privilege network exposure\n- Continuously tune rules using logs and metrics\n*Validate changes to reduce false positives.*", + "recommendationUrl": "https://hub.prowler.com/check/apigateway_restapi_waf_acl_attached", + "cli": "aws wafv2 associate-web-acl --web-acl-arn --resource-arn arn:aws:apigateway:::/restapis//stages/", + "nativeIaC": "```yaml\n# CloudFormation: Attach a WAFv2 Web ACL to an API Gateway REST API stage\nResources:\n :\n Type: AWS::WAFv2::WebACLAssociation\n Properties:\n ResourceArn: arn:aws:apigateway:::/restapis//stages/ # CRITICAL: target API Gateway stage\n WebACLArn: # CRITICAL: Web ACL to attach\n```", + "terraform": "```hcl\n# Attach a WAFv2 Web ACL to an API Gateway REST API stage\nresource \"aws_wafv2_web_acl_association\" \"\" {\n resource_arn = \"arn:aws:apigateway:::/restapis//stages/\" # CRITICAL: target API Gateway stage\n web_acl_arn = \"\" # CRITICAL: Web ACL to attach\n}\n```", + "other": "1. Open the AWS Console and go to WAF & Shield\n2. Select Web ACLs (Scope: Regional), choose your Web ACL\n3. Click Add AWS resource\n4. Select API Gateway, choose the REST API and the specific Stage\n5. Click Add/Associate to attach the Web ACL" + }, + "guardduty_no_high_severity_findings": { + "checkTitle": "GuardDuty detector has no high severity findings", + "recommendation": "Treat **High findings** as incidents.\n\n- Prioritize triage and containment; isolate affected resources, rotate secrets\n- Automate alerting and response with playbooks; integrate into IR\n- Enforce **least privilege**, network segmentation, and hardened baselines\n- Continuously tune detections and remove unused access to prevent recurrence", + "recommendationUrl": "https://hub.prowler.com/check/guardduty_no_high_severity_findings", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. Sign in to the AWS console and open Amazon GuardDuty\n2. Use the Region selector to choose a Region where GuardDuty is enabled\n3. Go to Findings and filter: Severity = High (7-8.9), Archived status = Not archived\n4. Select all results, click Actions > Archive\n5. Repeat steps 2-4 for every Region with GuardDuty enabled\n6. Confirm there are 0 active High severity findings in each Region" + }, + "guardduty_s3_protection_enabled": { + "checkTitle": "GuardDuty detector has S3 Protection enabled", + "recommendation": "Enable **S3 Protection** across all accounts and Regions to add **defense in depth** for S3. Apply **least privilege** to IAM and bucket policies, keep **Block Public Access** enforced, integrate findings with alerting, and regularly review anomalies to prevent data loss and tampering.", + "recommendationUrl": "https://hub.prowler.com/check/guardduty_s3_protection_enabled", + "cli": "aws guardduty update-detector --detector-id --data-sources S3Logs={Enable=true}", + "nativeIaC": "```yaml\n# CloudFormation: Enable S3 Protection on a GuardDuty detector\nResources:\n :\n Type: AWS::GuardDuty::Detector\n Properties:\n Enable: true\n DataSources:\n S3Logs:\n Enable: true # Critical: Enables GuardDuty S3 Protection\n```", + "terraform": "```hcl\n# Enable S3 Protection on a GuardDuty detector\nresource \"aws_guardduty_detector\" \"\" {\n enable = true\n\n datasources {\n s3_logs {\n enable = true # Critical: Enables GuardDuty S3 Protection\n }\n }\n}\n```", + "other": "1. Open the AWS Management Console and go to GuardDuty\n2. In the left menu, select Settings\n3. Find the S3 Protection section and click Enable (or toggle On)\n4. Click Save" + }, + "guardduty_ec2_malware_protection_enabled": { + "checkTitle": "GuardDuty detector has Malware Protection for EC2 enabled", + "recommendation": "Enable **Malware Protection for EC2** across all accounts and Regions under centralized administration. Apply **least privilege** to findings access, define scan scope with tags and minimize exclusions, and retain snapshots based on data sensitivity. Integrate alerts with IR/SIEM and pair with hardening and vulnerability scanning for **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/guardduty_ec2_malware_protection_enabled", + "cli": "aws guardduty update-detector --detector-id --features '[{\"Name\":\"EBS_MALWARE_PROTECTION\",\"Status\":\"ENABLED\"}]'", + "nativeIaC": "```yaml\n# CloudFormation: enable GuardDuty Malware Protection for EC2\nResources:\n GuardDutyDetector:\n Type: AWS::GuardDuty::Detector\n Properties:\n Enable: true\n Features:\n - Name: EBS_MALWARE_PROTECTION # Critical: selects EC2 Malware Protection feature\n Status: ENABLED # Critical: enables the feature\n```", + "terraform": "```hcl\n# Enable GuardDuty Malware Protection for EC2\nresource \"aws_guardduty_detector\" \"\" {\n enable = true\n\n features {\n name = \"EBS_MALWARE_PROTECTION\" # Critical: selects EC2 Malware Protection feature\n status = \"ENABLED\" # Critical: enables the feature\n }\n}\n```", + "other": "1. In the AWS console, open GuardDuty\n2. In the left menu, select Protection plans > Malware Protection for EC2\n3. Click Enable, then Save" + }, + "guardduty_centrally_managed": { + "checkTitle": "GuardDuty detector is managed by an administrator account or is the administrator with member accounts", + "recommendation": "Designate a **delegated administrator** (preferably via *AWS Organizations*) and enroll all accounts as **members**. Enable auto-enrollment for new accounts, standardize detector settings across required regions, and route findings to central monitoring. Apply **least privilege** and **separation of duties**.", + "recommendationUrl": "https://hub.prowler.com/check/guardduty_centrally_managed", + "cli": "aws guardduty enable-organization-admin-account --admin-account-id ", + "nativeIaC": null, + "terraform": null, + "other": "1. Sign in to the AWS Organizations management account\n2. Open the AWS Organizations console\n3. Go to Services > Amazon GuardDuty\n4. Click Register delegated administrator\n5. Enter the admin account ID and click Register" + }, + "guardduty_eks_audit_log_enabled": { + "checkTitle": "GuardDuty detector has EKS Audit Log Monitoring enabled", + "recommendation": "Enable **EKS Audit Log Monitoring** on all detectors in every required Region, centrally managed by the GuardDuty administrator.\n- Route findings to alerting/IR workflows\n- Enforce **least privilege** on access to findings and configs\n- Combine with **defense-in-depth**: hardened RBAC and runtime monitoring", + "recommendationUrl": "https://hub.prowler.com/check/guardduty_eks_audit_log_enabled", + "cli": "aws guardduty update-detector --detector-id --features '[{\"Name\":\"EKS_AUDIT_LOGS\",\"Status\":\"ENABLED\"}]'", + "nativeIaC": "```yaml\n# CloudFormation: Enable EKS Audit Log Monitoring on GuardDuty detector\nResources:\n GuardDutyDetector:\n Type: AWS::GuardDuty::Detector\n Properties:\n Enable: true\n DataSources:\n Kubernetes:\n AuditLogs:\n Enable: true # CRITICAL: Enables EKS Audit Log Monitoring\n```", + "terraform": "```hcl\n# Enable EKS Audit Log Monitoring on GuardDuty detector\nresource \"aws_guardduty_detector\" \"example\" {\n enable = true\n\n features {\n name = \"EKS_AUDIT_LOGS\"\n status = \"ENABLED\" # CRITICAL: Enables EKS Audit Log Monitoring\n }\n}\n```", + "other": "1. Open the AWS Console and go to Amazon GuardDuty\n2. Select the Region where you want to enable it\n3. In the left menu, click EKS Protection\n4. Click Enable and confirm\n5. If using AWS Organizations, perform these steps in the delegated GuardDuty administrator account" + }, + "guardduty_is_enabled": { + "checkTitle": "GuardDuty detector is enabled and not suspended", + "recommendation": "Enable and keep **GuardDuty** active in all supported Regions and accounts under a delegated admin. Turn on relevant protection plans and auto-enroll new accounts. Avoid `suspended` detectors, enforce **least privilege** for admins, and integrate findings into response for **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/guardduty_is_enabled", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: Ensure GuardDuty detector is enabled (not suspended) in the Region\nResources:\n ExampleGuardDutyDetector:\n Type: AWS::GuardDuty::Detector\n Properties:\n Enable: true # Critical: enables the detector so GuardDuty is active (not suspended)\n```", + "terraform": "```hcl\n# Terraform: Ensure GuardDuty detector is enabled (not suspended) in the Region\nresource \"aws_guardduty_detector\" \"example_resource_name\" {\n enable = true # Critical: turns GuardDuty on and ensures it is not suspended\n}\n```", + "other": "1. Sign in to the AWS Console and open Amazon GuardDuty\n2. Switch to the target AWS Region\n3. If prompted with Get started, click Enable GuardDuty\n4. If GuardDuty is already configured but suspended, go to Settings and click Enable (or Resume) to activate the detector\n5. Repeat in each required Region" + }, + "guardduty_rds_protection_enabled": { + "checkTitle": "GuardDuty detector has RDS Protection enabled", + "recommendation": "Enable **GuardDuty RDS Protection** across all accounts and Regions.\n- Enforce **least privilege** for DB users and rotate credentials\n- Restrict network exposure to databases\n- Integrate findings with alerting and incident response for rapid containment", + "recommendationUrl": "https://hub.prowler.com/check/guardduty_rds_protection_enabled", + "cli": "aws guardduty update-detector --detector-id --features Name=RDS_LOGIN_EVENTS,Status=ENABLED", + "nativeIaC": "```yaml\nResources:\n :\n Type: AWS::GuardDuty::Detector\n Properties:\n Enable: true\n Features:\n - Name: RDS_LOGIN_EVENTS # critical: selects GuardDuty RDS Protection feature\n Status: ENABLED # critical: turns RDS Protection on\n```", + "terraform": "```hcl\nresource \"aws_guardduty_detector\" \"\" {\n enable = true\n features {\n name = \"RDS_LOGIN_EVENTS\" # critical: GuardDuty RDS Protection feature\n status = \"ENABLED\" # critical: enable the feature\n }\n}\n```", + "other": "1. In the AWS Console, open Amazon GuardDuty\n2. Go to Settings (or Protection plans/Features)\n3. Find RDS Protection (RDS login events) and click Enable\n4. Save changes\n5. If using Organizations, perform this in the delegated GuardDuty administrator account" + }, + "guardduty_lambda_protection_enabled": { + "checkTitle": "GuardDuty detector has Lambda Protection enabled", + "recommendation": "Enable **Lambda Protection** on all detectors in every active Region and account.\n\nApply **least privilege** to Lambda roles, restrict egress with network controls, and integrate findings with alerting and response for **defense in depth**. *In multi-account setups*, manage centrally for consistent coverage.", + "recommendationUrl": "https://hub.prowler.com/check/guardduty_lambda_protection_enabled", + "cli": "aws guardduty update-detector --detector-id --features '[{\"Name\":\"LAMBDA_NETWORK_LOGS\",\"Status\":\"ENABLED\"}]'", + "nativeIaC": "```yaml\nResources:\n :\n Type: AWS::GuardDuty::Detector\n Properties:\n Enable: true\n Features:\n - Name: LAMBDA_NETWORK_LOGS # Critical: selects Lambda Protection feature\n Status: ENABLED # Critical: enables Lambda Protection\n```", + "terraform": "```hcl\nresource \"aws_guardduty_detector\" \"\" {\n enable = true\n features {\n name = \"LAMBDA_NETWORK_LOGS\" # Critical: selects Lambda Protection feature\n status = \"ENABLED\" # Critical: enables Lambda Protection\n }\n}\n```", + "other": "1. Open the AWS Console and go to GuardDuty\n2. In the left pane, select Settings > Lambda Protection\n3. Click Enable\n4. Click Confirm to save" + }, + "guardduty_eks_runtime_monitoring_enabled": { + "checkTitle": "GuardDuty detector has EKS Runtime Monitoring enabled", + "recommendation": "- Enable **EKS Runtime Monitoring** with automated agent management across all accounts and clusters\n- Enforce **least privilege** for agents and segment cluster access\n- Integrate findings with response workflows and periodically verify runtime coverage", + "recommendationUrl": "https://hub.prowler.com/check/guardduty_eks_runtime_monitoring_enabled", + "cli": "aws guardduty update-detector --detector-id --features name=EKS_RUNTIME_MONITORING,status=ENABLED", + "nativeIaC": "```yaml\nResources:\n :\n Type: AWS::GuardDuty::Detector\n Properties:\n Enable: true\n Features:\n - Name: EKS_RUNTIME_MONITORING # Critical: selects EKS Runtime Monitoring feature\n Status: ENABLED # Critical: enables the feature to pass the check\n```", + "terraform": "```hcl\nresource \"aws_guardduty_detector\" \"\" {\n enable = true\n\n features {\n name = \"EKS_RUNTIME_MONITORING\" # Critical: selects EKS Runtime Monitoring feature\n status = \"ENABLED\" # Critical: enables the feature to pass the check\n }\n}\n```", + "other": "1. Open the AWS Console and go to Amazon GuardDuty\n2. In the left pane, select Settings > Runtime monitoring\n3. Under EKS Runtime Monitoring, switch the status to Enabled\n4. Click Save changes" + }, + "backup_recovery_point_encrypted": { + "checkTitle": "AWS Backup recovery point is encrypted at rest", + "recommendation": "Encrypt all recovery points with **KMS**, preferring **customer-managed keys** for rotation and control. Apply **least privilege** to keys and vaults, require encrypted copies across accounts/Regions, and continuously monitor for unencrypted artifacts. Use `aws/backup` or `CMEK` consistently.", + "recommendationUrl": "https://hub.prowler.com/check/backup_recovery_point_encrypted", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: Encrypted AWS Backup Vault\nResources:\n :\n Type: AWS::Backup::BackupVault\n Properties:\n BackupVaultName: \n EncryptionKeyArn: # Critical: vault uses this KMS key so recovery points stored here are encrypted at rest\n```", + "terraform": "```hcl\n# Encrypted AWS Backup Vault\nresource \"aws_backup_vault\" \"\" {\n name = \"\"\n kms_key_arn = \"\" # Critical: ensures recovery points in this vault are encrypted at rest\n}\n```", + "other": "1. In AWS Backup, go to Backup vaults > Create backup vault\n2. Enter a name and select a KMS key (aws/backup or a customer-managed key)\n3. Save the vault\n4. Go to Backup plans > select your plan > Edit and set the Target backup vault to the encrypted vault > Save\n5. To remediate existing unencrypted recovery points: Recovery points > select the item > Copy > choose the encrypted vault > Start copy, then delete the original unencrypted recovery point" + }, + "backup_reportplans_exist": { + "checkTitle": "At least one AWS Backup report plan exists", + "recommendation": "Establish and maintain **report plans** to continuously monitor backup activity and policy adherence.\n- Apply least privilege to report storage\n- Include relevant accounts and Regions for coverage\n- Review reports routinely and alert on anomalies\n- Enforce separation of duties between backup admins and auditors", + "recommendationUrl": "https://hub.prowler.com/check/backup_reportplans_exist", + "cli": "aws backup create-report-plan --report-plan-name --report-delivery-channel s3BucketName=,formats=CSV --report-setting reportTemplate=BACKUP_JOB_REPORT", + "nativeIaC": "```yaml\nResources:\n :\n Type: AWS::Backup::ReportPlan\n Properties:\n ReportPlanName: # Critical: creates the report plan required to pass the check\n ReportDeliveryChannel:\n S3BucketName: # Critical: destination bucket for reports\n Formats:\n - CSV # Critical: valid report file format\n ReportSetting:\n ReportTemplate: BACKUP_JOB_REPORT # Critical: minimal template to enable job reports\n```", + "terraform": "```hcl\nresource \"aws_backup_report_plan\" \"\" {\n name = \"\" # Critical: creates at least one report plan\n\n report_delivery_channel {\n s3_bucket_name = \"\" # Critical: destination bucket for reports\n formats = [\"CSV\"] # Critical: valid report file format\n }\n\n report_setting {\n report_template = \"BACKUP_JOB_REPORT\" # Critical: minimal job report template\n }\n}\n```", + "other": "1. Open the AWS Backup console and go to Reports\n2. Click Create report plan\n3. Select the Backup jobs (job report) template\n4. Enter a Report plan name and choose an S3 bucket\n5. Select CSV as the file format\n6. Click Create report plan" + }, + "backup_vaults_encrypted": { + "checkTitle": "AWS Backup vault is encrypted at rest", + "recommendation": "Encrypt every backup vault with **customer-managed KMS keys** (`CMK`). Enforce **least privilege** in key policies, enable rotation, and separate key admins from backup operators. Add **defense-in-depth** with vault lock and logging. *For copies*, ensure destination vaults use appropriate KMS keys.", + "recommendationUrl": "https://hub.prowler.com/check/backup_vaults_encrypted", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: Encrypted AWS Backup Vault\nResources:\n :\n Type: AWS::Backup::BackupVault\n Properties:\n BackupVaultName: \n EncryptionKeyArn: # CRITICAL: sets KMS key to encrypt the vault at rest\n```", + "terraform": "```hcl\n# Encrypted AWS Backup Vault\nresource \"aws_backup_vault\" \"\" {\n name = \"\"\n kms_key_arn = \"\" # CRITICAL: enables encryption at rest for the vault\n}\n```", + "other": "1. In the AWS Console, go to AWS Backup > Backup vaults\n2. Click Create backup vault\n3. Set Name to \n4. Under Encryption key, select a customer managed KMS key ()\n5. Click Create backup vault\n6. Update any Backup plans to use the new vault (Plans > select plan > Edit > change Target vault name)\n7. Delete the old unencrypted vault after it is empty (select vault > Delete backup vault)" + }, + "backup_vaults_exist": { + "checkTitle": "At least one AWS Backup vault exists", + "recommendation": "Create and maintain a **backup vault** in each required region. Enforce **least privilege** access, encrypt with **KMS CMKs**, and enable **Vault Lock** to prevent tampering. Use lifecycle rules and cross-region/cross-account copies, and regularly test restores for **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/backup_vaults_exist", + "cli": "aws backup create-backup-vault --backup-vault-name ", + "nativeIaC": "```yaml\n# CloudFormation: create a Backup Vault\nResources:\n BackupVault:\n Type: AWS::Backup::BackupVault\n Properties:\n VaultName: # Critical: creates a backup vault to satisfy the check\n```", + "terraform": "```hcl\n# Create a Backup Vault\nresource \"aws_backup_vault\" \"\" {\n name = \"\" # Critical: ensures at least one backup vault exists\n}\n```", + "other": "1. Sign in to the AWS Management Console and open the AWS Backup console\n2. In the left navigation pane, select Backup vaults\n3. Click Create backup vault\n4. Enter a name (e.g., )\n5. Click Create backup vault" + }, + "backup_plans_exist": { + "checkTitle": "At least one AWS Backup plan exists", + "recommendation": "Establish and enforce **backup plans** for critical workloads:\n- Define schedules, retention, and lifecycle to meet RPO/RTO\n- Use tagging to include all required resources by policy\n- Enable cross-Region/account copies and immutability where feasible\n- Apply least privilege to backup roles\n- Regularly test restores and review reports", + "recommendationUrl": "https://hub.prowler.com/check/backup_plans_exist", + "cli": "aws backup create-backup-plan --backup-plan \"{\\\"BackupPlanName\\\":\\\"\\\",\\\"Rules\\\":[{\\\"RuleName\\\":\\\"\\\",\\\"TargetBackupVaultName\\\":\\\"Default\\\"}]}\"", + "nativeIaC": "```yaml\n# CloudFormation: create a minimal AWS Backup Plan to pass the check\nResources:\n :\n Type: AWS::Backup::BackupPlan\n Properties:\n BackupPlan:\n BackupPlanName: # Critical: ensures at least one Backup Plan exists\n Rules:\n - RuleName: # Critical: minimal required rule\n TargetBackupVault: Default # Critical: required vault for the rule\n```", + "terraform": "```hcl\n# Terraform: minimal AWS Backup Plan to satisfy the check\nresource \"aws_backup_plan\" \"\" {\n name = \"\" # Critical: creates the Backup Plan so the check passes\n\n rule {\n rule_name = \"\" # Critical: minimal rule\n target_vault_name = \"Default\" # Critical: required vault\n }\n}\n```", + "other": "1. In the AWS Console, go to AWS Backup\n2. Click Backup plans > Create backup plan\n3. Choose Build a new plan\n4. Enter Plan name: \n5. Under Backup rule, set Rule name: and Target backup vault: Default\n6. Click Create plan" + }, + "bedrock_api_key_no_long_term_credentials": { + "checkTitle": "Amazon Bedrock API key is expired", + "recommendation": "Prefer **short-term credentials** and **IAM roles**; avoid `never expire`.\n\nEnforce **least privilege**, strict **rotation**, and automatic **expiration** for any long-term key. Store secrets securely, monitor with audit logs, and revoke unused or stale keys quickly.", + "recommendationUrl": "https://hub.prowler.com/check/bedrock_api_key_no_long_term_credentials", + "cli": "aws iam delete-service-specific-credential --user-name --service-specific-credential-id ", + "nativeIaC": null, + "terraform": null, + "other": "1. Sign in to the AWS Management Console and open IAM\n2. Go to Users > select > Security credentials\n3. In \"API keys for Amazon Bedrock\", find the non-expired key and click Delete\n4. Confirm deletion to remove the key (removes the long-term credential so the check passes)" + }, + "bedrock_guardrail_prompt_attack_filter_enabled": { + "checkTitle": "Amazon Bedrock guardrail has prompt attack filter strength set to HIGH", + "recommendation": "Set the **Prompt attack** filter to `HIGH` and apply **defense in depth**:\n- Tag user/external inputs as untrusted for evaluation\n- Combine with denied topics and sensitive-info filters\n- Enforce **least privilege** and approvals for risky actions\n- Monitor guardrail hits and tune to reduce false negatives", + "recommendationUrl": "https://hub.prowler.com/check/bedrock_guardrail_prompt_attack_filter_enabled", + "cli": "aws bedrock update-guardrail --guardrail-identifier --content-policy-config 'filtersConfig=[{type=PROMPT_ATTACK,inputStrength=HIGH}]'", + "nativeIaC": "```yaml\nResources:\n :\n Type: AWS::Bedrock::Guardrail\n Properties:\n Name: \n BlockedInputMessaging: \"Blocked\"\n BlockedOutputsMessaging: \"Blocked\"\n ContentPolicyConfig:\n FiltersConfig:\n - Type: PROMPT_ATTACK # Critical: enables the Prompt Attack filter\n InputStrength: HIGH # Critical: sets filter strength to HIGH to pass the check\n```", + "terraform": "```hcl\nresource \"aws_bedrock_guardrail\" \"\" {\n name = \"\"\n blocked_input_messaging = \"Blocked\"\n blocked_outputs_messaging = \"Blocked\"\n\n content_policy_config {\n filters_config {\n type = \"PROMPT_ATTACK\" # Critical: enables the Prompt Attack filter\n input_strength = \"HIGH\" # Critical: sets filter strength to HIGH to pass the check\n }\n }\n}\n```", + "other": "1. Open the AWS Console and go to Amazon Bedrock\n2. Select Guardrails, then choose your guardrail\n3. In Content filters, find Prompt attacks\n4. Set Strength to High\n5. Click Save" + }, + "bedrock_agent_guardrail_enabled": { + "checkTitle": "Amazon Bedrock agent uses a guardrail to protect agent sessions", + "recommendation": "Associate a **guardrail** with every agent and tailor policies to your use case:\n- Enable content/word filters, denied topics, and sensitive-data masking\n- Use contextual grounding for RAG where relevant\n- Test and iterate across versions\nApply **least privilege** to agent tools and use **defense in depth** with monitoring and review.", + "recommendationUrl": "https://hub.prowler.com/check/bedrock_agent_guardrail_enabled", + "cli": "aws bedrock-agent update-agent --agent-id --agent-name --agent-resource-role-arn --foundation-model --guardrail-configuration guardrailIdentifier=,guardrailVersion=DRAFT", + "nativeIaC": "```yaml\n# CloudFormation: Associate a guardrail with a Bedrock Agent\nResources:\n ExampleAgent:\n Type: AWS::Bedrock::Agent\n Properties:\n AgentName: \n AgentResourceRoleArn: \n FoundationModel: \n Instruction: \"\"\n GuardrailConfiguration: # CRITICAL: associates a guardrail to protect sessions\n GuardrailIdentifier: # CRITICAL: guardrail ID used by the agent\n GuardrailVersion: DRAFT # CRITICAL: version applied\n```", + "terraform": "```hcl\n# Terraform (AWS Cloud Control): Associate a guardrail with a Bedrock Agent\nresource \"awscc_bedrock_agent\" \"example\" {\n agent_name = \"\"\n agent_resource_role_arn = \"\"\n foundation_model = \"\"\n instruction = \"\"\n\n # CRITICAL: associates a guardrail to protect agent sessions\n guardrail_configuration {\n guardrail_identifier = \"\" # CRITICAL: guardrail ID\n guardrail_version = \"DRAFT\" # CRITICAL: version applied\n }\n}\n```", + "other": "1. Open the Amazon Bedrock console and go to Agents\n2. Select the agent and click Edit\n3. In Guardrail details, select an existing guardrail and its version (e.g., DRAFT)\n4. Click Save (deploy changes if prompted)\n5. Verify the agent now shows the selected guardrail" + }, + "bedrock_api_key_no_administrative_privileges": { + "checkTitle": "Amazon Bedrock API key does not have administrative privileges, privilege escalation paths, or full Bedrock service access", + "recommendation": "Enforce **least privilege** on Bedrock keys:\n- Avoid wildcards like `*` and `bedrock:*`; allow only required actions\n- Prevent identity changes by disallowing `iam:*`\n- Prefer short-term credentials with rotation and MFA\n- Use permissions boundaries and SCPs as guardrails\n- Review usage and tighten policies via access analysis", + "recommendationUrl": "https://hub.prowler.com/check/bedrock_api_key_no_administrative_privileges", + "cli": "aws iam delete-service-specific-credential --user-name --service-specific-credential-id ", + "nativeIaC": "```yaml\n# CloudFormation: attach least-privilege policy to the IAM user owning the Bedrock API key\nResources:\n :\n Type: AWS::IAM::Policy\n Properties:\n PolicyName: least-priv-bedrock\n Users:\n - \n PolicyDocument:\n Version: '2012-10-17'\n Statement:\n - Effect: Allow\n Action:\n - bedrock:InvokeModel # CRITICAL: allow only needed Bedrock action to avoid admin or bedrock:* permissions\n Resource: \"*\" # Limits access scope to only InvokeModel on any resource\n```", + "terraform": "```hcl\n# Attach a minimal inline policy to the IAM user owning the Bedrock API key\nresource \"aws_iam_user_policy\" \"\" {\n name = \"least-priv-bedrock\"\n user = \"\"\n\n # CRITICAL: allow only the specific action required; avoids admin or bedrock:* full access\n policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [{\n Effect = \"Allow\"\n Action = [\"bedrock:InvokeModel\"]\n Resource = \"*\"\n }]\n })\n}\n```", + "other": "1. Open the AWS Console and go to IAM > Users\n2. Select the user that owns the Bedrock service-specific credential (Security credentials > Service-specific credentials shows bedrock.amazonaws.com)\n3. In the Permissions tab, detach any policy granting AdministratorAccess or bedrock:* (e.g., AmazonBedrockFullAccess)\n4. In the same tab, delete any inline policy that provides admin/privilege-escalation permissions or bedrock:* access\n5. If Bedrock access is needed, add a minimal policy allowing only bedrock:InvokeModel\n6. Save changes" + }, + "bedrock_model_invocation_logs_encryption_enabled": { + "checkTitle": "Amazon Bedrock model invocation logs are encrypted in the S3 bucket and KMS-encrypted in the CloudWatch log group", + "recommendation": "Ensure all invocation logs are encrypted end to end:\n- Enable S3 default encryption, preferably `SSE-KMS`, and restrict key usage\n- Assign a KMS key to CloudWatch log groups\n- Enforce **least privilege** on keys and logs, rotate keys, and monitor access for **defense in depth**", + "recommendationUrl": "https://hub.prowler.com/check/bedrock_model_invocation_logs_encryption_enabled", + "cli": null, + "nativeIaC": "```yaml\nResources:\n EncryptedLogsBucket:\n Type: AWS::S3::Bucket\n Properties:\n BucketName: \n BucketEncryption: # critical: enables default encryption for the bucket (SSE-S3)\n ServerSideEncryptionConfiguration:\n - ServerSideEncryptionByDefault:\n SSEAlgorithm: AES256\n\n EncryptedLogGroup:\n Type: AWS::Logs::LogGroup\n Properties:\n LogGroupName: \n KmsKeyId: # critical: encrypts the CloudWatch log group with a KMS key\n```", + "terraform": "```hcl\nresource \"aws_s3_bucket\" \"example\" {\n bucket = \"\"\n}\n\nresource \"aws_s3_bucket_server_side_encryption_configuration\" \"example\" {\n bucket = aws_s3_bucket.example.id\n rule {\n apply_server_side_encryption_by_default {\n sse_algorithm = \"AES256\" # critical: enables default encryption for the S3 bucket\n }\n }\n}\n\nresource \"aws_cloudwatch_log_group\" \"example\" {\n name = \"\"\n kms_key_id = \"\" # critical: encrypts the log group with a KMS key\n}\n```", + "other": "1. In the Bedrock console, go to Settings and note the S3 bucket and CloudWatch log group used for Model invocation logging.\n2. S3 bucket: AWS Console > S3 > Buckets > > Properties > Default encryption > Enable > Choose SSE-S3 (AES-256) > Save.\n3. CloudWatch Logs: AWS Console > CloudWatch > Logs > Log groups > select > Actions > Edit > KMS encryption > select > Save." + }, + "bedrock_model_invocation_logging_enabled": { + "checkTitle": "Amazon Bedrock model invocation logging is enabled", + "recommendation": "Enable **model invocation logging** and route events to **CloudWatch Logs** and/or **S3**.\n\nEnforce **least privilege** on log access, use encryption, and set retention/lifecycle policies. Monitor for anomalies and alerts to support **defense in depth** and **separation of duties**.", + "recommendationUrl": "https://hub.prowler.com/check/bedrock_model_invocation_logging_enabled", + "cli": "aws bedrock put-model-invocation-logging-configuration --logging-config '{\"s3Config\":{\"bucketName\":\"\"},\"textDataDeliveryEnabled\":true}'", + "nativeIaC": null, + "terraform": null, + "other": "1. Open the Amazon Bedrock console in the target Region\n2. Go to Settings > Model invocation logging\n3. Toggle Logging to On\n4. Select Amazon S3 as the destination and choose bucket\n5. Under Data types, select Text\n6. Click Save" + }, + "bedrock_guardrail_sensitive_information_filter_enabled": { + "checkTitle": "Amazon Bedrock guardrail blocks or masks sensitive information", + "recommendation": "Enable and tune **sensitive information filters** for inputs and outputs.\n- Use `BLOCK` for high-risk disclosures; `ANONYMIZE` when context is needed\n- Add custom regex for org-specific IDs\n- Apply least privilege and data minimization\n- Test regularly and monitor outcomes as part of defense-in-depth", + "recommendationUrl": "https://hub.prowler.com/check/bedrock_guardrail_sensitive_information_filter_enabled", + "cli": "aws bedrock update-guardrail --guardrail-identifier --sensitive-information-policy-config '{\"piiEntitiesConfig\":[{\"type\":\"EMAIL\",\"action\":\"ANONYMIZE\"}]}'", + "nativeIaC": null, + "terraform": null, + "other": "1. Sign in to the AWS Console and open Amazon Bedrock\n2. Go to Guardrails and select \n3. Click Edit (or Open draft) and open Sensitive information filters\n4. Add PII type EMAIL and set action to Mask (or Block)\n5. Click Save" + }, + "servicecatalog_portfolio_shared_within_organization_only": { + "checkTitle": "Service Catalog portfolio is shared only within the AWS Organization", + "recommendation": "Prefer **organizational sharing** for portfolios and avoid `ACCOUNT` targets. Enforce **least privilege** on portfolio access and launch roles, and review shares regularly. Apply **separation of duties** and **defense in depth** so only governed accounts consume products and blast radius remains constrained.", + "recommendationUrl": "https://hub.prowler.com/check/servicecatalog_portfolio_shared_within_organization_only", + "cli": "aws servicecatalog create-portfolio-share --portfolio-id --organization-ids ", + "nativeIaC": "```yaml\n# CloudFormation: Share Service Catalog portfolio only within the AWS Organization\nResources:\n :\n Type: AWS::ServiceCatalog::PortfolioShare\n Properties:\n PortfolioId: \n OrganizationNode: # CRITICAL: share within AWS Organizations\n Type: ORGANIZATION # Shares the portfolio with the entire org\n Value: # e.g., o-xxxxxxxxxx\n```", + "terraform": "```hcl\n# Share Service Catalog portfolio only within the AWS Organization\nresource \"aws_servicecatalog_portfolio_share\" \"\" {\n portfolio_id = \"\"\n\n organization_node { # CRITICAL: share within AWS Organizations\n type = \"ORGANIZATION\" # Shares the portfolio with the entire org\n value = \"\" # e.g., o-xxxxxxxxxx\n }\n}\n```", + "other": "1. In the AWS Console, go to Service Catalog > Portfolios and open the target portfolio\n2. Open the Shares/Sharing tab\n3. Remove every share of Type \"Account\" (stop sharing with each account)\n4. Click Share, choose \"AWS Organizations\", set Type to \"Organization\", enter your Org ID (o-xxxxxxxxxx), and share\n5. Verify no remaining shares of Type \"Account\" exist" + }, + "drs_job_exist": { + "checkTitle": "Region has AWS Elastic Disaster Recovery (DRS) enabled with at least one recovery job", + "recommendation": "Enable DRS in required Regions and protect critical workloads. Define RTO/RPO and run **regular recovery drills** to validate launch settings and dependencies. Apply **least privilege**, monitor replication health, and document failover procedures to ensure consistent, repeatable recovery.", + "recommendationUrl": "https://hub.prowler.com/check/drs_job_exist", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. In the AWS Console, switch to the target Region\n2. Open Elastic Disaster Recovery (DRS)\n3. Click \"Set default replication settings\" (or Settings > Initialize) and choose \"Configure and initialize\" to enable DRS in this Region\n4. Go to \"Source servers\" > \"Add server\", copy the install command, run it on one server, and wait until it shows Data replication status = Healthy and Ready for recovery\n5. Select that server, choose \"Initiate recovery drill\" (or \"Initiate recovery\") and confirm to create a job\n6. Verify under \"Recovery job history\" that the job completes" + }, + "eventbridge_global_endpoint_event_replication_enabled": { + "checkTitle": "EventBridge global endpoint has event replication enabled", + "recommendation": "Turn on **event replication** for global endpoints to ensure Regional resilience. Keep event buses, rules, and targets aligned across Regions. Use a dedicated IAM role with **least privilege** for replication. Design consumers for **idempotency** with unique IDs. Regularly test failover and monitor health as part of **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/eventbridge_global_endpoint_event_replication_enabled", + "cli": "aws events update-endpoint --name --replication-config State=ENABLED --role-arn ", + "nativeIaC": "```yaml\n# CloudFormation: Enable event replication on an EventBridge global endpoint\nResources:\n Endpoint:\n Type: AWS::Events::Endpoint\n Properties:\n Name: \n EventBuses:\n - EventBusArn: arn:aws:events:us-east-1::event-bus/\n - EventBusArn: arn:aws:events:us-west-2::event-bus/\n RoutingConfig:\n FailoverConfig:\n Primary:\n HealthCheck: arn:aws:route53:::healthcheck/\n Secondary:\n Route: us-west-2\n ReplicationConfig:\n State: ENABLED # Critical: enables event replication\n RoleArn: arn:aws:iam:::role/ # Critical: role used by replication\n```", + "terraform": "```hcl\n# Terraform (awscc): Enable event replication on an EventBridge global endpoint\nresource \"awscc_events_endpoint\" \"example\" {\n name = \"\"\n\n event_buses = [\n { event_bus_arn = \"arn:aws:events:us-east-1::event-bus/\" },\n { event_bus_arn = \"arn:aws:events:us-west-2::event-bus/\" }\n ]\n\n routing_config = {\n failover_config = {\n primary = { health_check = \"arn:aws:route53:::healthcheck/\" }\n secondary = { route = \"us-west-2\" }\n }\n }\n\n replication_config = { state = \"ENABLED\" } # Critical: enables event replication\n role_arn = \"arn:aws:iam:::role/\" # Critical: role used by replication\n}\n```", + "other": "1. In the AWS Console, open Amazon EventBridge and go to Global endpoints\n2. Select the endpoint and choose Edit\n3. Under Event replication, check Event replication enabled\n4. For Execution role, select an existing role or create a new one\n5. Save changes" + }, + "eventbridge_bus_exposed": { + "checkTitle": "AWS EventBridge event bus policy does not allow public access", + "recommendation": "Apply **least privilege** resource policies: limit principals to specific accounts or your organization, and constrain actions and event attributes (e.g., `source`, `detail-type`). Avoid `Principal: \"*\"`.\n\nUse **defense in depth** with rule patterns that include the expected `account`. Monitor policy changes and bus activity.", + "recommendationUrl": "https://hub.prowler.com/check/eventbridge_bus_exposed", + "cli": "aws events remove-permission --event-bus-name --statement-id ", + "nativeIaC": "```yaml\n# CloudFormation: restrict EventBridge event bus access to a specific account (not public)\nResources:\n :\n Type: AWS::Events::EventBusPolicy\n Properties:\n StatementId: AllowSpecificAccount\n Action: events:PutEvents\n Principal: arn:aws:iam:::root # CRITICAL: limit access to a specific AWS account to prevent public access\n # Omitting EventBusName applies this to the default event bus\n```", + "terraform": "```hcl\nresource \"aws_cloudwatch_event_bus_policy\" \"\" {\n # CRITICAL: Principal is a specific AWS account, not \"*\", preventing public access\n policy = <:root\"},\n \"Action\": \"events:PutEvents\",\n \"Resource\": \"arn:aws:events:::event-bus/default\"\n }]\n}\nPOLICY\n}\n```", + "other": "1. Open the AWS Console and go to EventBridge > Event buses\n2. Select the target event bus and open the Permissions tab\n3. Click Edit policy\n4. Remove any statement where Principal is \"*\" or AWS is \"*\"\n5. If needed, add a statement allowing only your trusted account ID as Principal (arn:aws:iam:::root)\n6. Save changes" + }, + "eventbridge_schema_registry_cross_account_access": { + "checkTitle": "AWS EventBridge schema registry does not allow cross-account access", + "recommendation": "Apply **least privilege** to registry resource policies:\n- Avoid public principals like `Principal: *`\n- Allow only trusted account ARNs or org IDs\n- Grant minimal actions, prefer read-only\n- Use **separation of duties** and log changes\n\n*If cross-account is needed*, scope tightly and review often.", + "recommendationUrl": "https://hub.prowler.com/check/eventbridge_schema_registry_cross_account_access", + "cli": "aws schemas put-resource-policy --registry-name --policy '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam:::root\"},\"Action\":\"schemas:*\",\"Resource\":\"*\"}]}'", + "nativeIaC": "```yaml\n# CloudFormation: Restrict EventBridge Schema Registry policy to same account only\nResources:\n RegistryPolicy:\n Type: AWS::EventSchemas::RegistryPolicy\n Properties:\n RegistryName: \n # Critical: Principal limited to this AWS account to prevent cross-account access\n Policy: !Sub |\n {\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Principal\": { \"AWS\": \"arn:${AWS::Partition}:iam::${AWS::AccountId}:root\" },\n \"Action\": \"schemas:*\",\n \"Resource\": \"*\"\n }\n ]\n }\n```", + "terraform": "```hcl\n# Restrict EventBridge Schema Registry policy to same account only\nresource \"aws_schemas_registry_policy\" \"\" {\n registry_name = \"\"\n\n # Critical: Principal limited to same account to remove cross-account access\n policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [{\n Effect = \"Allow\"\n Principal = { AWS = \"arn:aws:iam:::root\" }\n Action = \"schemas:*\"\n Resource = \"*\"\n }]\n })\n}\n```", + "other": "1. Open the Amazon EventBridge console\n2. Go to Schemas > Registries and select \n3. Open the Permissions tab and click Edit\n4. Remove any statement with Principal set to \"*\" or an AWS account different from yours\n5. Add a single Allow statement with Principal = arn:aws:iam:::root\n6. Save changes" + }, + "eventbridge_bus_cross_account_access": { + "checkTitle": "AWS EventBridge event bus does not allow cross-account access", + "recommendation": "Apply **least privilege** on the event bus resource policy: allow only specific account IDs or org scope (e.g., `aws:PrincipalOrgID`) and avoid wildcard `Principal` or `*`.\n\nConstrain rules to trusted senders using the `account` field and vetted sources, and add monitoring/throttling for **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/eventbridge_bus_cross_account_access", + "cli": "aws events remove-permission --event-bus-name --statement-id ", + "nativeIaC": "```yaml\n# CloudFormation: restrict EventBridge event bus to same account only\nResources:\n :\n Type: AWS::Events::EventBusPolicy\n Properties:\n StatementId: \n Action: events:PutEvents\n Principal: !Ref AWS::AccountId # Critical: allows only this AWS account, blocking cross-account access\n EventBusName: \n```", + "terraform": "```hcl\n# Terraform: restrict EventBridge event bus to same account only\nresource \"aws_cloudwatch_event_permission\" \"\" {\n statement_id = \"\"\n action = \"events:PutEvents\"\n principal = \"\" # Critical: set to your own AWS account ID to block cross-account access\n event_bus_name = \"\"\n}\n```", + "other": "1. In the AWS Console, go to Amazon EventBridge > Event buses\n2. Select the event bus ()\n3. Open the Permissions tab and click Edit\n4. Remove any statements that grant access to other accounts, an organization, or \"*\"\n5. Save changes" + }, + "firehose_stream_encrypted_at_rest": { + "checkTitle": "Kinesis Data Firehose delivery stream is encrypted at rest", + "recommendation": "Enable **server-side encryption** for Firehose with AWS KMS. Prefer **customer managed keys** (`CMEK`) to enforce **least privilege**, rotation, and auditing. Ensure upstream **Kinesis** sources are encrypted and confirm MSK defaults meet policy. Monitor KMS health signals and deny writes without encryption. Apply **defense in depth** at destinations.", + "recommendationUrl": "https://hub.prowler.com/check/firehose_stream_encrypted_at_rest", + "cli": "aws firehose start-delivery-stream-encryption --delivery-stream-name --delivery-stream-encryption-configuration-input KeyType=AWS_OWNED_CMK", + "nativeIaC": "```yaml\n# CloudFormation: Enable at-rest encryption for a Firehose delivery stream\nResources:\n :\n Type: AWS::KinesisFirehose::DeliveryStream\n Properties:\n DeliveryStreamEncryptionConfigurationInput:\n KeyType: AWS_OWNED_CMK # critical: enables SSE at rest using AWS owned KMS key\n ExtendedS3DestinationConfiguration:\n BucketARN: arn:aws:s3:::\n RoleARN: arn:aws:iam:::role/\n```", + "terraform": "```hcl\n# Terraform: Enable at-rest encryption for a Firehose delivery stream\nresource \"aws_kinesis_firehose_delivery_stream\" \"\" {\n name = \"\"\n destination = \"extended_s3\"\n\n server_side_encryption {\n enabled = true # critical: turns on SSE at rest (uses AWS owned KMS key by default)\n }\n\n extended_s3_configuration {\n role_arn = \"arn:aws:iam:::role/\"\n bucket_arn = \"arn:aws:s3:::\"\n }\n}\n```", + "other": "1. In the AWS Console, go to Amazon Data Firehose\n2. Select the affected delivery stream and click Edit\n3. Under Server-side encryption, set to Enabled (choose AWS owned key)\n4. Click Save changes" + }, + "macie_is_enabled": { + "checkTitle": "Amazon Macie is enabled", + "recommendation": "Enable and maintain **Amazon Macie** in all regions hosting **S3** data. Use continuous sensitive data discovery, apply custom classifications for your data types, and route findings to monitoring. Enforce least privilege for Macie access and strengthen defense in depth with restrictive bucket policies and access controls.", + "recommendationUrl": "https://hub.prowler.com/check/macie_is_enabled", + "cli": "aws macie2 enable-macie --region ", + "nativeIaC": "```yaml\n# CloudFormation: Enable Amazon Macie in this region\nResources:\n MacieSession:\n Type: AWS::Macie::Session\n Properties:\n Status: ENABLED # Critical: Enables Macie for the account in this region\n```", + "terraform": "```hcl\n# Enables Amazon Macie in this region\nresource \"aws_macie2_account\" \"main\" {\n # Critical: Creating this resource enables Macie for the account in the region\n}\n```", + "other": "1. Sign in to the AWS Management Console and switch to the target region\n2. Open Amazon Macie\n3. Click Get started or Enable Macie\n4. If Macie shows Suspended/Paused, click Resume Macie\n5. Repeat in each region with S3 buckets as needed" + }, + "macie_automated_sensitive_data_discovery_enabled": { + "checkTitle": "Macie automated sensitive data discovery is enabled", + "recommendation": "Enable and maintain `automated sensitive data discovery` for the Macie administrator across required Regions. Include relevant buckets, tune identifiers and allow lists to reduce noise, and route findings to monitoring. Complement with **least privilege** on S3 and **defense in depth** for data protection.", + "recommendationUrl": "https://hub.prowler.com/check/macie_automated_sensitive_data_discovery_enabled", + "cli": "aws macie2 update-automated-discovery-configuration --status ENABLED --region ", + "nativeIaC": null, + "terraform": null, + "other": "1. In the AWS Console, open Amazon Macie\n2. Select the correct Region from the Region selector\n3. Go to Settings > Automated sensitive data discovery\n4. Click Enable under Status (choose My account if prompted)\n5. Repeat in other Regions where Macie is enabled if needed" + }, + "appstream_fleet_maximum_session_duration": { + "checkTitle": "AppStream fleet maximum user session duration is less than 10 hours", + "recommendation": "Configure the **maximum session duration** to `<= 10 hours` (e.g., `600` minutes) or less based on data sensitivity. Prefer shorter limits, enforce **reauthentication** on renewal, apply **least privilege**, and enable **idle timeouts**. Monitor session activity as part of **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/appstream_fleet_maximum_session_duration", + "cli": "aws appstream update-fleet --name --max-user-duration-in-seconds 3600", + "nativeIaC": "```yaml\n# CloudFormation: Set AppStream Fleet session duration below 10 hours\nResources:\n AppStreamFleet:\n Type: AWS::AppStream::Fleet\n Properties:\n Name: \"\"\n MaxUserDurationInSeconds: 3600 # CRITICAL: ensures max session duration is < 10h to pass the check\n```", + "terraform": "```hcl\n# Terraform: Set AppStream Fleet session duration below 10 hours\nresource \"aws_appstream_fleet\" \"\" {\n name = \"\"\n max_user_duration_in_seconds = 3600 # CRITICAL: ensures max session duration is < 10h to pass the check\n}\n```", + "other": "1. Open the AWS Console and go to Amazon AppStream 2.0\n2. Click Fleets and select \n3. Click Edit\n4. Set Maximum session duration to a value under 10 hours (e.g., 3600 seconds)\n5. Save changes" + }, + "appstream_fleet_session_idle_disconnect_timeout": { + "checkTitle": "AppStream fleet session idle disconnect timeout is 10 minutes or less", + "recommendation": "Configure an **idle disconnect timeout 10 minutes**. Pair with a short `disconnect_timeout`, require **re-authentication** on reconnect, and enforce **least privilege**. Monitor session metrics and adjust per role to balance **security**, **cost**, and **user experience**.", + "recommendationUrl": "https://hub.prowler.com/check/appstream_fleet_session_idle_disconnect_timeout", + "cli": "aws appstream update-fleet --name --idle-disconnect-timeout-in-seconds 600", + "nativeIaC": "```yaml\nResources:\n :\n Type: AWS::AppStream::Fleet\n Properties:\n Name: \n InstanceType: stream.standard.small\n ComputeCapacity:\n DesiredInstances: 1\n IdleDisconnectTimeoutInSeconds: 600 # Critical: set to 10 minutes or less to pass the check\n```", + "terraform": "```hcl\nresource \"aws_appstream_fleet\" \"\" {\n name = \"\"\n instance_type = \"stream.standard.small\"\n image_name = \"\"\n\n compute_capacity {\n desired_instances = 1\n }\n\n idle_disconnect_timeout_in_seconds = 600 # Critical: enforce <= 10 minutes to pass the check\n}\n```", + "other": "1. Open the AWS Console and go to AppStream 2.0 > Fleets\n2. Select your fleet and click Edit\n3. Find \"Idle disconnect timeout\"\n4. Set it to 10 minutes (600 seconds) or less\n5. Click Save" + }, + "appstream_fleet_default_internet_access_disabled": { + "checkTitle": "AppStream fleet has default internet access disabled", + "recommendation": "Disable default Internet access (`EnableDefaultInternetAccess=false`) and place fleets in **private subnets**. Provide egress via **NAT gateways** or proxies, enforce **egress filtering**, and apply **least privilege** and **zero trust** to restrict outbound traffic. Use private connectivity to AWS services where possible.", + "recommendationUrl": "https://hub.prowler.com/check/appstream_fleet_default_internet_access_disabled", + "cli": "aws appstream update-fleet --name --no-enable-default-internet-access", + "nativeIaC": "```yaml\n# CloudFormation: disable default internet access on an AppStream fleet\nResources:\n :\n Type: AWS::AppStream::Fleet\n Properties:\n Name: \n InstanceType: \n EnableDefaultInternetAccess: false # Critical: disables default internet access to pass the check\n```", + "terraform": "```hcl\n# Terraform: disable default internet access on an AppStream fleet\nresource \"aws_appstream_fleet\" \"\" {\n name = \"\"\n instance_type = \"stream.standard.small\"\n image_name = \"\"\n compute_capacity { desired_instances = 1 }\n\n enable_default_internet_access = false # Critical: disables default internet access to pass the check\n}\n```", + "other": "1. In the AWS console, go to Amazon AppStream 2.0 > Fleets\n2. Select the target fleet\n3. If the fleet is RUNNING, click Actions > Stop and wait until the state is Stopped\n4. Click Edit (or Modify)\n5. Uncheck \"Default internet access\" (Disable \"Enable default internet access\")\n6. Save/Update the fleet and start it if needed" + }, + "appstream_fleet_session_disconnect_timeout": { + "checkTitle": "AppStream fleet session disconnect timeout is 5 minutes or less", + "recommendation": "Set `DisconnectTimeoutInSeconds` to `300` or less across fleets. Pair with a short `IdleDisconnectTimeoutInSeconds`, require re-authentication on reconnect, and enforce **least privilege**. Monitor session events and use **defense in depth** (network restrictions, device posture) to reduce takeover risk.", + "recommendationUrl": "https://hub.prowler.com/check/appstream_fleet_session_disconnect_timeout", + "cli": "aws appstream update-fleet --name --disconnect-timeout-in-seconds 300", + "nativeIaC": "```yaml\n# CloudFormation: Set AppStream Fleet disconnect timeout to 5 minutes or less\nResources:\n ExampleFleet:\n Type: AWS::AppStream::Fleet\n Properties:\n Name: \n InstanceType: stream.standard.medium\n ImageName: \n ComputeCapacity:\n DesiredInstances: 1\n DisconnectTimeoutInSeconds: 300 # CRITICAL: ensures session disconnect timeout is <= 300s\n```", + "terraform": "```hcl\n# Terraform: Set AppStream Fleet disconnect timeout to 5 minutes or less\nresource \"aws_appstream_fleet\" \"\" {\n name = \"\"\n instance_type = \"stream.standard.medium\"\n image_name = \"\"\n\n compute_capacity {\n desired_instances = 1\n }\n\n disconnect_timeout_in_seconds = 300 # CRITICAL: ensures timeout is <= 300s\n}\n```", + "other": "1. In the AWS console, go to Amazon AppStream 2.0 > Fleets\n2. Select the fleet and choose Edit\n3. Set Disconnect timeout to 5 minutes (300 seconds) or less\n4. Save changes" + }, + "directconnect_virtual_interface_redundancy": { + "checkTitle": "Direct Connect gateway or virtual private gateway has at least two virtual interfaces on different Direct Connect connections", + "recommendation": "Apply connectivity **defense in depth**:\n- Attach at least two `VIFs` per gateway on separate **Direct Connect connections** in distinct locations\n- Prefer active/active dynamic routing and size capacity to survive a link loss\n- *Optionally* add a **VPN/Transit Gateway** path to sustain operations during provider outages", + "recommendationUrl": "https://hub.prowler.com/check/directconnect_virtual_interface_redundancy", + "cli": "aws directconnect create-private-virtual-interface --connection-id --new-private-virtual-interface '{\"virtualInterfaceName\":\"\",\"vlan\":,\"asn\":,\"addressFamily\":\"ipv4\",\"amazonAddress\":\"\",\"customerAddress\":\"\",\"directConnectGatewayId\":\"\"}'", + "nativeIaC": null, + "terraform": "```hcl\n# Create a second Private VIF on a different DX connection and attach to the gateway\nresource \"aws_dx_private_virtual_interface\" \"example\" {\n connection_id = \"\" # CRITICAL: use a DIFFERENT Direct Connect connection than existing VIFs\n dx_gateway_id = \"\" # CRITICAL: attaches the VIF to the Direct Connect gateway (use virtual_gateway_id for VGW)\n name = \"\"\n vlan = 100\n bgp_asn = 65000\n address_family = \"ipv4\"\n amazon_address = \"169.254.100.1/30\"\n customer_address = \"169.254.100.2/30\"\n}\n```", + "other": "1. In the AWS Console, open Direct Connect\n2. Go to Connections and select a different connection than the one used by your existing VIF\n3. Click Create virtual interface and choose Private\n4. For Gateway, select your Direct Connect gateway (or Virtual private gateway for VGW)\n5. Enter VLAN, BGP ASN, and IPv4 peer IPs (Amazon/Customer), then Create\n6. Verify the gateway now has at least two VIFs on different Direct Connect connections" + }, + "directconnect_connection_redundancy": { + "checkTitle": "Direct Connect connections span at least two locations per region", + "recommendation": "Apply **redundancy** and **defense in depth**:\n- Deploy 2 Direct Connect connections across **two distinct locations**\n- Use **dynamic, active/active routing** for automatic failover\n- Ensure **provider/device diversity**\n- Size capacity so one link loss doesn't overload remaining paths\n- Consider a **VPN** as tertiary backup", + "recommendationUrl": "https://hub.prowler.com/check/directconnect_connection_redundancy", + "cli": "aws directconnect create-connection --region --location --bandwidth 1Gbps --connection-name ", + "nativeIaC": null, + "terraform": "```hcl\n# Create an additional Direct Connect connection in a different location\nresource \"aws_dx_connection\" \"example\" {\n name = \"\"\n bandwidth = \"1Gbps\"\n location = \"\" # Critical: choose a different DX location in the same Region to achieve location redundancy\n}\n```", + "other": "1. In the AWS Console, go to Direct Connect > Connections\n2. Click Create connection\n3. Region: select the Region where the existing connection resides\n4. Name: enter \n5. Location: select a different Direct Connect location than your existing connection\n6. Bandwidth: choose a supported value (e.g., 1 Gbps)\n7. Click Create connection" + }, + "kafka_cluster_mutual_tls_authentication_enabled": { + "checkTitle": "Kafka cluster has TLS authentication enabled", + "recommendation": "Enable **mutual TLS** for client-broker traffic and disable `PLAINTEXT` listeners. Issue short-lived client certificates from a managed CA with rotation. Apply **least privilege** using Kafka ACLs, restrict network access to trusted sources, and monitor authentication events as part of **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/kafka_cluster_mutual_tls_authentication_enabled", + "cli": "aws kafka update-security --cluster-arn --current-version --client-authentication 'Tls={CertificateAuthorityArnList=[\"\"]}' --encryption-info 'EncryptionInTransit={ClientBroker=TLS}'", + "nativeIaC": "```yaml\n# CloudFormation: Enable mTLS for an MSK cluster\nResources:\n :\n Type: AWS::MSK::Cluster\n Properties:\n ClusterName: \n KafkaVersion: \n NumberOfBrokerNodes: 2\n BrokerNodeGroupInfo:\n InstanceType: kafka.m5.large\n ClientSubnets:\n - \n - \n ClientAuthentication:\n Tls:\n CertificateAuthorityArnList:\n - # CRITICAL: Enables mutual TLS using this Private CA\n EncryptionInfo:\n EncryptionInTransit:\n ClientBroker: TLS # CRITICAL: Required when enabling mTLS\n```", + "terraform": "```hcl\n# Terraform: Enable mTLS for an MSK cluster\nresource \"aws_msk_cluster\" \"\" {\n cluster_name = \"\"\n kafka_version = \"\"\n number_of_broker_nodes = 2\n\n broker_node_group_info {\n instance_type = \"kafka.m5.large\"\n client_subnets = [\"\", \"\"]\n }\n\n client_authentication {\n tls {\n certificate_authority_arns = [\"\"] # CRITICAL: Enables mutual TLS with this Private CA\n }\n }\n\n encryption_info {\n encryption_in_transit {\n client_broker = \"TLS\" # CRITICAL: Required when enabling mTLS\n }\n }\n}\n```", + "other": "1. In the AWS Console, go to Amazon MSK > Clusters and select the provisioned cluster (state must be ACTIVE)\n2. Choose Actions > Update security (or Security > Edit)\n3. Under Client authentication, enable TLS and add your AWS Private CA ARN(s)\n4. Under Encryption in transit, set Client-broker to TLS\n5. Save/Update and wait for the update to complete" + }, + "kafka_cluster_encryption_at_rest_uses_cmk": { + "checkTitle": "Kafka cluster has encryption at rest enabled with a customer managed key (CMK) or is serverless", + "recommendation": "Use a **customer-managed KMS key** for MSK at-rest encryption. Apply **least privilege** in key policies and grants, enable **key rotation**, and log key use for auditing. Enforce **separation of duties** between MSK admins and KMS key custodians, and regularly review access, aliases, and pending-deletion states.", + "recommendationUrl": "https://hub.prowler.com/check/kafka_cluster_encryption_at_rest_uses_cmk", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: MSK cluster using a customer managed KMS key for encryption at rest\nResources:\n :\n Type: AWS::MSK::Cluster\n Properties:\n ClusterName: \n KafkaVersion: \n NumberOfBrokerNodes: 2\n BrokerNodeGroupInfo:\n InstanceType: kafka.m5.large\n ClientSubnets:\n - \n - \n SecurityGroups:\n - \n EncryptionInfo:\n EncryptionAtRest:\n DataVolumeKMSKeyId: # Critical: use a customer managed KMS key ARN to enable CMK encryption at rest\n```", + "terraform": "```hcl\n# MSK cluster using a customer managed KMS key for encryption at rest\nresource \"aws_msk_cluster\" \"\" {\n cluster_name = \"\"\n kafka_version = \"\"\n number_of_broker_nodes = 2\n\n broker_node_group_info {\n instance_type = \"kafka.m5.large\"\n client_subnets = [\"\", \"\"]\n security_groups = [\"\"]\n }\n\n encryption_info {\n encryption_at_rest_kms_key_arn = \"\" # Critical: customer managed KMS key to pass the check\n }\n}\n```", + "other": "1. In the AWS Console, go to Amazon MSK > Clusters\n2. Click Create cluster\n3. Choose Provisioned (or choose Serverless to pass by default)\n4. In Encryption settings, for At-rest encryption, select Customer managed key and choose your CMK (not alias/aws/kafka)\n5. Create the cluster, migrate clients to it, then delete the old cluster that used the AWS managed key" + }, + "kafka_cluster_unrestricted_access_disabled": { + "checkTitle": "Kafka cluster requires authentication", + "recommendation": "Disable **unauthenticated access** and require **strong client authentication** (mTLS or IAM/SASL).\n- Enforce **least privilege** with scoped ACLs\n- Restrict network paths via private connectivity and tight security groups\n- Encrypt in transit, monitor access, and rotate credentials regularly", + "recommendationUrl": "https://hub.prowler.com/check/kafka_cluster_unrestricted_access_disabled", + "cli": "aws kafka update-security --cluster-arn --current-version --client-authentication 'Unauthenticated={Enabled=false}'", + "nativeIaC": "```yaml\n# CloudFormation: Disable unauthenticated client access for MSK\nResources:\n :\n Type: AWS::MSK::Cluster\n Properties:\n ClusterName: \n KafkaVersion: \n NumberOfBrokerNodes: 2\n BrokerNodeGroupInfo:\n InstanceType: \n ClientSubnets:\n - \n - \n StorageInfo:\n EbsStorageInfo:\n VolumeSize: 1000\n ClientAuthentication:\n Unauthenticated:\n Enabled: false # CRITICAL: Disables unauthenticated client access\n```", + "terraform": "```hcl\n# Terraform: Disable unauthenticated client access for MSK\nresource \"aws_msk_cluster\" \"\" {\n cluster_name = \"\"\n kafka_version = \"\"\n number_of_broker_nodes = 2\n\n broker_node_group_info {\n instance_type = \"\"\n client_subnets = [\"\", \"\"]\n ebs_volume_size = 1000\n }\n\n client_authentication {\n unauthenticated = false # CRITICAL: Disables unauthenticated client access\n }\n}\n```", + "other": "1. Open the AWS Console and go to Amazon MSK\n2. Select your cluster and open the Security tab\n3. Click Edit under Client authentication\n4. Turn off/clear Unauthenticated access\n5. Save changes to apply the update" + }, + "kafka_connector_in_transit_encryption_enabled": { + "checkTitle": "MSK Connect connector has encryption in transit enabled", + "recommendation": "Require **TLS** for all connector communications and disallow plaintext. Prefer private connectivity, validate certificates, and use modern cipher suites. Pair with **mutual authentication** and **least privilege** roles for defense-in-depth. Regularly review connector configs to avoid non-TLS endpoints.", + "recommendationUrl": "https://hub.prowler.com/check/kafka_connector_in_transit_encryption_enabled", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: MSK Connect connector with in-transit encryption enabled\nResources:\n :\n Type: AWS::KafkaConnect::Connector\n Properties:\n ConnectorName: \n KafkaCluster:\n ApacheKafkaCluster:\n BootstrapServers: \n Vpc:\n SecurityGroups: []\n Subnets: []\n KafkaClusterClientAuthentication:\n AuthenticationType: NONE\n KafkaClusterEncryptionInTransit:\n EncryptionType: TLS # Critical: enables TLS encryption in transit\n KafkaConnectVersion: \n Plugins:\n - CustomPlugin:\n CustomPluginArn: \n Revision: 1\n Capacity:\n ProvisionedCapacity:\n McuCount: 1\n WorkerCount: 1\n ServiceExecutionRoleArn: \n ConnectorConfiguration:\n connector.class: \n tasks.max: \"1\"\n```", + "terraform": "```hcl\n# Terraform: MSK Connect connector with in-transit encryption enabled\nresource \"aws_mskconnect_connector\" \"\" {\n name = \"\"\n kafkaconnect_version = \"\"\n\n kafka_cluster {\n apache_kafka_cluster {\n bootstrap_servers = \"\"\n vpc {\n security_groups = [\"\"]\n subnets = [\"\"]\n }\n }\n }\n\n kafka_cluster_client_authentication {\n authentication_type = \"NONE\"\n }\n\n kafka_cluster_encryption_in_transit {\n encryption_type = \"TLS\" # Critical: enables TLS encryption in transit\n }\n\n capacity {\n provisioned_capacity {\n mcu_count = 1\n worker_count = 1\n }\n }\n\n service_execution_role_arn = \"\"\n\n connector_configuration = {\n \"connector.class\" = \"\"\n \"tasks.max\" = \"1\"\n }\n\n plugin {\n custom_plugin {\n arn = \"\"\n revision = 1\n }\n }\n}\n```", + "other": "1. In the AWS console, go to Amazon MSK > MSK Connect > Connectors\n2. Select the non-TLS connector and choose Delete (encryption setting can't be changed)\n3. Choose Create connector and select your custom plugin and cluster\n4. In the Security section, set Encryption in transit to TLS (required)\n5. Complete other required fields and Create the connector" + }, + "kafka_cluster_enhanced_monitoring_enabled": { + "checkTitle": "Amazon MSK cluster has enhanced monitoring enabled", + "recommendation": "Select an enhanced level (e.g., `PER_BROKER` or finer) and establish **observability**: prioritize telemetry for broker resources, replication health, and consumer lag. Configure alerts and dashboards aligned to SLOs to enable proactive scaling and rapid incident containment. *Balance granularity with cost*.", + "recommendationUrl": "https://hub.prowler.com/check/kafka_cluster_enhanced_monitoring_enabled", + "cli": "aws kafka update-monitoring --cluster-arn --current-version --enhanced-monitoring PER_BROKER", + "nativeIaC": "```yaml\n# CloudFormation: Enable enhanced monitoring on an MSK cluster\nResources:\n :\n Type: AWS::MSK::Cluster\n Properties:\n ClusterName: \n KafkaVersion: \n NumberOfBrokerNodes: 2\n BrokerNodeGroupInfo:\n ClientSubnets:\n - \n - \n InstanceType: kafka.t3.small\n EnhancedMonitoring: PER_BROKER # Critical: sets enhanced monitoring above DEFAULT to pass the check\n```", + "terraform": "```hcl\n# Terraform: Enable enhanced monitoring on an MSK cluster\nresource \"aws_msk_cluster\" \"\" {\n cluster_name = \"\"\n kafka_version = \"\"\n number_of_broker_nodes = 2\n\n broker_node_group_info {\n instance_type = \"kafka.t3.small\"\n client_subnets = [\"\", \"\"]\n }\n\n enhanced_monitoring = \"PER_BROKER\" # Critical: sets monitoring above DEFAULT to pass the check\n}\n```", + "other": "1. Open the AWS Console and go to Amazon MSK\n2. Select your provisioned cluster\n3. Click Edit\n4. Under Monitoring, set Enhanced monitoring to PER_BROKER (or higher)\n5. Save changes and wait for the update to complete" + }, + "kafka_cluster_uses_latest_version": { + "checkTitle": "MSK cluster uses the latest Kafka version or is serverless with AWS-managed version", + "recommendation": "Adopt a controlled upgrade strategy:\n- Track MSK version support and upgrade before end of support\n- Test in staging and schedule maintenance windows\n- Use blue/green or rolling upgrades to reduce downtime\n- Validate client compatibility and security settings\n- Consider serverless MSK if automatic versioning fits your risk model", + "recommendationUrl": "https://hub.prowler.com/check/kafka_cluster_uses_latest_version", + "cli": "aws kafka update-cluster-kafka-version --cluster-arn --current-version --target-kafka-version ", + "nativeIaC": "```yaml\n# CloudFormation: Upgrade MSK cluster to latest Kafka version\nResources:\n :\n Type: AWS::MSK::Cluster\n Properties:\n ClusterName: \n KafkaVersion: # CRITICAL: set to the latest Kafka version to pass the check\n NumberOfBrokerNodes: 2\n BrokerNodeGroupInfo:\n InstanceType: kafka.m5.large\n ClientSubnets:\n - \n - \n```", + "terraform": "```hcl\n# Terraform: Upgrade MSK cluster to latest Kafka version\nresource \"aws_msk_cluster\" \"\" {\n cluster_name = \"\"\n kafka_version = \"\" # CRITICAL: set to the latest Kafka version to pass the check\n number_of_broker_nodes = 2\n\n broker_node_group_info {\n instance_type = \"kafka.m5.large\"\n client_subnets = [\"\", \"\"]\n\n storage_info {\n ebs_storage_info { volume_size = 1000 }\n }\n }\n}\n```", + "other": "1. Open the AWS Management Console and go to Amazon MSK\n2. Select your cluster and choose Actions > Update cluster\n3. In Kafka version, select the latest available version\n4. Review and start the upgrade (Update/Start upgrade)\n5. Wait until the operation completes and the cluster status returns to Active" + }, + "kafka_cluster_is_public": { + "checkTitle": "Kafka cluster is not publicly accessible", + "recommendation": "Keep brokers private within the VPC by disabling public access and limiting exposure to trusted networks.\n\nEnforce strong auth (SASL/IAM, SASL/SCRAM, or mTLS), require TLS, and apply Kafka ACLs. Provide access via VPN, bastion, or private networking (peering/Transit Gateway). Apply **least privilege** and monitor broker connections.", + "recommendationUrl": "https://hub.prowler.com/check/kafka_cluster_is_public", + "cli": "aws kafka update-connectivity --cluster-arn --current-version --connectivity-info '{\"PublicAccess\":{\"Type\":\"DISABLED\"}}'", + "nativeIaC": "```yaml\n# CloudFormation: ensure MSK cluster is not publicly accessible\nResources:\n :\n Type: AWS::MSK::Cluster\n Properties:\n ClusterName: \n KafkaVersion: \"2.8.1\"\n NumberOfBrokerNodes: 2\n BrokerNodeGroupInfo:\n ClientSubnets:\n - \n - \n InstanceType: kafka.t3.small\n ConnectivityInfo:\n PublicAccess:\n Type: DISABLED # Critical: disables public access to brokers\n```", + "terraform": "```hcl\n# Terraform: ensure MSK cluster is not publicly accessible\nresource \"aws_msk_cluster\" \"\" {\n cluster_name = \"\"\n kafka_version = \"2.8.1\"\n number_of_broker_nodes = 2\n\n broker_node_group_info {\n client_subnets = [\n \"\",\n \"\",\n ]\n instance_type = \"kafka.t3.small\"\n\n connectivity_info {\n public_access {\n type = \"DISABLED\" # Critical: disables public access to brokers\n }\n }\n }\n}\n```", + "other": "1. Open the Amazon MSK console\n2. Select your cluster and go to the Properties tab\n3. In Network settings, click Edit public access\n4. Set Public access to Disabled (Off)\n5. Click Save changes" + }, + "kafka_cluster_in_transit_encryption_enabled": { + "checkTitle": "Kafka cluster has encryption in transit enabled", + "recommendation": "Enforce end-to-end transport protection:\n- Require `client_broker=TLS` for all clients\n- Enable `in_cluster=true` for broker-to-broker links\n\nApply **defense in depth**: restrict network paths, prefer private connectivity, and use strong client authentication with **least privilege** authorization to limit blast radius.", + "recommendationUrl": "https://hub.prowler.com/check/kafka_cluster_in_transit_encryption_enabled", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: MSK cluster with encryption in transit enforced\nResources:\n :\n Type: AWS::MSK::Cluster\n Properties:\n ClusterName: \n KafkaVersion: \n NumberOfBrokerNodes: 3\n BrokerNodeGroupInfo:\n ClientSubnets:\n - \n - \n InstanceType: kafka.m5.large\n EncryptionInfo:\n EncryptionInTransit:\n ClientBroker: TLS # Critical: forces client-to-broker TLS only\n InCluster: true # Critical: enables inter-broker encryption\n```", + "terraform": "```hcl\n# Terraform: MSK cluster with encryption in transit enforced\nresource \"aws_msk_cluster\" \"\" {\n cluster_name = \"\"\n kafka_version = \"\"\n number_of_broker_nodes = 3\n\n broker_node_group_info {\n instance_type = \"kafka.m5.large\"\n client_subnets = [\n \"subnet-\",\n \"subnet-\",\n ]\n }\n\n encryption_info {\n encryption_in_transit {\n client_broker = \"TLS\" # Critical: forces client-to-broker TLS only\n in_cluster = true # Critical: enables inter-broker encryption\n }\n }\n}\n```", + "other": "1. In the AWS Console, go to Amazon MSK > Clusters and select your cluster\n2. Click Edit (Security)\n3. Under Encryption in transit, set Client-broker to TLS only\n4. Save changes\n5. Verify Inter-broker (in-cluster) encryption is enabled; if it is disabled (immutable), create a new cluster with:\n - Encryption in transit: Client-broker = TLS only, Inter-broker encryption = Enabled\n - Migrate clients to the new cluster, then decommission the old one" + }, + "elbv2_deletion_protection": { + "checkTitle": "ELBv2 load balancer has deletion protection enabled", + "recommendation": "Enable **deletion protection** for production and other critical load balancers.\n\nEnforce **least privilege** to restrict delete actions, apply governance (tags and policy guardrails) for protected assets, and require **change control** with approvals. *For pipelines*, add checks that block deletion of protected resources.", + "recommendationUrl": "https://hub.prowler.com/check/elbv2_deletion_protection", + "cli": "aws elbv2 modify-load-balancer-attributes --load-balancer-arn --attributes Key=deletion_protection.enabled,Value=true", + "nativeIaC": "```yaml\nResources:\n :\n Type: AWS::ElasticLoadBalancingV2::LoadBalancer\n Properties:\n Subnets:\n - \n - \n LoadBalancerAttributes:\n - Key: deletion_protection.enabled # Critical: enable deletion protection\n Value: \"true\" # Ensures the LB cannot be deleted accidentally\n```", + "terraform": "```hcl\nresource \"aws_lb\" \"\" {\n subnets = [\"\", \"\"]\n\n enable_deletion_protection = true # Critical: enables deletion protection to pass the check\n}\n```", + "other": "1. In the AWS Console, go to EC2 > Load Balancers (under Load Balancing)\n2. Select the target load balancer\n3. Open the Attributes tab and click Edit attributes\n4. Enable Deletion protection\n5. Click Save changes" + }, + "elbv2_nlb_tls_termination_enabled": { + "checkTitle": "ELBv2 Network Load Balancer has TLS termination enabled", + "recommendation": "Enable **TLS listeners** to terminate client encryption at the NLB and enforce centralized, modern cipher policies and certificate rotation. Apply **defense in depth** by re-encrypting to targets when needed, limit backend access to the NLB, and automate certificate lifecycle with secure storage and monitoring for deprecated protocols.", + "recommendationUrl": "https://hub.prowler.com/check/elbv2_nlb_tls_termination_enabled", + "cli": "aws elbv2 create-listener --load-balancer-arn --protocol TLS --port 443 --ssl-policy ELBSecurityPolicy-TLS13-1-2-2021-06 --certificates CertificateArn= --default-actions Type=forward,TargetGroupArn=", + "nativeIaC": "```yaml\n# CloudFormation: Add a TLS listener to enable TLS termination on the NLB\nResources:\n \"\":\n Type: AWS::ElasticLoadBalancingV2::Listener\n Properties:\n LoadBalancerArn: \"\"\n Protocol: TLS # critical: enables TLS termination on the NLB\n Port: 443\n SslPolicy: ELBSecurityPolicy-TLS13-1-2-2021-06 # critical: required when Protocol is TLS\n Certificates:\n - CertificateArn: \"\" # critical: server certificate for TLS termination\n DefaultActions:\n - Type: forward\n TargetGroupArn: \"\"\n```", + "terraform": "```hcl\n# Terraform: Add a TLS listener to enable TLS termination on the NLB\nresource \"aws_lb_listener\" \"\" {\n load_balancer_arn = \"\"\n port = 443\n protocol = \"TLS\" # critical: enables TLS termination\n ssl_policy = \"ELBSecurityPolicy-TLS13-1-2-2021-06\" # critical: required for TLS\n certificate_arn = \"\" # critical: server certificate for TLS termination\n\n default_action {\n type = \"forward\"\n target_group_arn = \"\"\n }\n}\n```", + "other": "1. In the AWS Console, go to EC2 > Load Balancers and select your Network Load Balancer\n2. Open the Listeners tab and click Add listener\n3. Set Protocol to TLS and Port to 443\n4. Select an ACM certificate and a security policy\n5. Set Default action to Forward to your target group\n6. Click Save changes" + }, + "elbv2_desync_mitigation_mode": { + "checkTitle": "Application Load Balancer has desync mitigation mode set to strictest or defensive, or drops invalid header fields", + "recommendation": "Set ALBs to `desync_mitigation_mode`=`strictest` (*or* `defensive` if compatibility is required) and keep `routing.http.drop_invalid_header_fields.enabled`=`true`.\n\nApply **defense in depth**: validate RFC-compliant requests, roll out changes gradually with monitoring, and enforce **least privilege** on downstream services.", + "recommendationUrl": "https://hub.prowler.com/check/elbv2_desync_mitigation_mode", + "cli": "aws elbv2 modify-load-balancer-attributes --load-balancer-arn --attributes Key=routing.http.desync_mitigation_mode,Value=strictest", + "nativeIaC": "```yaml\n# CloudFormation: Set ALB desync mitigation mode\nResources:\n :\n Type: AWS::ElasticLoadBalancingV2::LoadBalancer\n Properties:\n Type: application\n Subnets:\n - \n - \n LoadBalancerAttributes:\n - Key: routing.http.desync_mitigation_mode # Critical: enforce strictest/defensive desync mitigation to pass the check\n Value: strictest\n```", + "terraform": "```hcl\n# Terraform: Set ALB desync mitigation mode\nresource \"aws_lb\" \"\" {\n name = \"\"\n subnets = [\"\", \"\"]\n\n desync_mitigation_mode = \"strictest\" # Critical: enforce strictest/defensive desync mitigation to pass the check\n}\n```", + "other": "1. Open the AWS Console and go to EC2 > Load Balancers\n2. Select your Application Load Balancer\n3. Choose Actions > Edit attributes (or the Attributes tab > Edit)\n4. Set Desync mitigation mode to Strictest (or Defensive)\n5. Save changes" + }, + "elbv2_waf_acl_attached": { + "checkTitle": "Application Load Balancer has a WAF Web ACL attached", + "recommendation": "Associate a **WAF web ACL** with each ALB as **defense in depth**. Use managed and custom rules, IP reputation lists, and rate limiting to block attacks. Continuously tune policies and monitor logs. *Apply least privilege* by scoping rules to required paths, methods, and sources.", + "recommendationUrl": "https://hub.prowler.com/check/elbv2_waf_acl_attached", + "cli": "aws wafv2 associate-web-acl --web-acl-arn --resource-arn ", + "nativeIaC": "```yaml\n# CloudFormation: associate an existing WAFv2 Web ACL to an ALB\nResources:\n :\n Type: AWS::WAFv2::WebACLAssociation\n Properties:\n ResourceArn: # CRITICAL: ALB ARN to protect\n WebACLArn: # CRITICAL: WAFv2 Web ACL ARN to attach\n```", + "terraform": "```hcl\n# Associate WAFv2 Web ACL with an ALB\nresource \"aws_wafv2_web_acl_association\" \"\" {\n resource_arn = \"\" # CRITICAL: ALB ARN\n web_acl_arn = \"\" # CRITICAL: WAFv2 Web ACL ARN\n}\n```", + "other": "1. In the AWS Console, open **WAF & Shield**\n2. Go to **Web ACLs** and select your regional Web ACL\n3. Click **Associated AWS resources** > **Associate resource**\n4. Select the target **Application Load Balancer** and click **Associate**" + }, + "elbv2_cross_zone_load_balancing_enabled": { + "checkTitle": "ELBv2 Network or Gateway Load Balancer has cross-zone load balancing enabled", + "recommendation": "Enable **cross-zone load balancing** to spread load across zones and design for AZ redundancy.\n\n- Balance capacity per AZ and use health-based routing\n- Avoid single-AZ dependencies and sticky designs\n- Monitor zonal health to sustain **fault tolerance**", + "recommendationUrl": "https://hub.prowler.com/check/elbv2_cross_zone_load_balancing_enabled", + "cli": "aws elbv2 modify-load-balancer-attributes --load-balancer-arn --attributes Key=load_balancing.cross_zone.enabled,Value=true", + "nativeIaC": "```yaml\nResources:\n :\n Type: AWS::ElasticLoadBalancingV2::LoadBalancer\n Properties:\n Type: network\n Subnets:\n - \n - \n LoadBalancerAttributes:\n - Key: load_balancing.cross_zone.enabled # Critical: enable cross-zone load balancing\n Value: true # Ensures the check passes for NLB/GLB\n```", + "terraform": "```hcl\nresource \"aws_lb\" \"\" {\n load_balancer_type = \"network\"\n subnets = [\"\", \"\"]\n enable_cross_zone_load_balancing = true # Critical: enable cross-zone load balancing\n}\n```", + "other": "1. Open the AWS EC2 console and go to Load Balancers\n2. Select your Network or Gateway Load Balancer\n3. Choose the Attributes tab > Edit attributes\n4. Turn on Cross-zone load balancing\n5. Save changes" + }, + "elbv2_logging_enabled": { + "checkTitle": "ELBv2 Application Load Balancer has access logs to S3 configured", + "recommendation": "Enable **ALB access logging** to a dedicated, encrypted S3 bucket. Apply **least privilege** to the bucket for delivery and readers, set lifecycle policies for retention, and consider `Object Lock` to deter tampering. Centralize logs in a **SIEM** and alert on anomalies as part of **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/elbv2_logging_enabled", + "cli": "aws elbv2 modify-load-balancer-attributes --load-balancer-arn --attributes Key=access_logs.s3.enabled,Value=true Key=access_logs.s3.bucket,Value=", + "nativeIaC": "```yaml\n# CloudFormation: enable ALB access logs to S3\nResources:\n :\n Type: AWS::ElasticLoadBalancingV2::LoadBalancer\n Properties:\n Subnets:\n - \n - \n SecurityGroups:\n - \n LoadBalancerAttributes:\n - Key: access_logs.s3.enabled # critical: enable ALB access logging\n Value: \"true\"\n - Key: access_logs.s3.bucket # critical: destination S3 bucket for logs\n Value: \"\"\n```", + "terraform": "```hcl\n# Terraform: enable ALB access logs to S3\nresource \"aws_lb\" \"\" {\n name = \"\"\n security_groups = [\"\"]\n subnets = [\"\", \"\"]\n\n access_logs {\n bucket = \"\" # critical: destination S3 bucket for logs\n enabled = true # critical: enable ALB access logging\n }\n}\n```", + "other": "1. In AWS Console, go to EC2 > Load Balancers and select your Application Load Balancer\n2. Open the Attributes (or Edit attributes) section and find Access logs\n3. Check Enable access logs and choose the S3 bucket for delivery\n4. Save changes" + }, + "elbv2_internet_facing": { + "checkTitle": "Application Load Balancer is not publicly accessible (no inbound TCP from 0.0.0.0/0 or ::/0)", + "recommendation": "Enforce **least privilege** on security groups: avoid `0.0.0.0/0`; allow only trusted CIDRs or upstream services.\n\nUse an `internal` load balancer for non-public apps.\n\nFor public endpoints, layer **WAF** rules, strict TLS, and rate limiting; consider **CloudFront/Shield** for defense in depth and reduced direct exposure.", + "recommendationUrl": "https://hub.prowler.com/check/elbv2_internet_facing", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation Security Group for ALB with no public (0.0.0.0/0 or ::/0) TCP ingress\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: ALB SG restricted ingress\n VpcId: \"\"\n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 80\n ToPort: 80\n CidrIp: 10.0.0.0/8 # Critical: restricts inbound to private CIDR, preventing public access\n```", + "terraform": "```hcl\n# Security Group for ALB with no public (0.0.0.0/0 or ::/0) TCP ingress\nresource \"aws_security_group\" \"\" {\n name = \"alb-restricted-sg\"\n vpc_id = \"\"\n\n ingress {\n from_port = 80\n to_port = 80\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # Critical: restricts inbound to private CIDR, preventing public access\n }\n}\n```", + "other": "1. In AWS Console, go to EC2 > Load Balancers and select the ALB\n2. In the Description tab, note the attached Security Group and open it\n3. Click Edit inbound rules\n4. Delete any TCP rule with Source 0.0.0.0/0 or ::/0\n5. If access is needed, add only specific private CIDRs or trusted security groups\n6. Click Save rules" + }, + "elbv2_insecure_ssl_ciphers": { + "checkTitle": "ELBv2 load balancer uses a secure SSL policy on HTTPS listeners", + "recommendation": "Enforce **modern TLS** on load balancer listeners:\n- Use AWS recommended policies like `ELBSecurityPolicy-TLS13-1-2-2021-06`\n- Disable TLS 1.0/1.1 and weak ciphers; prefer suites with **forward secrecy**\n- Periodically review and update policies\n\nApply **defense in depth** with strict client access and **least privilege** for changes.", + "recommendationUrl": "https://hub.prowler.com/check/elbv2_insecure_ssl_ciphers", + "cli": "aws elbv2 modify-listener --listener-arn --ssl-policy ELBSecurityPolicy-TLS13-1-2-2021-06", + "nativeIaC": "```yaml\n# CloudFormation: Set a secure SSL policy on an HTTPS listener\nResources:\n :\n Type: AWS::ElasticLoadBalancingV2::Listener\n Properties:\n LoadBalancerArn: \n Protocol: HTTPS\n Port: 443\n DefaultActions:\n - Type: forward\n TargetGroupArn: \n Certificates:\n - CertificateArn: \n SslPolicy: ELBSecurityPolicy-TLS13-1-2-2021-06 # FIX: uses an approved secure policy to eliminate insecure ciphers\n```", + "terraform": "```hcl\n# Terraform: Ensure HTTPS listener uses a secure SSL policy\nresource \"aws_lb_listener\" \"\" {\n load_balancer_arn = \"\"\n port = 443\n protocol = \"HTTPS\"\n ssl_policy = \"ELBSecurityPolicy-TLS13-1-2-2021-06\" # FIX: approved secure policy\n certificate_arn = \"\"\n\n default_action {\n type = \"forward\"\n target_group_arn = \"\"\n }\n}\n```", + "other": "1. In the AWS Console, go to EC2 > Load Balancers\n2. Select the load balancer and open the Listeners tab\n3. Select the HTTPS listener and choose Edit\n4. Set Security policy to ELBSecurityPolicy-TLS13-1-2-2021-06 (or any approved policy)\n5. Save changes" + }, + "elbv2_is_in_multiple_az": { + "checkTitle": "ELBv2 load balancer is configured across multiple Availability Zones", + "recommendation": "Operate each load balancer across at least **two AZs** and ensure every enabled AZ has healthy, scaled targets.\n- Distribute capacity per AZ; use autoscaling\n- Keep health checks effective\n- Consider cross-zone load balancing to absorb bursts\n- Regularly test failover", + "recommendationUrl": "https://hub.prowler.com/check/elbv2_is_in_multiple_az", + "cli": "aws elbv2 set-subnets --load-balancer-arn --subnets ", + "nativeIaC": "```yaml\n# CloudFormation: ensure the ELBv2 spans at least two AZs by specifying two subnets\nResources:\n :\n Type: AWS::ElasticLoadBalancingV2::LoadBalancer\n Properties:\n Subnets:\n - # critical: add a second AZ/subnet\n - # critical: ensures the load balancer spans >=2 AZs\n```", + "terraform": "```hcl\n# Ensure ELBv2 spans at least two Availability Zones\nresource \"aws_lb\" \"\" {\n subnets = [\n \"\", # critical: add a second AZ/subnet\n \"\" # critical: ensures the load balancer spans >=2 AZs\n ]\n}\n```", + "other": "1. Open AWS Console > EC2 > Load Balancers\n2. Select the load balancer\n3. Go to the Network mapping tab and click Edit subnets\n4. Enable at least two Availability Zones by selecting one subnet in each of two AZs\n5. Click Save changes" + }, + "elbv2_ssl_listeners": { + "checkTitle": "ELBv2 Application Load Balancer listeners use HTTPS or redirect HTTP to HTTPS", + "recommendation": "Enforce **TLS everywhere**: use `HTTPS` listeners and make all `HTTP` listeners redirect to `HTTPS` only. Do not forward plaintext. Apply **defense in depth** with strong TLS policies and managed certificates, and consider `HSTS` to prevent users from reaching `http`.", + "recommendationUrl": "https://hub.prowler.com/check/elbv2_ssl_listeners", + "cli": "aws elbv2 modify-listener --listener-arn --default-actions '[{\"Type\":\"redirect\",\"RedirectConfig\":{\"Protocol\":\"HTTPS\",\"Port\":\"443\",\"StatusCode\":\"HTTP_301\"}}]'", + "nativeIaC": "```yaml\n# CloudFormation: Redirect HTTP listener to HTTPS\nResources:\n :\n Type: AWS::ElasticLoadBalancingV2::Listener\n Properties:\n LoadBalancerArn: \n Protocol: HTTP\n Port: 80\n DefaultActions:\n - Type: redirect\n RedirectConfig:\n Protocol: HTTPS # Critical: redirect HTTP to HTTPS\n Port: '443' # Critical: target HTTPS port\n StatusCode: HTTP_301 # Critical: enforce redirect\n```", + "terraform": "```hcl\n# Terraform: Redirect HTTP listener to HTTPS\nresource \"aws_lb_listener\" \"\" {\n load_balancer_arn = \"\"\n protocol = \"HTTP\"\n port = 80\n\n default_action {\n type = \"redirect\"\n redirect {\n protocol = \"HTTPS\" # Critical: redirect to HTTPS\n port = \"443\" # Critical: target HTTPS port\n status_code = \"HTTP_301\" # Critical: enforce redirect\n }\n }\n}\n```", + "other": "1. Open the EC2 console and go to Load Balancers\n2. Select the Application Load Balancer and open the Listeners tab\n3. Select the HTTP:80 listener and choose Edit (or View/edit rules)\n4. Set the default action to Redirect to, Protocol: HTTPS, Port: 443, Status code: HTTP_301\n5. Save changes" + }, + "elbv2_listeners_underneath": { + "checkTitle": "ELBv2 load balancer has at least one listener", + "recommendation": "Define at least one listener per load balancer. Prefer **HTTPS** on `443` to protect data in transit, and expose only required ports. Apply **least privilege** by limiting protocols and rules to intended traffic, and set an explicit default action to avoid unintended routing.", + "recommendationUrl": "https://hub.prowler.com/check/elbv2_listeners_underneath", + "cli": "aws elbv2 create-listener --load-balancer-arn --protocol HTTP --port 80 --default-actions 'Type=fixed-response,FixedResponseConfig={StatusCode=200}'", + "nativeIaC": "```yaml\n# CloudFormation: add a minimal listener to the ELBv2\nResources:\n :\n Type: AWS::ElasticLoadBalancingV2::Listener\n Properties:\n LoadBalancerArn: # Critical: attaches the listener to the load balancer\n Port: 80 # Critical: defines the listener port\n Protocol: HTTP # Critical: defines the listener protocol\n DefaultActions:\n - Type: fixed-response # Critical: minimal required default action so the listener is valid\n FixedResponseConfig:\n StatusCode: '200' # Critical: required for fixed-response action\n```", + "terraform": "```hcl\n# Terraform: add a minimal listener to the ELBv2\nresource \"aws_lb_listener\" \"\" {\n load_balancer_arn = \"\" # Critical: attaches the listener to the load balancer\n port = 80 # Critical: defines the listener port\n protocol = \"HTTP\" # Critical: defines the listener protocol\n\n default_action { # Critical: required default action so the listener is valid\n type = \"fixed-response\"\n fixed_response {\n status_code = \"200\" # Critical: required for fixed-response action\n }\n }\n}\n```", + "other": "1. In the AWS Console, go to EC2 > Load Balancing > Load Balancers\n2. Select the load balancer with the finding\n3. Open the Listeners tab and click Add listener\n4. Set Protocol to HTTP and Port to 80\n5. For Default action, choose Return fixed response and set Status code to 200\n6. Click Create/Save to add the listener" + }, + "stepfunctions_statemachine_logging_enabled": { + "checkTitle": "Step Functions state machine has logging enabled", + "recommendation": "Enable CloudWatch logging on all state machines at an appropriate `level` (e.g., `ERROR` or `ALL`) and send logs to a protected log group. Apply **least privilege** to log write/read, set **retention**, and avoid sensitive data unless required using `includeExecutionData`. Use X-Ray tracing for **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/stepfunctions_statemachine_logging_enabled", + "cli": "aws stepfunctions update-state-machine --state-machine-arn --logging-configuration file://logging-config.json", + "nativeIaC": "```yaml\nResources:\n :\n Type: AWS::StepFunctions::StateMachine\n Properties:\n RoleArn: arn:aws:iam:::role/\n DefinitionString: |\n {\"StartAt\":\"Pass\",\"States\":{\"Pass\":{\"Type\":\"Pass\",\"End\":true}}}\n LoggingConfiguration:\n Destinations:\n - CloudWatchLogsLogGroup:\n LogGroupArn: arn:aws:logs:::log-group::* # Critical: target CloudWatch Logs group\n Level: ERROR # Critical: enables logging (not OFF)\n```", + "terraform": "```hcl\nresource \"aws_sfn_state_machine\" \"\" {\n name = \"\"\n role_arn = \"arn:aws:iam:::role/\"\n definition = jsonencode({ StartAt = \"Pass\", States = { Pass = { Type = \"Pass\", End = true } } })\n\n logging_configuration {\n log_destination = \"arn:aws:logs:::log-group::*\" # Critical: CloudWatch Logs destination\n level = \"ERROR\" # Critical: enables logging\n }\n}\n```", + "other": "1. Open AWS Console > Step Functions > State machines\n2. Select the state machine and click Edit\n3. In Logging, enable logging\n4. Choose an existing CloudWatch Logs log group\n5. Set Level to Error (or All)\n6. Save changes" + }, + "waf_global_rulegroup_not_empty": { + "checkTitle": "AWS WAF Classic global rule group has at least one rule", + "recommendation": "Populate each rule group with **effective rules** aligned to application threats; choose `block` or `count` actions as appropriate. Prefer **managed rule groups** as a baseline and layer custom rules for **least privilege**. Avoid placeholder groups, test in staging, and monitor metrics to tune.", + "recommendationUrl": "https://hub.prowler.com/check/waf_global_rulegroup_not_empty", + "cli": "aws waf update-rule-group --rule-group-id --updates Action=INSERT,ActivatedRule={Priority=1,RuleId=,Action={Type=BLOCK}} --change-token --region us-east-1", + "nativeIaC": "```yaml\n# CloudFormation: ensure the WAF Classic global rule group has at least one rule\nResources:\n :\n Type: AWS::WAF::RuleGroup\n Properties:\n Name: \n MetricName: examplemetric\n ActivatedRules:\n - Priority: 1 # Critical: adds a rule to the group (makes it non-empty)\n RuleId: # Critical: ID of the existing rule to add\n Action:\n Type: BLOCK # Critical: required action when activating the rule\n```", + "terraform": "```hcl\n# Terraform: ensure the WAF Classic global rule group has at least one rule\nresource \"aws_waf_rule_group\" \"\" {\n name = \"\"\n metric_name = \"examplemetric\"\n\n activated_rule {\n priority = 1 # Critical: adds a rule to the group (makes it non-empty)\n rule_id = \"\" # Critical: ID of the existing rule to add\n action {\n type = \"BLOCK\" # Critical: required action when activating the rule\n }\n }\n}\n```", + "other": "1. Open the AWS Console and go to AWS WAF, then switch to AWS WAF Classic\n2. At the top, set scope to Global (CloudFront)\n3. Go to Rule groups and select the target rule group\n4. Click Edit rule group\n5. Select an existing rule, choose its action (e.g., BLOCK), and click Add rule to rule group\n6. Click Update to save" + }, + "waf_regional_webacl_with_rules": { + "checkTitle": "AWS WAF Classic Regional Web ACL has at least one rule or rule group", + "recommendation": "Populate each web ACL with at least one **rule** or **rule group** that inspects requests and enforces **least privilege**. Apply defense in depth by combining managed and custom rules, include rate controls where appropriate, and review regularly. *Default to blocking undesired traffic; only permit required patterns*.", + "recommendationUrl": "https://hub.prowler.com/check/waf_regional_webacl_with_rules", + "cli": "aws waf-regional update-web-acl --web-acl-id --change-token $(aws waf-regional get-change-token --query 'ChangeToken' --output text) --updates '[{\"Action\":\"INSERT\",\"ActivatedRule\":{\"Priority\":1,\"RuleId\":\"\",\"Action\":{\"Type\":\"BLOCK\"}}}]'", + "nativeIaC": "```yaml\n# CloudFormation: Ensure the Web ACL has at least one rule\nResources:\n :\n Type: AWS::WAFRegional::WebACL\n Properties:\n Name: \"\"\n MetricName: \"\"\n DefaultAction:\n Type: ALLOW\n # Critical: adding any rule to the Web ACL makes it non-empty and passes the check\n Rules:\n - Action:\n Type: BLOCK\n Priority: 1\n RuleId: \"\" # Rule to insert into the Web ACL\n```", + "terraform": "```hcl\n# Terraform: Ensure the Web ACL has at least one rule\nresource \"aws_wafregional_web_acl\" \"\" {\n name = \"\"\n metric_name = \"\"\n\n default_action {\n type = \"ALLOW\"\n }\n\n # Critical: add at least one rule so the Web ACL is not empty\n rules {\n priority = 1\n rule_id = \"\"\n action {\n type = \"BLOCK\"\n }\n }\n}\n```", + "other": "1. Open the AWS Console and go to AWS WAF\n2. In the left pane, click Web ACLs and switch to AWS WAF Classic if prompted\n3. Select the Regional Web ACL and open the Rules tab\n4. Click Edit web ACL\n5. In Rules, select an existing rule or rule group and choose Add rule to web ACL\n6. Click Save changes" + }, + "waf_global_rule_with_conditions": { + "checkTitle": "AWS WAF Classic Global rule has at least one condition", + "recommendation": "Attach at least one precise **condition** to every rule, aligned to known threats and application context. Apply **least privilege** for traffic, use managed rule groups for **defense in depth**, and routinely review rules to remove placeholders. *If on Classic*, plan migration to WAFv2.", + "recommendationUrl": "https://hub.prowler.com/check/waf_global_rule_with_conditions", + "cli": "aws waf update-rule --rule-id --change-token --updates '[{\"Action\":\"INSERT\",\"Predicate\":{\"Negated\":false,\"Type\":\"IPMatch\",\"DataId\":\"\"}}]' --region us-east-1", + "nativeIaC": "```yaml\n# CloudFormation: ensure the WAF Classic Global rule has at least one condition\nResources:\n :\n Type: AWS::WAF::Rule\n Properties:\n Name: \n MetricName: \n # Critical: add at least one predicate (condition) so the rule is not empty\n Predicates:\n - Negated: false # evaluate as-is\n Type: IPMatch\n DataId: # existing IPSet ID\n```", + "terraform": "```hcl\n# Ensure the WAF Classic Global rule has at least one condition\nresource \"aws_waf_rule\" \"\" {\n name = \"\"\n metric_name = \"\"\n\n # Critical: add at least one predicate (condition) so the rule is not empty\n predicate {\n data_id = \"\" # existing IPSet ID\n negated = false\n type = \"IPMatch\"\n }\n}\n```", + "other": "1. Open the AWS Console > AWS WAF, then click Switch to AWS WAF Classic\n2. In Global (CloudFront) scope, go to Rules and select the target rule\n3. Click Edit (or Add rule) > Add condition\n4. Choose a condition type (e.g., IP match), select an existing condition, set it to does (not negated)\n5. Click Update/Save to apply\n" + }, + "waf_regional_rule_with_conditions": { + "checkTitle": "AWS WAF Classic Regional rule has at least one condition", + "recommendation": "Define precise **conditions** for each rule (e.g., IP, pattern, geo, size) and avoid placeholder rules. Apply **least privilege** filtering, review rule order, and use layered controls for **defense in depth**. Regularly validate and monitor rule effectiveness.", + "recommendationUrl": "https://hub.prowler.com/check/waf_regional_rule_with_conditions", + "cli": "aws waf-regional update-rule --rule-id --change-token $(aws waf-regional get-change-token --query ChangeToken --output text) --updates '[{\"Action\":\"INSERT\",\"Predicate\":{\"Negated\":false,\"Type\":\"IPMatch\",\"DataId\":\"\"}}]'", + "nativeIaC": "```yaml\n# Add at least one condition to a WAF Classic Regional Rule\nResources:\n :\n Type: AWS::WAFRegional::Rule\n Properties:\n Name: \n MetricName: \n Predicates:\n - Negated: false # CRITICAL: ensures the predicate is applied as-is\n Type: IPMatch # CRITICAL: predicate type\n DataId: # CRITICAL: attaches an existing IP set as a condition\n```", + "terraform": "```hcl\n# WAF Classic Regional rule with at least one condition\nresource \"aws_wafregional_rule\" \"\" {\n name = \"\"\n metric_name = \"\"\n\n predicate { \n data_id = \"\" # CRITICAL: attaches existing IP set as the condition\n type = \"IPMatch\" # CRITICAL: predicate type\n negated = false # CRITICAL: apply condition directly\n }\n}\n```", + "other": "1. Open the AWS Console and go to AWS WAF, then select Switch to AWS WAF Classic\n2. In the left pane, choose Regional and click Rules\n3. Select the target rule and choose Add rule\n4. Click Add condition, set When a request to does, choose IP match (or another type), and select an existing condition (e.g., an IP set)\n5. Click Update to save the rule with the condition" + }, + "waf_regional_rulegroup_not_empty": { + "checkTitle": "AWS WAF Classic Regional rule group has at least one rule", + "recommendation": "Apply **least privilege**: populate each rule group with vetted rules aligned to your threat model, using `ALLOW`, `BLOCK`, or `COUNT` actions as appropriate. Remove or disable unused groups to avoid false assurance. Validate behavior in staging and monitor metrics to maintain **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/waf_regional_rulegroup_not_empty", + "cli": "aws waf-regional update-rule-group --rule-group-id --updates Action=INSERT,ActivatedRule={Priority=1,RuleId=,Action={Type=BLOCK}} --change-token ", + "nativeIaC": "```yaml\n# CloudFormation: Ensure WAF Classic Regional Rule Group has at least one rule\nResources:\n :\n Type: AWS::WAFRegional::RuleGroup\n Properties:\n Name: \n MetricName: \n ActivatedRules:\n - Priority: 1 # Critical: adds a rule so the rule group is not empty\n RuleId: # Critical: references an existing rule to include in the group\n Action:\n Type: BLOCK\n```", + "terraform": "```hcl\n# Ensure WAF Classic Regional Rule Group has at least one rule\nresource \"aws_wafregional_rule_group\" \"\" {\n name = \"\"\n metric_name = \"\"\n\n # Critical: adds a rule so the rule group is not empty\n activated_rule {\n priority = 1\n rule_id = \"\" # existing rule ID\n action {\n type = \"BLOCK\"\n }\n }\n}\n```", + "other": "1. In the AWS Console, go to AWS WAF & Shield and switch to AWS WAF Classic\n2. Select the correct Region, then choose Rule groups\n3. Open the target rule group and click Edit rule group\n4. Click Add rule to rule group, select an existing rule, choose an action (e.g., BLOCK), and click Update\n5. Save changes to ensure the rule group contains at least one rule" + }, + "waf_global_webacl_logging_enabled": { + "checkTitle": "AWS WAF Classic Global Web ACL has logging enabled", + "recommendation": "Enable **logging** on all global Web ACLs and send records to a centralized logging platform. Apply **least privilege** to log destinations and redact sensitive fields. Monitor and alert on anomalies, and integrate logs with incident response for **defense in depth** and faster containment.", + "recommendationUrl": "https://hub.prowler.com/check/waf_global_webacl_logging_enabled", + "cli": "aws waf put-logging-configuration --logging-configuration ResourceArn=,LogDestinationConfigs=", + "nativeIaC": null, + "terraform": null, + "other": "1. In the AWS console, create an Amazon Kinesis Data Firehose delivery stream named starting with \"aws-waf-logs-\" (for CloudFront/global, create it in us-east-1)\n2. Open the AWS WAF console and switch to AWS WAF Classic\n3. Select Filter: Global (CloudFront) and go to Web ACLs\n4. Open the target Web ACL and go to the Logging tab\n5. Click Enable logging and select the Firehose delivery stream created in step 1\n6. Click Enable/Save" + }, + "waf_global_webacl_with_rules": { + "checkTitle": "AWS WAF Classic global Web ACL has at least one rule or rule group", + "recommendation": "Populate each global web ACL with effective protections:\n- Use rule groups and targeted rules (managed, rate-based, IP sets)\n- Apply least privilege: default `block` where feasible; explicitly `allow` required traffic\n- Layer defenses and enable logging to tune policies\n- *Consider migrating to WAFv2*", + "recommendationUrl": "https://hub.prowler.com/check/waf_global_webacl_with_rules", + "cli": "aws waf update-web-acl --web-acl-id --change-token --updates '[{\"Action\":\"INSERT\",\"ActivatedRule\":{\"Priority\":1,\"RuleId\":\"\",\"Action\":{\"Type\":\"BLOCK\"}}}]'", + "nativeIaC": "```yaml\nResources:\n :\n Type: AWS::WAF::WebACL\n Properties:\n Name: \n MetricName: \n DefaultAction:\n Type: ALLOW\n Rules:\n - Action:\n Type: BLOCK\n Priority: 1\n RuleId: # Critical: Adds a rule so the Web ACL is not empty\n # This ensures the Web ACL has at least one rule, changing FAIL to PASS\n```", + "terraform": "```hcl\nresource \"aws_waf_web_acl\" \"\" {\n name = \"\"\n metric_name = \"\"\n\n default_action {\n type = \"ALLOW\"\n }\n\n rules { # Critical: Adds at least one rule so the Web ACL is not empty\n priority = 1\n rule_id = \"\"\n type = \"REGULAR\"\n action {\n type = \"BLOCK\"\n }\n }\n}\n```", + "other": "1. Open the AWS console and go to WAF\n2. In the left menu, click Switch to AWS WAF Classic\n3. At the top, set Filter to Global (CloudFront)\n4. Click Web ACLs and select your web ACL\n5. On the Rules tab, click Edit web ACL\n6. In Rules, select an existing rule or rule group and click Add rule to web ACL\n7. Click Save changes" + }, + "accessanalyzer_enabled_without_findings": { + "checkTitle": "IAM Access Analyzer analyzer is active and has no active findings", + "recommendation": "Enable **IAM Access Analyzer** in all relevant Regions and org/account scopes. Triage every `Active` finding:\n- Remove unintended access by tightening resource and trust policies\n- Enforce **least privilege** and separation of duties\n- Archive only validated, intended access\n- Continuously monitor and automate reviews", + "recommendationUrl": "https://hub.prowler.com/check/accessanalyzer_enabled_without_findings", + "cli": null, + "nativeIaC": "```yaml\nResources:\n example_resource:\n Type: AWS::AccessAnalyzer::Analyzer\n Properties:\n AnalyzerName: example_resource\n Type: ACCOUNT # This line fixes the security issue\n```", + "terraform": "```hcl\nresource \"aws_accessanalyzer_analyzer\" \"example_resource\" {\n analyzer_name = \"example_resource\"\n type = \"ACCOUNT\" # This line fixes the security issue\n}\n```", + "other": "1. In the AWS Console, go to IAM > Access analyzer\n2. If no analyzer exists, click Create analyzer, select Type: Account, name it example_resource, and click Create\n3. To clear active findings: under Resource analysis, select your analyzer, select all Active findings, choose Actions > Archive\n4. For unintended access findings, open the finding and follow the linked resource to remove the offending permission (edit the resource policy or role trust policy), then return to the finding and choose Rescan\n5. Confirm the dashboard shows 0 Active findings" + }, + "accessanalyzer_enabled": { + "checkTitle": "IAM Access Analyzer is enabled", + "recommendation": "Enable **IAM Access Analyzer** across all accounts and active Regions (*or organization-wide*). Operate on least privilege: continuously review findings, remove unintended access, and trim unused permissions. Use archive rules sparingly, integrate reviews into change/CI/CD workflows, and enforce separation of duties on policy changes.", + "recommendationUrl": "https://hub.prowler.com/check/accessanalyzer_enabled", + "cli": "aws accessanalyzer create-analyzer --analyzer-name example_resource --type ACCOUNT", + "nativeIaC": "```yaml\nResources:\n example_resource:\n Type: AWS::AccessAnalyzer::Analyzer # This resource enables IAM Access Analyzer\n Properties:\n AnalyzerName: example_resource\n Type: ACCOUNT # This line fixes the security issue\n```", + "terraform": "```hcl\nresource \"aws_accessanalyzer_analyzer\" \"example_resource\" {\n analyzer_name = \"example_resource\"\n type = \"ACCOUNT\" # This line fixes the security issue\n}\n```", + "other": "1. In the AWS Console, open IAM\n2. Go to Access analyzer > Analyzer settings\n3. Confirm the desired Region\n4. Click Create analyzer\n5. Select Resource analysis - External access\n6. Set Name to \"example_resource\" and Zone of trust to \"Current account\"\n7. Click Create" + }, + "cloudwatch_log_group_not_publicly_accessible": { + "checkTitle": "CloudWatch Log Group is not publicly accessible", + "recommendation": "Remove public access from log group resource policies. Replace `Principal:\"*\"` and `Resource:\"*\"` with narrowly scoped principals and specific ARNs. Grant only necessary actions, apply conditions to constrain use, and enforce **least privilege** and **separation of duties** with regular policy reviews.", + "recommendationUrl": "https://hub.prowler.com/check/cloudwatch_log_group_not_publicly_accessible", + "cli": "aws logs delete-resource-policy --policy-name ", + "nativeIaC": "```yaml\nResources:\n :\n Type: AWS::Logs::ResourcePolicy\n Properties:\n PolicyName: \n PolicyDocument:\n Version: '2012-10-17'\n Statement:\n - Effect: Allow\n Principal:\n AWS: \"\" # FIX: restrict to specific account (not *) to prevent public access\n Action: logs:PutSubscriptionFilter\n Resource: \"arn:aws:logs:::destination:\"\n```", + "terraform": "```hcl\nresource \"aws_cloudwatch_log_resource_policy\" \"\" {\n policy_name = \"\"\n policy_document = jsonencode({\n Version = \"2012-10-17\"\n Statement = [{\n Effect = \"Allow\"\n Principal = { AWS = \"\" } # FIX: restrict Principal (not \"*\") to avoid public access\n Action = \"logs:PutSubscriptionFilter\"\n Resource = \"arn:aws:logs:::destination:\"\n }]\n })\n}\n```", + "other": "1. Open the CloudWatch console\n2. Go to Logs > Resource policies\n3. Select the policy that exposes your log groups (Principal set to \"*\" or Resource \"*\")\n4. Click Delete and confirm" + }, + "cloudwatch_log_metric_filter_authentication_failures": { + "checkTitle": "Account has a CloudWatch Logs metric filter and alarm for AWS Management Console authentication failures", + "recommendation": "Implement a log metric filter for `ConsoleLogin` failures and attach a **CloudWatch alarm** with actionable notifications. Tune thresholds to reduce noise and route alerts to incident response.\n\nApply **least privilege** and enforce **MFA** to limit impact, and correlate alerts with source IP and user context.", + "recommendationUrl": "https://hub.prowler.com/check/cloudwatch_log_metric_filter_authentication_failures", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: Metric filter and alarm for console authentication failures\nResources:\n MetricFilter:\n Type: AWS::Logs::MetricFilter\n Properties:\n LogGroupName: \"\"\n FilterPattern: '{ ($.eventName = ConsoleLogin) && ($.errorMessage = \"Failed authentication\") }' # Critical: matches failed console login events\n MetricTransformations:\n - MetricValue: \"1\"\n MetricNamespace: \"\" # Critical: creates metric namespace\n MetricName: \"\" # Critical: creates metric name\n\n Alarm:\n Type: AWS::CloudWatch::Alarm\n Properties:\n MetricName: \"\" # Critical: alarm targets metric from filter\n Namespace: \"\" # Critical: must match metric's namespace\n ComparisonOperator: GreaterThanOrEqualToThreshold\n EvaluationPeriods: 1\n Period: 300\n Statistic: Sum\n Threshold: 1\n```", + "terraform": "```hcl\n# Metric filter and alarm for console authentication failures\nresource \"aws_cloudwatch_log_metric_filter\" \"metric\" {\n name = \"\"\n log_group_name = \"\"\n pattern = \"{($.eventName = ConsoleLogin) && ($.errorMessage = \\\"Failed authentication\\\") }\" # Critical: detects failed console logins\n\n metric_transformation {\n name = \"\" # Critical: metric created by filter\n namespace = \"\" # Critical: metric namespace\n value = \"1\"\n }\n}\n\nresource \"aws_cloudwatch_metric_alarm\" \"alarm\" {\n metric_name = aws_cloudwatch_log_metric_filter.metric.metric_transformation[0].name # Critical: alarm references the filter's metric\n namespace = aws_cloudwatch_log_metric_filter.metric.metric_transformation[0].namespace # Critical: must match\n comparison_operator = \"GreaterThanOrEqualToThreshold\"\n evaluation_periods = 1\n period = 300\n statistic = \"Sum\"\n threshold = 1\n}\n```", + "other": "1. In the AWS Console, open CloudWatch\n2. Go to Logs > Log groups and select the CloudTrail log group receiving events\n3. Open the Metric filters tab > Create metric filter\n - Filter pattern: { ($.eventName = ConsoleLogin) && ($.errorMessage = \"Failed authentication\") }\n - Assign any metric name and namespace, value 1, then create\n4. On the created metric filter, select it and choose Create alarm\n - Statistic: Sum, Period: 5 minutes, Threshold type: Static, Threshold: >= 1\n - Create the alarm" + }, + "cloudwatch_log_metric_filter_policy_changes": { + "checkTitle": "CloudWatch Logs metric filter and alarm exist for IAM policy changes", + "recommendation": "Create a metric filter for IAM policy create/update/delete and attach/detach events with an **alarm** to notify responders.\n- Enforce **least privilege** and separation of duties for policy changes\n- Require approvals and central logging across Regions/accounts\n- Integrate alerts with incident response", + "recommendationUrl": "https://hub.prowler.com/check/cloudwatch_log_metric_filter_policy_changes", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: Create metric filter and alarm for IAM policy changes\nResources:\n IAMPolicyChangeMetricFilter:\n Type: AWS::Logs::MetricFilter\n Properties:\n LogGroupName: # IMPORTANT: CloudTrail log group to monitor\n # CRITICAL: Pattern matching IAM policy change events required by the check\n FilterPattern: '{($.eventName=DeleteGroupPolicy)||($.eventName=DeleteRolePolicy)||($.eventName=DeleteUserPolicy)||($.eventName=PutGroupPolicy)||($.eventName=PutRolePolicy)||($.eventName=PutUserPolicy)||($.eventName=CreatePolicy)||($.eventName=DeletePolicy)||($.eventName=CreatePolicyVersion)||($.eventName=DeletePolicyVersion)||($.eventName=AttachRolePolicy)||($.eventName=DetachRolePolicy)||($.eventName=AttachUserPolicy)||($.eventName=DetachUserPolicy)||($.eventName=AttachGroupPolicy)||($.eventName=DetachGroupPolicy)}'\n MetricTransformations:\n - MetricName: # CRITICAL: Metric created from filter\n MetricNamespace: CISBenchmark # CRITICAL: Namespace for the metric\n MetricValue: \"1\"\n\n IAMPolicyChangeAlarm:\n Type: AWS::CloudWatch::Alarm\n Properties:\n AlarmName: \n # CRITICAL: Alarm on the metric created above when >= 1 event occurs\n MetricName: \n Namespace: CISBenchmark\n Statistic: Sum\n Period: 300\n EvaluationPeriods: 1\n Threshold: 1\n ComparisonOperator: GreaterThanOrEqualToThreshold\n```", + "terraform": "```hcl\n# Terraform: Metric filter and alarm for IAM policy changes\nresource \"aws_cloudwatch_log_metric_filter\" \"\" {\n name = \"\"\n log_group_name = \"\" # CloudTrail log group\n\n # CRITICAL: Pattern matching IAM policy change events required by the check\n pattern = \"{($.eventName=DeleteGroupPolicy)||($.eventName=DeleteRolePolicy)||($.eventName=DeleteUserPolicy)||($.eventName=PutGroupPolicy)||($.eventName=PutRolePolicy)||($.eventName=PutUserPolicy)||($.eventName=CreatePolicy)||($.eventName=DeletePolicy)||($.eventName=CreatePolicyVersion)||($.eventName=DeletePolicyVersion)||($.eventName=AttachRolePolicy)||($.eventName=DetachRolePolicy)||($.eventName=AttachUserPolicy)||($.eventName=DetachUserPolicy)||($.eventName=AttachGroupPolicy)||($.eventName=DetachGroupPolicy)}\"\n\n metric_transformation {\n name = \"\" # CRITICAL: Metric created from filter\n namespace = \"CISBenchmark\" # CRITICAL: Namespace for the metric\n value = \"1\"\n }\n}\n\nresource \"aws_cloudwatch_metric_alarm\" \"\" {\n alarm_name = \"\"\n # CRITICAL: Alarm on the metric when >= 1 event occurs\n metric_name = aws_cloudwatch_log_metric_filter..metric_transformation[0].name\n namespace = aws_cloudwatch_log_metric_filter..metric_transformation[0].namespace\n statistic = \"Sum\"\n period = 300\n evaluation_periods = 1\n threshold = 1\n comparison_operator = \"GreaterThanOrEqualToThreshold\"\n}\n```", + "other": "1. Open the CloudWatch console > Logs > Log groups and select the CloudTrail log group\n2. Create metric filter:\n - Filter pattern: {($.eventName=DeleteGroupPolicy)||($.eventName=DeleteRolePolicy)||($.eventName=DeleteUserPolicy)||($.eventName=PutGroupPolicy)||($.eventName=PutRolePolicy)||($.eventName=PutUserPolicy)||($.eventName=CreatePolicy)||($.eventName=DeletePolicy)||($.eventName=CreatePolicyVersion)||($.eventName=DeletePolicyVersion)||($.eventName=AttachRolePolicy)||($.eventName=DetachRolePolicy)||($.eventName=AttachUserPolicy)||($.eventName=DetachUserPolicy)||($.eventName=AttachGroupPolicy)||($.eventName=DetachGroupPolicy)}\n - Metric name: \n - Namespace: CISBenchmark\n - Metric value: 1\n3. On the Metric filters tab, select the new filter and choose Create alarm\n4. Set: Statistic=Sum, Period=5 minutes, Threshold type=Static, Greater/Equal, Threshold=1, Evaluation periods=1\n5. Create the alarm" + }, + "cloudwatch_changes_to_network_acls_alarm_configured": { + "checkTitle": "CloudWatch log metric filter and alarm exist for Network ACL (NACL) change events", + "recommendation": "Implement a CloudWatch Logs metric filter and alarm for NACL change events from CloudTrail and route alerts to responders. Enforce **least privilege** on NACL management, require **change control**, and use **defense in depth** with configuration monitoring and flow logs to validate and monitor network posture.", + "recommendationUrl": "https://hub.prowler.com/check/cloudwatch_changes_to_network_acls_alarm_configured", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation to alert on NACL changes\nResources:\n MetricFilter:\n Type: AWS::Logs::MetricFilter\n Properties:\n LogGroupName: \"\" # CRITICAL: CloudTrail log group to monitor\n FilterPattern: '{ ($.eventName = CreateNetworkAcl) || ($.eventName = CreateNetworkAclEntry) || ($.eventName = DeleteNetworkAcl) || ($.eventName = DeleteNetworkAclEntry) || ($.eventName = ReplaceNetworkAclEntry) || ($.eventName = ReplaceNetworkAclAssociation) }' # CRITICAL: detects NACL changes\n MetricTransformations:\n - MetricValue: \"1\"\n MetricNamespace: \"CISBenchmark\"\n MetricName: \"nacl_changes\"\n\n NaclChangesAlarm:\n Type: AWS::CloudWatch::Alarm\n Properties:\n AlarmName: \"nacl_changes\"\n ComparisonOperator: GreaterThanOrEqualToThreshold\n EvaluationPeriods: 1\n MetricName: \"nacl_changes\" # CRITICAL: alarm targets the metric from the filter\n Namespace: \"CISBenchmark\"\n Period: 300\n Statistic: Sum\n Threshold: 1\n```", + "terraform": "```hcl\n# CloudWatch metric filter and alarm for NACL changes\nresource \"aws_cloudwatch_log_metric_filter\" \"nacl\" {\n name = \"nacl_changes\"\n log_group_name = \"\" # CloudTrail log group\n pattern = \"{ ($.eventName = CreateNetworkAcl) || ($.eventName = CreateNetworkAclEntry) || ($.eventName = DeleteNetworkAcl) || ($.eventName = DeleteNetworkAclEntry) || ($.eventName = ReplaceNetworkAclEntry) || ($.eventName = ReplaceNetworkAclAssociation) }\" # CRITICAL: detects NACL changes\n\n metric_transformation {\n name = \"nacl_changes\"\n namespace = \"CISBenchmark\"\n value = \"1\"\n }\n}\n\nresource \"aws_cloudwatch_metric_alarm\" \"nacl\" {\n alarm_name = \"nacl_changes\"\n comparison_operator = \"GreaterThanOrEqualToThreshold\"\n evaluation_periods = 1\n metric_name = \"nacl_changes\" # CRITICAL: alarm targets the metric from the filter\n namespace = \"CISBenchmark\"\n period = 300\n statistic = \"Sum\"\n threshold = 1\n}\n```", + "other": "1. In the AWS Console, go to CloudWatch > Log groups and open the CloudTrail log group\n2. Metric filters tab > Create metric filter\n3. Set Filter pattern to:\n { ($.eventName = CreateNetworkAcl) || ($.eventName = CreateNetworkAclEntry) || ($.eventName = DeleteNetworkAcl) || ($.eventName = DeleteNetworkAclEntry) || ($.eventName = ReplaceNetworkAclEntry) || ($.eventName = ReplaceNetworkAclAssociation) }\n4. Next > Filter name: nacl_changes; Metric namespace: CISBenchmark; Metric name: nacl_changes; Metric value: 1 > Create metric filter\n5. Select the new metric filter > Create alarm\n6. Set Statistic: Sum, Period: 5 minutes, Threshold type: Static, Condition: Greater/Equal, Threshold: 1\n7. Next through actions (optional) > Name: nacl_changes > Create alarm" + }, + "cloudwatch_changes_to_vpcs_alarm_configured": { + "checkTitle": "AWS account has a CloudWatch Logs metric filter and alarm for VPC changes", + "recommendation": "Create a CloudWatch Logs metric filter and alarm on CloudTrail for critical **VPC change events**, and notify responders. Apply **least privilege** to network changes, require change approvals, and use **defense in depth** (segmentation, route controls) to prevent and contain unauthorized modifications.", + "recommendationUrl": "https://hub.prowler.com/check/cloudwatch_changes_to_vpcs_alarm_configured", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: Create a metric filter and alarm for VPC changes\nResources:\n VPCChangesMetricFilter:\n Type: AWS::Logs::MetricFilter\n Properties:\n LogGroupName: \n FilterPattern: '{ ($.eventName = CreateVpc) || ($.eventName = DeleteVpc) || ($.eventName = ModifyVpcAttribute) || ($.eventName = AcceptVpcPeeringConnection) || ($.eventName = CreateVpcPeeringConnection) || ($.eventName = DeleteVpcPeeringConnection) || ($.eventName = RejectVpcPeeringConnection) || ($.eventName = AttachClassicLinkVpc) || ($.eventName = DetachClassicLinkVpc) || ($.eventName = DisableVpcClassicLink) || ($.eventName = EnableVpcClassicLink) }' # Critical: matches VPC change events\n MetricTransformations:\n - MetricName: vpc_changes_metric\n MetricNamespace: CISBenchmark\n MetricValue: \"1\" # Critical: emits a metric on matching events\n\n VPCChangesAlarm:\n Type: AWS::CloudWatch::Alarm\n Properties:\n MetricName: vpc_changes_metric # Critical: alarm monitors the metric above\n Namespace: CISBenchmark\n Statistic: Sum\n Period: 300\n EvaluationPeriods: 1\n Threshold: 1\n ComparisonOperator: GreaterThanOrEqualToThreshold\n```", + "terraform": "```hcl\n# Metric filter for VPC changes\nresource \"aws_cloudwatch_log_metric_filter\" \"\" {\n name = \"\"\n log_group_name = \"\"\n pattern = \"{ ($.eventName = CreateVpc) || ($.eventName = DeleteVpc) || ($.eventName = ModifyVpcAttribute) || ($.eventName = AcceptVpcPeeringConnection) || ($.eventName = CreateVpcPeeringConnection) || ($.eventName = DeleteVpcPeeringConnection) || ($.eventName = RejectVpcPeeringConnection) || ($.eventName = AttachClassicLinkVpc) || ($.eventName = DetachClassicLinkVpc) || ($.eventName = DisableVpcClassicLink) || ($.eventName = EnableVpcClassicLink) }\" # Critical: matches VPC change events\n\n metric_transformation {\n name = \"\" # Critical: metric created by the filter\n namespace = \"CISBenchmark\"\n value = \"1\"\n }\n}\n\n# Alarm on the VPC changes metric\nresource \"aws_cloudwatch_metric_alarm\" \"\" {\n metric_name = \"\" # Critical: alarm monitors the filter's metric\n namespace = \"CISBenchmark\"\n statistic = \"Sum\"\n period = 300\n evaluation_periods = 1\n threshold = 1\n comparison_operator = \"GreaterThanOrEqualToThreshold\"\n}\n```", + "other": "1. In the AWS Console, go to CloudWatch > Log groups and open the CloudTrail log group\n2. Choose Create metric filter\n3. For Filter pattern, paste:\n { ($.eventName = CreateVpc) || ($.eventName = DeleteVpc) || ($.eventName = ModifyVpcAttribute) || ($.eventName = AcceptVpcPeeringConnection) || ($.eventName = CreateVpcPeeringConnection) || ($.eventName = DeleteVpcPeeringConnection) || ($.eventName = RejectVpcPeeringConnection) || ($.eventName = AttachClassicLinkVpc) || ($.eventName = DetachClassicLinkVpc) || ($.eventName = DisableVpcClassicLink) || ($.eventName = EnableVpcClassicLink) }\n4. Name the filter and set Metric namespace to CISBenchmark, Metric name to vpc_changes_metric, Metric value to 1; create the filter\n5. Select the new filter and choose Create alarm\n6. Set Statistic to Sum, Period 5 minutes, Threshold type Static, Whenever Greater/Equal 1, Evaluation periods 1\n7. Create the alarm (actions/notifications are optional and not required for pass)\n" + }, + "cloudwatch_log_metric_filter_root_usage": { + "checkTitle": "Account has a CloudWatch Logs metric filter and alarm for root account usage", + "recommendation": "Enable real-time alerts for **root activity** using a log metric filter and a high-priority alarm with notifications.\n\nReduce exposure: enforce **least privilege**, keep root for *break-glass* with MFA, disable root access keys, and route alerts into incident response for **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/cloudwatch_log_metric_filter_root_usage", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: Create metric filter and alarm for root account usage\nResources:\n RootUsageMetricFilter:\n Type: AWS::Logs::MetricFilter\n Properties:\n LogGroupName: \"\"\n FilterPattern: '{ $.userIdentity.type = \"Root\" && $.userIdentity.invokedBy NOT EXISTS && $.eventType != \"AwsServiceEvent\" }' # CRITICAL: detects root user actions not invoked by services\n MetricTransformations:\n - MetricValue: \"1\"\n MetricNamespace: \"\" # CRITICAL: metric namespace used by the alarm\n MetricName: \"\" # CRITICAL: metric name used by the alarm\n\n RootUsageAlarm:\n Type: AWS::CloudWatch::Alarm\n Properties:\n ComparisonOperator: GreaterThanOrEqualToThreshold\n EvaluationPeriods: 1\n MetricName: \"\" # CRITICAL: alarms on the metric created by the filter\n Namespace: \"\"\n Period: 300\n Statistic: Sum\n Threshold: 1\n```", + "terraform": "```hcl\n# CloudWatch Logs metric filter for root account usage\nresource \"aws_cloudwatch_log_metric_filter\" \"\" {\n name = \"\"\n log_group_name = \"\"\n pattern = \"{ $.userIdentity.type = \\\"Root\\\" && $.userIdentity.invokedBy NOT EXISTS && $.eventType != \\\"AwsServiceEvent\\\" }\" # CRITICAL: detects root user actions\n\n metric_transformation {\n name = \"\" # CRITICAL: metric used by the alarm\n namespace = \"\"\n value = \"1\"\n }\n}\n\n# Alarm on the root usage metric\nresource \"aws_cloudwatch_metric_alarm\" \"\" {\n comparison_operator = \"GreaterThanOrEqualToThreshold\"\n evaluation_periods = 1\n metric_name = \"\" # CRITICAL: matches metric filter\n namespace = \"\"\n period = 300\n statistic = \"Sum\"\n threshold = 1\n}\n```", + "other": "1. In the AWS console, open CloudWatch > Logs > Log groups and select the CloudTrail log group\n2. Go to Metric filters > Create metric filter\n3. For Filter pattern, enter: { $.userIdentity.type = \"Root\" && $.userIdentity.invokedBy NOT EXISTS && $.eventType != \"AwsServiceEvent\" }\n4. Click Next, set any Filter name, set Metric namespace and Metric name, set Metric value to 1, then Create metric filter\n5. Select the new metric filter and click Create alarm\n6. Set Period to 5 minutes, Statistic to Sum, Threshold type Static with value 1, Evaluation periods 1, then Create alarm" + }, + "cloudwatch_log_group_retention_policy_specific_days_enabled": { + "checkTitle": "CloudWatch log group has a retention policy of at least the configured minimum days or never expires", + "recommendation": "Define a minimum retention baseline (e.g., `>=365` days) aligned to legal and investigative needs. Apply it consistently with documented exceptions. Automate enforcement, monitor changes, and restrict who can modify retention under **least privilege** and **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/cloudwatch_log_group_retention_policy_specific_days_enabled", + "cli": "aws logs put-retention-policy --log-group-name --retention-in-days ", + "nativeIaC": "```yaml\n# CloudFormation: set retention on a CloudWatch Log Group\nResources:\n :\n Type: AWS::Logs::LogGroup\n Properties:\n LogGroupName: \"\"\n RetentionInDays: # Critical: sets log retention to the required minimum to pass the check\n```", + "terraform": "```hcl\n# Set retention on a CloudWatch Log Group\nresource \"aws_cloudwatch_log_group\" \"\" {\n name = \"\"\n retention_in_days = # Critical: set to at least the required minimum to pass the check\n}\n```", + "other": "1. In the AWS Console, go to CloudWatch > Log groups\n2. Select the target log group\n3. In the Expire events after/Retention column, click the current value\n4. Choose a retention value >= or select Never expire\n5. Click Save" + }, + "cloudwatch_changes_to_network_route_tables_alarm_configured": { + "checkTitle": "Account monitors VPC route table changes with a CloudWatch Logs metric filter and alarm", + "recommendation": "Implement a **CloudWatch Logs metric filter and alarm** on CloudTrail for these route table events and notify responders. Enforce **least privilege** for route modifications, require **change control**, and apply **defense in depth** with VPC Flow Logs and guardrails to prevent and quickly contain unsafe routing changes.", + "recommendationUrl": "https://hub.prowler.com/check/cloudwatch_changes_to_network_route_tables_alarm_configured", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: Metric filter + alarm for VPC route table changes\nResources:\n RouteTableChangeMetricFilter:\n Type: AWS::Logs::MetricFilter\n Properties:\n LogGroupName: \"\"\n # CRITICAL: Detect EC2 route table change events in CloudTrail logs\n # Includes eventSource and the required eventNames\n FilterPattern: '{($.eventSource = ec2.amazonaws.com) && (($.eventName = CreateRoute) || ($.eventName = CreateRouteTable) || ($.eventName = ReplaceRoute) || ($.eventName = ReplaceRouteTableAssociation) || ($.eventName = DeleteRouteTable) || ($.eventName = DeleteRoute) || ($.eventName = DisassociateRouteTable))}'\n MetricTransformations:\n - MetricValue: \"1\"\n MetricNamespace: \"\"\n MetricName: \"\"\n\n RouteTableChangeAlarm:\n Type: AWS::CloudWatch::Alarm\n Properties:\n # CRITICAL: Alarm on the metric from the filter above\n Namespace: \"\"\n MetricName: \"\"\n ComparisonOperator: GreaterThanOrEqualToThreshold\n EvaluationPeriods: 1\n Period: 300\n Statistic: Sum\n Threshold: 1\n```", + "terraform": "```hcl\n# Metric filter + alarm for VPC route table changes\nresource \"aws_cloudwatch_log_metric_filter\" \"routes\" {\n name = \"\"\n log_group_name = \"\"\n # CRITICAL: Detect EC2 route table change events in CloudTrail logs\n pattern = \"{($.eventSource = ec2.amazonaws.com) && (($.eventName = CreateRoute) || ($.eventName = CreateRouteTable) || ($.eventName = ReplaceRoute) || ($.eventName = ReplaceRouteTableAssociation) || ($.eventName = DeleteRouteTable) || ($.eventName = DeleteRoute) || ($.eventName = DisassociateRouteTable))}\"\n\n metric_transformation {\n name = \"\"\n namespace = \"\"\n value = \"1\"\n }\n}\n\nresource \"aws_cloudwatch_metric_alarm\" \"routes\" {\n alarm_name = \"\"\n # CRITICAL: Alarm targets the metric from the filter above\n metric_name = \"\"\n namespace = \"\"\n comparison_operator = \"GreaterThanOrEqualToThreshold\"\n evaluation_periods = 1\n period = 300\n statistic = \"Sum\"\n threshold = 1\n}\n```", + "other": "1. In the AWS console, open CloudWatch > Log groups and select your CloudTrail log group\n2. Go to Metric filters > Create metric filter\n3. Set Filter pattern to:\n {($.eventSource = ec2.amazonaws.com) && (($.eventName = CreateRoute) || ($.eventName = CreateRouteTable) || ($.eventName = ReplaceRoute) || ($.eventName = ReplaceRouteTableAssociation) || ($.eventName = DeleteRouteTable) || ($.eventName = DeleteRoute) || ($.eventName = DisassociateRouteTable))}\n4. Name the metric and set Metric value to 1; choose any namespace/name\n5. Create the filter\n6. From the filter, click Create alarm\n7. Set Statistic: Sum, Period: 5 minutes, Threshold type: Static, Threshold: 1, Whenever: Greater/Equal\n8. Create the alarm (notifications optional)" + }, + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes": { + "checkTitle": "CloudWatch log metric filter and alarm exist for S3 bucket policy changes", + "recommendation": "Establish and maintain **metric filters** and **alarms** for S3 bucket policy, ACL, CORS, lifecycle, and replication changes. Route alerts to monitored channels and integrate with SIEM. Enforce **least privilege**, require change reviews, and use **defense in depth** to prevent and quickly detect unsafe bucket policy changes.", + "recommendationUrl": "https://hub.prowler.com/check/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: CloudWatch metric filter and alarm for S3 bucket policy changes\nResources:\n MetricFilter:\n Type: AWS::Logs::MetricFilter\n Properties:\n LogGroupName: # Critical: CloudTrail log group to monitor\n FilterPattern: '{($.eventSource=s3.amazonaws.com) && (($.eventName=PutBucketAcl) || ($.eventName=PutBucketPolicy) || ($.eventName=PutBucketCors) || ($.eventName=PutBucketLifecycle) || ($.eventName=PutBucketReplication) || ($.eventName=DeleteBucketPolicy) || ($.eventName=DeleteBucketCors) || ($.eventName=DeleteBucketLifecycle) || ($.eventName=DeleteBucketReplication))}' # Critical: detects S3 bucket policy changes\n MetricTransformations:\n - MetricName: \n MetricNamespace: \n MetricValue: \"1\"\n\n Alarm:\n Type: AWS::CloudWatch::Alarm\n Properties:\n AlarmName: \n Namespace: # Critical: must match metric filter\n MetricName: # Critical: must match metric filter\n ComparisonOperator: GreaterThanOrEqualToThreshold\n EvaluationPeriods: 1\n Period: 300\n Statistic: Sum\n Threshold: 1\n```", + "terraform": "```hcl\n# CloudWatch metric filter for S3 bucket policy changes\nresource \"aws_cloudwatch_log_metric_filter\" \"\" {\n name = \"\"\n log_group_name = \"\"\n # Critical: detects S3 bucket policy changes from CloudTrail logs\n pattern = \"{($.eventSource=s3.amazonaws.com) && (($.eventName=PutBucketAcl) || ($.eventName=PutBucketPolicy) || ($.eventName=PutBucketCors) || ($.eventName=PutBucketLifecycle) || ($.eventName=PutBucketReplication) || ($.eventName=DeleteBucketPolicy) || ($.eventName=DeleteBucketCors) || ($.eventName=DeleteBucketLifecycle) || ($.eventName=DeleteBucketReplication))}\"\n\n metric_transformation {\n name = \"\"\n namespace = \"\"\n value = \"1\"\n }\n}\n\n# Alarm on the metric filter\nresource \"aws_cloudwatch_metric_alarm\" \"\" {\n alarm_name = \"\"\n metric_name = \"\" # Critical: matches metric filter\n namespace = \"\" # Critical: matches metric filter\n comparison_operator = \"GreaterThanOrEqualToThreshold\"\n evaluation_periods = 1\n period = 300\n statistic = \"Sum\"\n threshold = 1\n}\n```", + "other": "1. Open the CloudWatch console and go to Logs > Log groups.\n2. Select the CloudTrail log group that receives your trail events.\n3. Create metric filter:\n - Choose Create metric filter.\n - Filter pattern:\n ```\n {($.eventSource=s3.amazonaws.com) && (($.eventName=PutBucketAcl) || ($.eventName=PutBucketPolicy) || ($.eventName=PutBucketCors) || ($.eventName=PutBucketLifecycle) || ($.eventName=PutBucketReplication) || ($.eventName=DeleteBucketPolicy) || ($.eventName=DeleteBucketCors) || ($.eventName=DeleteBucketLifecycle) || ($.eventName=DeleteBucketReplication))}\n ```\n - Set Metric name and Namespace (any values) and Metric value = 1. Save.\n4. From the Metric filters tab, select the new filter and choose Create alarm.\n5. Set: Statistic = Sum, Period = 5 minutes, Threshold type = Static, Condition = Greater/Equal, Threshold = 1, Evaluation periods = 1. Create alarm." + }, + "cloudwatch_cross_account_sharing_disabled": { + "checkTitle": "CloudWatch does not allow cross-account sharing", + "recommendation": "Disable **cross-account sharing** unless strictly required. If needed, restrict access to specific trusted accounts, scope read-only permissions to only necessary resources, and use a dedicated monitoring account. Apply **least privilege** and **separation of duties**, and regularly audit role trust and access patterns.", + "recommendationUrl": "https://hub.prowler.com/check/cloudwatch_cross_account_sharing_disabled", + "cli": "aws cloudformation delete-stack --stack-name CloudWatch-CrossAccountSharingRole", + "nativeIaC": null, + "terraform": null, + "other": "1. Sign in to the AWS Management Console and open IAM\n2. Go to Roles\n3. Find and select the role named \"CloudWatch-CrossAccountSharingRole\"\n4. Click Delete and confirm\n5. If deletion is blocked because it is managed by CloudFormation: open CloudFormation, select the stack named \"CloudWatch-CrossAccountSharingRole\", and click Delete" + }, + "cloudwatch_log_group_kms_encryption_enabled": { + "checkTitle": "CloudWatch log group is encrypted with an AWS KMS key", + "recommendation": "Associate each log group with a **customer-managed KMS key** via `kmsKeyId`.\n- Enforce **least privilege** in key and IAM policies, granting `kms:Decrypt` only to required principals\n- Enable rotation and monitor key usage\n- Separate keys by app/tenant to support **defense in depth** and rapid revocation", + "recommendationUrl": "https://hub.prowler.com/check/cloudwatch_log_group_kms_encryption_enabled", + "cli": "aws logs associate-kms-key --log-group-name --kms-key-id arn:aws:kms:::key/", + "nativeIaC": "```yaml\n# CloudFormation: Encrypt a CloudWatch Log Group with KMS\nResources:\n :\n Type: AWS::Logs::LogGroup\n Properties:\n KmsKeyId: arn:aws:kms:::key/ # Critical: associates a CMK to encrypt the log group\n```", + "terraform": "```hcl\n# Encrypt a CloudWatch Log Group with KMS\nresource \"aws_cloudwatch_log_group\" \"\" {\n name = \"\"\n kms_key_id = \"arn:aws:kms:::key/\" # Critical: associates a CMK to encrypt the log group\n}\n```", + "other": "1. In the AWS Console, go to CloudWatch > Log groups\n2. Click Create log group and enter a name\n3. Under Encryption, select KMS key and provide the key ARN\n4. Click Create log group\n5. For existing log groups, the console cannot attach a KMS key; use the CLI command provided" + }, + "cloudwatch_log_metric_filter_sign_in_without_mfa": { + "checkTitle": "CloudWatch log metric filter and alarm exist for Management Console sign-in without MFA", + "recommendation": "Enforce **MFA** for all console-capable identities and maintain alerts for `ConsoleLogin` with `MFAUsed != \\\"Yes\\\"`.\n\nApply **least privilege**, route alarms to monitored channels, and tune for SSO to reduce noise. Test alarms regularly and review coverage as part of **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/cloudwatch_log_metric_filter_sign_in_without_mfa", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: Create metric filter and alarm for console sign-in without MFA\nResources:\n NoMFAConsoleSigninMetricFilter:\n Type: AWS::Logs::MetricFilter\n Properties:\n LogGroupName: \"\"\n FilterPattern: '{ ($.eventName = \"ConsoleLogin\") && ($.additionalEventData.MFAUsed != \"Yes\") }' # CRITICAL: detects ConsoleLogin events without MFA\n MetricTransformations:\n - MetricName: \"\"\n MetricNamespace: \"\"\n MetricValue: \"1\" # CRITICAL: emits a metric on each match\n\n NoMFAConsoleSigninAlarm:\n Type: AWS::CloudWatch::Alarm\n Properties:\n MetricName: \"\" # CRITICAL: alarm uses the metric from the filter\n Namespace: \"\"\n ComparisonOperator: GreaterThanOrEqualToThreshold\n EvaluationPeriods: 1\n Period: 300\n Statistic: Sum\n Threshold: 1 # CRITICAL: alarm on first occurrence\n```", + "terraform": "```hcl\n# Create metric filter for console sign-in without MFA\nresource \"aws_cloudwatch_log_metric_filter\" \"nomfa\" {\n name = \"\"\n log_group_name = \"\"\n pattern = \"{ ($.eventName = \\\"ConsoleLogin\\\") && ($.additionalEventData.MFAUsed != \\\"Yes\\\") }\" # CRITICAL: detects ConsoleLogin without MFA\n\n metric_transformation {\n name = \"\"\n namespace = \"\"\n value = \"1\" # CRITICAL: emits a count per match\n }\n}\n\n# Alarm on the emitted metric\nresource \"aws_cloudwatch_metric_alarm\" \"nomfa\" {\n alarm_name = \"\"\n metric_name = aws_cloudwatch_log_metric_filter.nomfa.metric_transformation[0].name # CRITICAL: ties alarm to the metric\n namespace = aws_cloudwatch_log_metric_filter.nomfa.metric_transformation[0].namespace\n comparison_operator = \"GreaterThanOrEqualToThreshold\"\n evaluation_periods = 1\n period = 300\n statistic = \"Sum\"\n threshold = 1 # CRITICAL: alarm on first event\n}\n```", + "other": "1. In AWS Console, go to CloudWatch > Logs > Log groups and open the CloudTrail log group\n2. Go to Metric filters > Create metric filter\n3. Set Filter pattern to: { ($.eventName = \"ConsoleLogin\") && ($.additionalEventData.MFAUsed != \"Yes\") }\n4. Next > set Filter name, Metric namespace, Metric name; set Metric value = 1; Create metric filter\n5. Select the new filter > Create alarm\n6. Set Statistic = Sum, Period = 5 minutes, Threshold type = Static, Threshold = 1, Whenever >= 1; Next\n7. Skip actions if not needed, Name the alarm, Create alarm" + }, + "cloudwatch_alarm_actions_alarm_state_configured": { + "checkTitle": "CloudWatch metric alarm has actions configured for the ALARM state", + "recommendation": "Assign at least one **ALARM-state action** per alarm (e.g., notify via SNS or run automated remediation with Lambda/SSM). Keep actions enabled, apply **least privilege** to targets, and regularly test. *For critical metrics*, add redundant paths (EventBridge) for **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/cloudwatch_alarm_actions_alarm_state_configured", + "cli": "aws cloudwatch put-metric-alarm --alarm-name --metric-name --namespace --statistic --period --evaluation-periods --threshold --comparison-operator --alarm-actions ", + "nativeIaC": "```yaml\n# CloudFormation: add an ALARM action to a metric alarm\nResources:\n :\n Type: AWS::CloudWatch::Alarm\n Properties:\n AlarmName: \n MetricName: \n Namespace: \n Statistic: Average\n Period: 60\n EvaluationPeriods: 1\n Threshold: 1\n ComparisonOperator: GreaterThanThreshold\n AlarmActions:\n - # CRITICAL: adds an action for ALARM state so the check passes\n```", + "terraform": "```hcl\n# Terraform: add an ALARM action to a metric alarm\nresource \"aws_cloudwatch_metric_alarm\" \"\" {\n alarm_name = \"\"\n metric_name = \"\"\n namespace = \"\"\n statistic = \"Average\"\n period = 60\n evaluation_periods = 1\n threshold = 1\n comparison_operator = \"GreaterThanThreshold\"\n alarm_actions = [\"\"] # CRITICAL: ensures an action is configured for ALARM state\n}\n```", + "other": "1. Open the AWS Console and go to CloudWatch > Alarms\n2. Select the target alarm and choose Edit (or Modify alarm)\n3. In Actions, under When alarm state is ALARM, add an action (e.g., select an SNS topic or other supported action)\n4. Click Save changes" + }, + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled": { + "checkTitle": "CloudWatch Logs metric filter and alarm exist for CloudTrail configuration changes", + "recommendation": "Implement a **metric filter** for trail configuration events and a linked **alarm** that notifies response channels.\n\nApply **least privilege** and **separation of duties** for trail changes, add **defense in depth** with centralized logging and validation, and regularly test that alerts fire.", + "recommendationUrl": "https://hub.prowler.com/check/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", + "cli": null, + "nativeIaC": "```yaml\nResources:\n CloudTrailCfgMetricFilter:\n Type: AWS::Logs::MetricFilter\n Properties:\n LogGroupName: # CRITICAL: CloudTrail log group to monitor\n FilterPattern: \"{($.eventName = CreateTrail) || ($.eventName = UpdateTrail) || ($.eventName = DeleteTrail) || ($.eventName = StartLogging) || ($.eventName = StopLogging)}\" # CRITICAL: Detects CloudTrail config changes\n MetricTransformations:\n - MetricName: # CRITICAL: Metric created by the filter\n MetricNamespace: \n MetricValue: \"1\"\n\n CloudTrailCfgAlarm:\n Type: AWS::CloudWatch::Alarm\n Properties:\n MetricName: # CRITICAL: Alarm uses metric from filter\n Namespace: \n ComparisonOperator: GreaterThanOrEqualToThreshold\n EvaluationPeriods: 1\n Period: 300\n Statistic: Sum\n Threshold: 1\n```", + "terraform": "```hcl\nresource \"aws_cloudwatch_log_metric_filter\" \"cfg\" {\n name = \"\"\n log_group_name = \"\" # CRITICAL: CloudTrail log group\n pattern = \"{($.eventName = CreateTrail) || ($.eventName = UpdateTrail) || ($.eventName = DeleteTrail) || ($.eventName = StartLogging) || ($.eventName = StopLogging)}\" # CRITICAL: Detects CloudTrail config changes\n\n metric_transformation {\n name = \"\" # CRITICAL: Metric created by filter\n namespace = \"\"\n value = \"1\"\n }\n}\n\nresource \"aws_cloudwatch_metric_alarm\" \"cfg\" {\n metric_name = \"\" # CRITICAL: Uses metric from filter\n namespace = \"\"\n comparison_operator = \"GreaterThanOrEqualToThreshold\"\n evaluation_periods = 1\n period = 300\n statistic = \"Sum\"\n threshold = 1\n}\n```", + "other": "1. In the AWS Console, go to CloudWatch > Log groups and open the log group used by CloudTrail\n2. Create metric filter: Actions > Create metric filter\n - Filter pattern: {($.eventName = CreateTrail) || ($.eventName = UpdateTrail) || ($.eventName = DeleteTrail) || ($.eventName = StartLogging) || ($.eventName = StopLogging)}\n - Metric name: , Namespace: , Value: 1\n - Create metric filter\n3. From the Metric filters tab, select the new filter and choose Create alarm\n - Threshold: Greater/Equal 1, Period: 5 minutes, Evaluation periods: 1\n - Create alarm" + }, + "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled": { + "checkTitle": "CloudWatch Logs metric filter and alarm exist for AWS Config configuration changes", + "recommendation": "Create a **CloudWatch Logs metric filter and alarm** for `config.amazonaws.com` events (`StopConfigurationRecorder`, `DeleteDeliveryChannel`, `PutDeliveryChannel`, `PutConfigurationRecorder`). Route CloudTrail to Logs, notify responders, and enforce **least privilege** and **separation of duties** on Config changes to prevent abuse.", + "recommendationUrl": "https://hub.prowler.com/check/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: Create metric filter and alarm for AWS Config changes\nResources:\n ConfigChangeMetricFilter:\n Type: AWS::Logs::MetricFilter\n Properties:\n LogGroupName: \n FilterPattern: \"{($.eventSource = config.amazonaws.com) && (($.eventName=StopConfigurationRecorder)||($.eventName=DeleteDeliveryChannel)||($.eventName=PutDeliveryChannel)||($.eventName=PutConfigurationRecorder))}\" # Critical: detects AWS Config configuration change events\n MetricTransformations:\n - MetricName: aws_config_changes_metric # Critical: metric used by the alarm\n MetricNamespace: CISBenchmark\n MetricValue: \"1\"\n\n ConfigChangeAlarm:\n Type: AWS::CloudWatch::Alarm\n Properties:\n AlarmName: aws_config_changes_alarm\n MetricName: aws_config_changes_metric # Critical: ties alarm to the metric filter\n Namespace: CISBenchmark\n ComparisonOperator: GreaterThanOrEqualToThreshold\n EvaluationPeriods: 1\n Period: 300\n Statistic: Sum\n Threshold: 1 # Critical: alarm on first occurrence\n```", + "terraform": "```hcl\n# CloudWatch Logs metric filter for AWS Config changes\nresource \"aws_cloudwatch_log_metric_filter\" \"config_change\" {\n name = \"aws_config_changes_metric\"\n log_group_name = \"\"\n pattern = \"{($.eventSource = config.amazonaws.com) && (($.eventName=StopConfigurationRecorder)||($.eventName=DeleteDeliveryChannel)||($.eventName=PutDeliveryChannel)||($.eventName=PutConfigurationRecorder))}\" # Critical: detects AWS Config configuration change events\n\n metric_transformation {\n name = \"aws_config_changes_metric\" # Critical: metric used by the alarm\n namespace = \"CISBenchmark\"\n value = \"1\"\n }\n}\n\n# Alarm for the above metric\nresource \"aws_cloudwatch_metric_alarm\" \"config_change\" {\n alarm_name = \"aws_config_changes_alarm\"\n metric_name = \"aws_config_changes_metric\" # Critical: ties alarm to the metric filter\n namespace = \"CISBenchmark\"\n comparison_operator = \"GreaterThanOrEqualToThreshold\"\n evaluation_periods = 1\n period = 300\n statistic = \"Sum\"\n threshold = 1 # Critical: alarm on first occurrence\n}\n```", + "other": "1. Open the CloudWatch console and go to Logs > Log groups\n2. Select the CloudTrail log group that receives trail events\n3. Create a metric filter with pattern:\n {($.eventSource = config.amazonaws.com) && (($.eventName=StopConfigurationRecorder)||($.eventName=DeleteDeliveryChannel)||($.eventName=PutDeliveryChannel)||($.eventName=PutConfigurationRecorder))}\n - Metric name: aws_config_changes_metric\n - Namespace: CISBenchmark\n - Value: 1\n4. From the created metric filter, choose Create alarm\n5. Set: Sum, Period 5 minutes, Threshold >= 1, Evaluation periods 1\n6. Create the alarm (actions/notifications optional)" + }, + "cloudwatch_log_group_no_secrets_in_logs": { + "checkTitle": "CloudWatch log group contains no secrets in its log events", + "recommendation": "Avoid logging **secrets** via application sanitization and data minimization. Apply CloudWatch data protection policies to audit and mask sensitive patterns. Enforce *least privilege* for log readers and restrict `logs:Unmask`. Rotate exposed keys, reduce retention, and monitor findings to validate controls.", + "recommendationUrl": "https://hub.prowler.com/check/cloudwatch_log_group_no_secrets_in_logs", + "cli": "aws logs put-data-protection-policy --log-group-identifier --policy-document '{\"Statement\":[{\"DataIdentifier\":[\"arn:aws:dataprotection::aws:data-identifier/Credentials\"],\"Operation\":{\"Audit\":{\"FindingsDestination\":{}}}},{\"DataIdentifier\":[\"arn:aws:dataprotection::aws:data-identifier/Credentials\"],\"Operation\":{\"Deidentify\":{\"MaskConfig\":{}}}}]}'", + "nativeIaC": "```yaml\n# CloudFormation: apply data protection policy to mask secrets in a log group\nResources:\n LogGroup:\n Type: AWS::Logs::LogGroup\n Properties:\n LogGroupName: \n # Critical: Enables masking of detected credentials at egress so secrets aren't exposed\n DataProtectionPolicy: |\n {\"Statement\":[{\"DataIdentifier\":[\"arn:aws:dataprotection::aws:data-identifier/Credentials\"],\"Operation\":{\"Audit\":{\"FindingsDestination\":{}}}},{\"DataIdentifier\":[\"arn:aws:dataprotection::aws:data-identifier/Credentials\"],\"Operation\":{\"Deidentify\":{\"MaskConfig\":{}}}}]}\n```", + "terraform": "```hcl\n# Apply a CloudWatch Logs data protection policy to mask secrets\nresource \"aws_cloudwatch_log_group\" \"log_group\" {\n name = \"\"\n\n # Critical: Masks detected credentials so secrets aren't visible and the check passes\n data_protection_policy = jsonencode({\n Statement = [\n {\n DataIdentifier = [\n \"arn:aws:dataprotection::aws:data-identifier/Credentials\"\n ]\n Operation = { Audit = { FindingsDestination = {} } }\n },\n {\n DataIdentifier = [\n \"arn:aws:dataprotection::aws:data-identifier/Credentials\"\n ]\n Operation = { Deidentify = { MaskConfig = {} } }\n }\n ]\n })\n}\n```", + "other": "1. In AWS Console, go to CloudWatch > Logs > Log groups and open \n2. Select the Data protection tab and click Create policy\n3. Under Managed data identifiers, select Credentials (or AwsSecretKey if listed)\n4. Click Activate data protection to save\n5. Re-ingest or generate new logs to ensure sensitive data is masked" + }, + "cloudwatch_log_metric_filter_aws_organizations_changes": { + "checkTitle": "CloudWatch Logs metric filter and alarm exist for AWS Organizations changes", + "recommendation": "Send CloudTrail events to **CloudWatch Logs**, add a metric filter for `organizations.amazonaws.com` change events, and attach an alarm that notifies responders. Enforce **least privilege** and **separation of duties** for org admins, require MFA and approvals, and regularly test alerts to ensure timely detection and response.", + "recommendationUrl": "https://hub.prowler.com/check/cloudwatch_log_metric_filter_aws_organizations_changes", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: CloudWatch Logs metric filter and alarm for AWS Organizations changes\nResources:\n OrganizationsChangesMetricFilter:\n Type: AWS::Logs::MetricFilter\n Properties:\n LogGroupName: \n FilterPattern: '{ ($.eventSource = organizations.amazonaws.com) && (($.eventName = \"AcceptHandshake\") || ($.eventName = \"AttachPolicy\") || ($.eventName = \"CancelHandshake\") || ($.eventName = \"CreateAccount\") || ($.eventName = \"CreateOrganization\") || ($.eventName = \"CreateOrganizationalUnit\") || ($.eventName = \"CreatePolicy\") || ($.eventName = \"DeclineHandshake\") || ($.eventName = \"DeleteOrganization\") || ($.eventName = \"DeleteOrganizationalUnit\") || ($.eventName = \"DeletePolicy\") || ($.eventName = \"EnableAllFeatures\") || ($.eventName = \"EnablePolicyType\") || ($.eventName = \"InviteAccountToOrganization\") || ($.eventName = \"LeaveOrganization\") || ($.eventName = \"DetachPolicy\") || ($.eventName = \"DisablePolicyType\") || ($.eventName = \"MoveAccount\") || ($.eventName = \"RemoveAccountFromOrganization\") || ($.eventName = \"UpdateOrganizationalUnit\") || ($.eventName = \"UpdatePolicy\")) }' # Critical: matches AWS Organizations change events\n MetricTransformations:\n - MetricValue: \"1\"\n MetricNamespace: CISBenchmark\n MetricName: # Critical: creates metric used by the alarm\n\n OrganizationsChangesAlarm:\n Type: AWS::CloudWatch::Alarm\n Properties:\n ComparisonOperator: GreaterThanOrEqualToThreshold\n EvaluationPeriods: 1\n MetricName: # Critical: alarms on the metric from the filter\n Namespace: CISBenchmark # Critical: must match the metric filter namespace\n Period: 300\n Statistic: Sum\n Threshold: 1\n```", + "terraform": "```hcl\n# CloudWatch Logs metric filter for AWS Organizations changes\nresource \"aws_cloudwatch_log_metric_filter\" \"organizations_changes\" {\n name = \"\"\n log_group_name = \"\"\n pattern = \"{ ($.eventSource = organizations.amazonaws.com) && (($.eventName = \\\"AcceptHandshake\\\") || ($.eventName = \\\"AttachPolicy\\\") || ($.eventName = \\\"CancelHandshake\\\") || ($.eventName = \\\"CreateAccount\\\") || ($.eventName = \\\"CreateOrganization\\\") || ($.eventName = \\\"CreateOrganizationalUnit\\\") || ($.eventName = \\\"CreatePolicy\\\") || ($.eventName = \\\"DeclineHandshake\\\") || ($.eventName = \\\"DeleteOrganization\\\") || ($.eventName = \\\"DeleteOrganizationalUnit\\\") || ($.eventName = \\\"DeletePolicy\\\") || ($.eventName = \\\"EnableAllFeatures\\\") || ($.eventName = \\\"EnablePolicyType\\\") || ($.eventName = \\\"InviteAccountToOrganization\\\") || ($.eventName = \\\"LeaveOrganization\\\") || ($.eventName = \\\"DetachPolicy\\\") || ($.eventName = \\\"DisablePolicyType\\\") || ($.eventName = \\\"MoveAccount\\\") || ($.eventName = \\\"RemoveAccountFromOrganization\\\") || ($.eventName = \\\"UpdateOrganizationalUnit\\\") || ($.eventName = \\\"UpdatePolicy\\\")) }\" # Critical: matches AWS Organizations change events\n\n metric_transformation {\n name = \"\" # Critical: metric created by the filter\n namespace = \"CISBenchmark\" # Critical: used by the alarm\n value = \"1\"\n }\n}\n\n# Alarm on the metric generated by the filter\nresource \"aws_cloudwatch_metric_alarm\" \"organizations_changes\" {\n alarm_name = \"\"\n comparison_operator = \"GreaterThanOrEqualToThreshold\"\n evaluation_periods = 1\n metric_name = \"\" # Critical: matches metric filter name\n namespace = \"CISBenchmark\" # Critical: matches metric filter namespace\n period = 300\n statistic = \"Sum\"\n threshold = 1\n}\n```", + "other": "1. Open CloudWatch > Logs > Log groups and select the CloudTrail log group for your trail\n2. Choose Create metric filter and set Filter pattern to:\n { ($.eventSource = organizations.amazonaws.com) && (($.eventName = \"AcceptHandshake\") || ($.eventName = \"AttachPolicy\") || ($.eventName = \"CancelHandshake\") || ($.eventName = \"CreateAccount\") || ($.eventName = \"CreateOrganization\") || ($.eventName = \"CreateOrganizationalUnit\") || ($.eventName = \"CreatePolicy\") || ($.eventName = \"DeclineHandshake\") || ($.eventName = \"DeleteOrganization\") || ($.eventName = \"DeleteOrganizationalUnit\") || ($.eventName = \"DeletePolicy\") || ($.eventName = \"EnableAllFeatures\") || ($.eventName = \"EnablePolicyType\") || ($.eventName = \"InviteAccountToOrganization\") || ($.eventName = \"LeaveOrganization\") || ($.eventName = \"DetachPolicy\") || ($.eventName = \"DisablePolicyType\") || ($.eventName = \"MoveAccount\") || ($.eventName = \"RemoveAccountFromOrganization\") || ($.eventName = \"UpdateOrganizationalUnit\") || ($.eventName = \"UpdatePolicy\")) }\n3. Assign a metric: Namespace = CISBenchmark, Metric name = OrganizationsChanges, Metric value = 1, then Create metric filter\n4. On the metric filter, select Create alarm; set Statistic = Sum, Period = 5 minutes, Threshold type = Static, Threshold = 1, Evaluation periods = 1; Create alarm" + }, + "cloudwatch_alarm_actions_enabled": { + "checkTitle": "CloudWatch metric alarm has actions enabled", + "recommendation": "Enable `actions_enabled` on critical alarms and attach least-privilege actions (SNS, automation) for ALARM and recovery states. Use redundant targets, regularly test notifications, and integrate with incident response. Apply **defense in depth** with complementary detections to ensure timely, reliable alerting.", + "recommendationUrl": "https://hub.prowler.com/check/cloudwatch_alarm_actions_enabled", + "cli": "aws cloudwatch enable-alarm-actions --alarm-names ", + "nativeIaC": "```yaml\nResources:\n :\n Type: AWS::CloudWatch::Alarm\n Properties:\n ActionsEnabled: true # FIX: activates alarm actions so the check passes\n ComparisonOperator: GreaterThanThreshold\n EvaluationPeriods: 1\n MetricName: \n Namespace: \n Period: 60\n Statistic: Average\n Threshold: 1\n```", + "terraform": "```hcl\nresource \"aws_cloudwatch_metric_alarm\" \"\" {\n alarm_name = \"\"\n comparison_operator = \"GreaterThanThreshold\"\n evaluation_periods = 1\n metric_name = \"\"\n namespace = \"\"\n period = 60\n statistic = \"Average\"\n threshold = 1\n\n actions_enabled = true # FIX: activates alarm actions so the check passes\n}\n```", + "other": "1. Open the CloudWatch console\n2. Go to Alarms > All alarms and select the alarm\n3. Choose Actions > Alarm actions - new > Enable\n4. Confirm to activate actions" + }, + "cloudwatch_log_metric_filter_unauthorized_api_calls": { + "checkTitle": "CloudWatch Logs metric filter and alarm exist for unauthorized API calls", + "recommendation": "Enable real-time **alerting** by adding a CloudWatch Logs metric filter for unauthorized errors (`*UnauthorizedOperation`, `AccessDenied*`) and associating it with an alarm that notifies responders.\n- Enforce **least privilege** to reduce noise\n- Integrate with IR tooling for **defense in depth**", + "recommendationUrl": "https://hub.prowler.com/check/cloudwatch_log_metric_filter_unauthorized_api_calls", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: Create metric filter and alarm for unauthorized API calls\nResources:\n MetricFilterUnauthorized:\n Type: AWS::Logs::MetricFilter\n Properties:\n LogGroupName: \n FilterPattern: '{($.errorCode = \"*UnauthorizedOperation\") || ($.errorCode = \"AccessDenied*\")}' # Critical: detects unauthorized/denied API calls\n MetricTransformations:\n - MetricName: unauthorized_api_calls_metric\n MetricNamespace: CISBenchmark\n MetricValue: \"1\"\n\n AlarmUnauthorized:\n Type: AWS::CloudWatch::Alarm\n Properties:\n ComparisonOperator: GreaterThanOrEqualToThreshold\n EvaluationPeriods: 1\n MetricName: unauthorized_api_calls_metric # Critical: alarm on the metric from the filter\n Namespace: CISBenchmark\n Period: 300\n Statistic: Sum\n Threshold: 1\n```", + "terraform": "```hcl\n# Terraform: Metric filter and alarm for unauthorized API calls\nresource \"aws_cloudwatch_log_metric_filter\" \"unauthorized\" {\n name = \"unauthorized_api_calls_metric\"\n log_group_name = \"\"\n pattern = \"{($.errorCode = \\\"*UnauthorizedOperation\\\") || ($.errorCode = \\\"AccessDenied*\\\")}\" # Critical: detects unauthorized/denied API calls\n\n metric_transformation {\n name = \"unauthorized_api_calls_metric\"\n namespace = \"CISBenchmark\"\n value = \"1\"\n }\n}\n\nresource \"aws_cloudwatch_metric_alarm\" \"unauthorized\" {\n comparison_operator = \"GreaterThanOrEqualToThreshold\"\n evaluation_periods = 1\n metric_name = \"unauthorized_api_calls_metric\" # Critical: alarm on the metric from the filter\n namespace = \"CISBenchmark\"\n period = 300\n statistic = \"Sum\"\n threshold = 1\n}\n```", + "other": "1. In the AWS Console, open CloudWatch > Logs > Log groups and select the CloudTrail log group\n2. Go to Metric filters > Create metric filter\n3. Set Filter pattern to: {($.errorCode = \"*UnauthorizedOperation\") || ($.errorCode = \"AccessDenied*\")}\n4. Name the metric unauthorized_api_calls_metric, set Namespace to CISBenchmark, Value to 1, then create\n5. Select the new metric filter and click Create alarm\n6. Set Statistic: Sum, Period: 5 minutes, Threshold type: Static, Threshold: 1, Evaluation periods: 1\n7. Create the alarm" + }, + "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk": { + "checkTitle": "Account has a CloudWatch log metric filter and alarm for disabling or scheduled deletion of customer-managed KMS keys", + "recommendation": "Establish **CloudWatch metric filters and alarms** for `DisableKey` and `ScheduleKeyDeletion` CloudTrail events to enable rapid response.\n- Apply **least privilege** to KMS administration\n- Enforce **change control** and separation of duties\n- Use deletion waiting periods and monitor all regions", + "recommendationUrl": "https://hub.prowler.com/check/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: Metric filter and alarm for KMS key disable/deletion\nResources:\n MetricFilter:\n Type: AWS::Logs::MetricFilter\n Properties:\n LogGroupName: \n # CRITICAL: Detect KMS DisableKey or ScheduleKeyDeletion events from CloudTrail logs\n # This pattern is what the check looks for\n FilterPattern: '{($.eventSource = kms.amazonaws.com) && (($.eventName=DisableKey)||($.eventName=ScheduleKeyDeletion)) }'\n MetricTransformations:\n - MetricValue: \"1\"\n MetricNamespace: CISBenchmark\n MetricName: disable_or_delete_cmk_changes_metric\n\n Alarm:\n Type: AWS::CloudWatch::Alarm\n Properties:\n # CRITICAL: Alarm on the metric created by the filter above\n MetricName: disable_or_delete_cmk_changes_metric\n Namespace: CISBenchmark\n Statistic: Sum\n Period: 300\n EvaluationPeriods: 1\n Threshold: 1\n ComparisonOperator: GreaterThanOrEqualToThreshold\n```", + "terraform": "```hcl\n# Metric filter for KMS DisableKey or ScheduleKeyDeletion\nresource \"aws_cloudwatch_log_metric_filter\" \"cmk\" {\n name = \"\"\n log_group_name = \"\" # CRITICAL: CloudTrail log group\n # CRITICAL: Detect KMS key disable or scheduled deletion events\n pattern = \"{($.eventSource = kms.amazonaws.com) && (($.eventName=DisableKey)||($.eventName=ScheduleKeyDeletion)) }\"\n\n metric_transformation {\n name = \"disable_or_delete_cmk_changes_metric\" # CRITICAL: metric used by alarm\n namespace = \"CISBenchmark\"\n value = \"1\"\n }\n}\n\n# Alarm for the metric above\nresource \"aws_cloudwatch_metric_alarm\" \"cmk\" {\n alarm_name = \"\"\n comparison_operator = \"GreaterThanOrEqualToThreshold\"\n evaluation_periods = 1\n metric_name = \"disable_or_delete_cmk_changes_metric\" # CRITICAL: same metric name\n namespace = \"CISBenchmark\"\n period = 300\n statistic = \"Sum\"\n threshold = 1\n}\n```", + "other": "1. Open the AWS Console and go to CloudWatch > Log groups\n2. Select the CloudTrail log group that receives your trail events\n3. Choose Create metric filter\n4. In Filter pattern, paste: {($.eventSource = kms.amazonaws.com) && (($.eventName=DisableKey)||($.eventName=ScheduleKeyDeletion)) }\n5. Name the metric (e.g., disable_or_delete_cmk_changes_metric), set Namespace to CISBenchmark, Value to 1, then Create\n6. From the Metric filters tab, select the new filter and click Create alarm\n7. Set Statistic: Sum, Period: 5 minutes, Threshold type: Static, Threshold: 1, Comparison: Greater/Equal\n8. Create the alarm (notification actions optional and not required for pass)" + }, + "cloudwatch_log_metric_filter_security_group_changes": { + "checkTitle": "CloudWatch Logs metric filter and alarm exist for security group changes", + "recommendation": "Establish real-time alerts for **security group modifications** by sending CloudTrail to CloudWatch, creating metric filters and alarms, and notifying responders.\n- Enforce **least privilege** on SG changes\n- Use change management and tagging\n- Centralize logs, test alarms, and maintain runbooks\n- Layer with NACLs and WAF for **defense in depth**", + "recommendationUrl": "https://hub.prowler.com/check/cloudwatch_log_metric_filter_security_group_changes", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: Create metric filter and alarm for Security Group changes\nResources:\n MetricFilter:\n Type: AWS::Logs::MetricFilter\n Properties:\n LogGroupName: \n # Critical: Matches Security Group change events required by the check\n # This publishes a metric when these events appear in CloudTrail logs\n FilterPattern: '{ ($.eventName = AuthorizeSecurityGroupIngress) || ($.eventName = AuthorizeSecurityGroupEgress) || ($.eventName = RevokeSecurityGroupIngress) || ($.eventName = RevokeSecurityGroupEgress) || ($.eventName = CreateSecurityGroup) || ($.eventName = DeleteSecurityGroup) }'\n MetricTransformations:\n - MetricName: \n MetricNamespace: \n MetricValue: \"1\"\n\n Alarm:\n Type: AWS::CloudWatch::Alarm\n Properties:\n # Critical: Alarm on the metric to satisfy the requirement\n MetricName: \n Namespace: \n Statistic: Sum\n Period: 300\n EvaluationPeriods: 1\n Threshold: 1\n ComparisonOperator: GreaterThanOrEqualToThreshold\n```", + "terraform": "```hcl\n# Metric filter for Security Group changes\nresource \"aws_cloudwatch_log_metric_filter\" \"sg\" {\n name = \"\"\n log_group_name = \"\"\n # Critical: Matches Security Group change events required by the check\n pattern = \"{ ($.eventName = AuthorizeSecurityGroupIngress) || ($.eventName = AuthorizeSecurityGroupEgress) || ($.eventName = RevokeSecurityGroupIngress) || ($.eventName = RevokeSecurityGroupEgress) || ($.eventName = CreateSecurityGroup) || ($.eventName = DeleteSecurityGroup) }\"\n\n metric_transformation {\n name = \"\"\n namespace = \"\"\n value = \"1\"\n }\n}\n\n# Alarm for the above metric\nresource \"aws_cloudwatch_metric_alarm\" \"sg\" {\n alarm_name = \"\"\n # Critical: Alarm on the SG change metric to pass the control\n metric_name = \"\"\n namespace = \"\"\n statistic = \"Sum\"\n period = 300\n evaluation_periods = 1\n threshold = 1\n comparison_operator = \"GreaterThanOrEqualToThreshold\"\n}\n```", + "other": "1. Open the CloudWatch console > Logs > Log groups, and select the CloudTrail log group\n2. Create metric filter with this pattern:\n { ($.eventName = AuthorizeSecurityGroupIngress) || ($.eventName = AuthorizeSecurityGroupEgress) || ($.eventName = RevokeSecurityGroupIngress) || ($.eventName = RevokeSecurityGroupEgress) || ($.eventName = CreateSecurityGroup) || ($.eventName = DeleteSecurityGroup) }\n3. Assign metric: name , namespace , value 1, then create the filter\n4. From the metric filter, choose Create alarm and set: Statistic Sum, Period 5 minutes, Threshold type Static, Greater/Equal 1, Evaluation periods 1, then create the alarm" + }, + "cloudwatch_changes_to_network_gateways_alarm_configured": { + "checkTitle": "CloudWatch Logs metric filter and alarm exist for changes to network gateways", + "recommendation": "Send CloudTrail to CloudWatch Logs and create a metric filter for the listed gateway events with an alarm that notifies responders. Enforce **least privilege** for gateway modifications, require change approvals, and route alerts to monitored channels as part of **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/cloudwatch_changes_to_network_gateways_alarm_configured", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: Create metric filter and alarm for network gateway changes\nResources:\n NetworkGatewayMetricFilter:\n Type: AWS::Logs::MetricFilter\n Properties:\n LogGroupName: \n FilterPattern: '{ ($.eventName = CreateCustomerGateway) || ($.eventName = DeleteCustomerGateway) || ($.eventName = AttachInternetGateway) || ($.eventName = CreateInternetGateway) || ($.eventName = DeleteInternetGateway) || ($.eventName = DetachInternetGateway) }' # Critical: matches gateway change events\n MetricTransformations:\n - MetricName: \n MetricNamespace: \n MetricValue: \"1\"\n\n NetworkGatewayAlarm:\n Type: AWS::CloudWatch::Alarm\n Properties:\n ComparisonOperator: GreaterThanOrEqualToThreshold\n EvaluationPeriods: 1\n MetricName: # Critical: alarm targets the metric created by the filter\n Namespace: \n Period: 300\n Statistic: Sum\n Threshold: 1\n```", + "terraform": "```hcl\n# CloudWatch Logs metric filter for network gateway changes\nresource \"aws_cloudwatch_log_metric_filter\" \"\" {\n name = \"\"\n log_group_name = \"\"\n pattern = \"{ ($.eventName = CreateCustomerGateway) || ($.eventName = DeleteCustomerGateway) || ($.eventName = AttachInternetGateway) || ($.eventName = CreateInternetGateway) || ($.eventName = DeleteInternetGateway) || ($.eventName = DetachInternetGateway) }\" # Critical: matches gateway change events\n\n metric_transformation {\n name = \"\"\n namespace = \"\"\n value = \"1\"\n }\n}\n\n# Alarm on the metric filter\nresource \"aws_cloudwatch_metric_alarm\" \"\" {\n alarm_name = \"\"\n comparison_operator = \"GreaterThanOrEqualToThreshold\"\n evaluation_periods = 1\n metric_name = \"\" # Critical: must match metric from the filter\n namespace = \"\"\n period = 300\n statistic = \"Sum\"\n threshold = 1\n}\n```", + "other": "1. In the AWS Console, go to CloudWatch > Logs > Log groups and open the CloudTrail log group\n2. Create metric filter:\n - Filter pattern: { ($.eventName = CreateCustomerGateway) || ($.eventName = DeleteCustomerGateway) || ($.eventName = AttachInternetGateway) || ($.eventName = CreateInternetGateway) || ($.eventName = DeleteInternetGateway) || ($.eventName = DetachInternetGateway) }\n - Metric name: \n - Metric namespace: \n - Metric value: 1\n3. From the filter, choose Create alarm:\n - Statistic: Sum, Period: 5 minutes, Threshold: >= 1, Evaluation periods: 1\n - Create the alarm (actions optional)\n" + }, + "storagegateway_gateway_fault_tolerant": { + "checkTitle": "AWS Storage Gateway gateway is not hosted on EC2", + "recommendation": "Design for **high availability**: avoid single-instance gateways for critical workloads. Prefer managed multi-AZ services like **EFS** or **FSx**, or use multiple gateways with client failover and resilient naming. Apply **defense in depth**, validate failover regularly, and monitor health to prevent outages.", + "recommendationUrl": "https://hub.prowler.com/check/storagegateway_gateway_fault_tolerant", + "cli": null, + "nativeIaC": null, + "terraform": null, + "other": "1. In the AWS Console, go to Storage Gateway and click Create gateway\n2. Choose your gateway type, then under Host platform select VMware ESXi, Microsoft Hyper-V, Linux KVM, or Hardware Appliance (do not select Amazon EC2)\n3. Download and deploy the VM on your host, power it on, and note its IP\n4. In the console, connect to and Activate the new gateway\n5. Recreate equivalent resources (shares/volumes/tapes) on the new gateway and update clients to use the new gateway IP/DNS\n6. In Storage Gateway > Gateways, delete the old EC2-hosted gateway\n7. Verify the new gateway's details show Host platform not equal to Amazon EC2" + }, + "storagegateway_fileshare_encryption_enabled": { + "checkTitle": "Storage Gateway file share is encrypted with KMS CMK", + "recommendation": "Use a **customer-managed KMS key** for each file share's server-side encryption (`SSE-KMS`; *consider* `DSSE-KMS` for multilayer needs). Apply **least privilege** and **separation of duties** to key access, rotate keys, monitor key usage, and restrict scope to necessary principals and regions.", + "recommendationUrl": "https://hub.prowler.com/check/storagegateway_fileshare_encryption_enabled", + "cli": "aws storagegateway update-nfs-file-share --file-share-arn --kms-encrypted --kms-key ", + "nativeIaC": "```yaml\n# CloudFormation: enable KMS CMK encryption for a Storage Gateway NFS file share\nResources:\n :\n Type: AWS::StorageGateway::NFSFileShare\n Properties:\n ClientToken: \"\"\n GatewayARN: \"\"\n LocationARN: \"\"\n Role: \"\"\n KMSEncrypted: true # Critical: enables KMS CMK encryption for the file share\n KMSKey: \"\" # Critical: CMK ARN used for encryption\n```", + "terraform": "```hcl\n# Enable KMS CMK encryption for a Storage Gateway NFS file share\nresource \"aws_storagegateway_nfs_file_share\" \"\" {\n client_list = [\"\"]\n gateway_arn = \"\"\n location_arn = \"\"\n role_arn = \"\"\n\n kms_encrypted = true # Critical: enables KMS CMK encryption\n kms_key_arn = \"\" # Critical: CMK ARN used for encryption\n}\n```", + "other": "1. In the AWS Console, go to Storage Gateway > File shares\n2. Select the affected file share and click Edit\n3. Under Encryption, choose AWS KMS key\n4. Select the CMK to use (or paste its ARN)\n5. Save changes" + }, + "secretsmanager_automatic_rotation_enabled": { + "checkTitle": "Secrets Manager secret has rotation enabled", + "recommendation": "Enable **automatic rotation** for secrets and set schedules based on sensitivity (e.g., `30-90 days`). Enforce **least privilege** for accessing and rotating secrets and apply **separation of duties**. Monitor rotation health. Avoid hardcoded credentials; retrieve secrets at runtime and support versioned updates.", + "recommendationUrl": "https://hub.prowler.com/check/secretsmanager_automatic_rotation_enabled", + "cli": "aws secretsmanager rotate-secret --secret-id --rotation-lambda-arn --rotation-rules AutomaticallyAfterDays=30", + "nativeIaC": "```yaml\nResources:\n :\n Type: AWS::SecretsManager::RotationSchedule\n Properties:\n SecretId: \n RotationLambdaARN: \n RotationRules:\n AutomaticallyAfterDays: 30 # Critical: enables rotation on a 30-day schedule\n```", + "terraform": "```hcl\nresource \"aws_secretsmanager_secret_rotation\" \"\" {\n secret_id = \"\"\n rotation_lambda_arn = \"\"\n rotation_rules {\n automatically_after_days = 30 # Critical: enables rotation schedule\n }\n}\n```", + "other": "1. Open AWS Console > Secrets Manager\n2. Select the secret\n3. Click Rotation > Enable automatic rotation\n4. Choose the rotation Lambda function\n5. Set rotation interval to 30 days\n6. Save" + }, + "secretsmanager_secret_unused": { + "checkTitle": "Secrets Manager secret has been accessed within the last 90 days", + "recommendation": "Apply a **lifecycle policy** for secrets:\n- Require ownership tags and periodic reviews\n- Rotate or disable, then retire secrets unused beyond policy\n- Enforce **least privilege** and monitor retrievals with alerts\n- Automate cleanup using recovery windows to prevent accidental loss", + "recommendationUrl": "https://hub.prowler.com/check/secretsmanager_secret_unused", + "cli": "aws secretsmanager delete-secret --secret-id ", + "nativeIaC": null, + "terraform": null, + "other": "1. In the AWS Console, go to Secrets Manager\n2. Select the unused secret\n3. If the secret has replicas: in Replicate secret, select each replica and choose Actions > Delete replica\n4. Choose Actions > Delete secret\n5. Keep the default recovery window (or set one) and select Schedule deletion" + }, + "secretsmanager_secret_rotated_periodically": { + "checkTitle": "AWS Secrets Manager secret is rotated within the configured maximum number of days", + "recommendation": "Enable **automatic rotation** for all secrets with intervals aligned to sensitivity (**`90` days or more frequent). Ensure apps retrieve secrets at runtime. Apply **least privilege** to rotation roles and KMS keys, use **separation of duties**, and monitor rotation health with alerts. Avoid hard-coded credentials and retire unused secrets.", + "recommendationUrl": "https://hub.prowler.com/check/secretsmanager_secret_rotated_periodically", + "cli": "aws secretsmanager rotate-secret --secret-id ", + "nativeIaC": "```yaml\n# CloudFormation: enable rotation and rotate now\nResources:\n :\n Type: AWS::SecretsManager::RotationSchedule\n Properties:\n SecretId: # CRITICAL: target secret to rotate\n RotationLambdaARN: # CRITICAL: Lambda ARN used to perform rotation\n ScheduleExpression: rate(30 days) # CRITICAL: ensures rotation occurs within max allowed days\n RotateImmediatelyOnUpdate: true # CRITICAL: triggers an immediate rotation to pass the check\n```", + "terraform": "```hcl\n# Enable rotation for the secret\nresource \"aws_secretsmanager_secret_rotation\" \"\" {\n secret_id = \"\" # CRITICAL: target secret\n rotation_lambda_arn = \"\" # CRITICAL: Lambda ARN used to rotate\n\n rotation_rules { \n automatically_after_days = 30 # CRITICAL: rotate within allowed days\n }\n}\n```", + "other": "1. Open the AWS Console > Secrets Manager\n2. Select the secret\n3. If Rotation status is Enabled: click Rotate secret immediately\n4. If Rotation is Disabled: click Edit rotation, turn on Automatic rotation, choose the rotation Lambda function, Save, then click Rotate secret immediately" + }, + "secretsmanager_not_publicly_accessible": { + "checkTitle": "Secrets Manager secret resource policy does not allow public access", + "recommendation": "Apply **least privilege** to resource policies:\n- Remove wildcards and limit access to specific principals\n- Add contextual conditions (e.g., VPC endpoints, source account/ARN)\n- Enable safeguards that block public policies\n- Prefer private access paths\n- Periodically review related identity and KMS policies", + "recommendationUrl": "https://hub.prowler.com/check/secretsmanager_not_publicly_accessible", + "cli": "aws secretsmanager put-resource-policy --secret-id --resource-policy '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam:::root\"},\"Action\":\"secretsmanager:GetSecretValue\",\"Resource\":\"*\"}]}' --block-public-policy", + "nativeIaC": "```yaml\n# CloudFormation: attach a non-public resource policy\nResources:\n :\n Type: AWS::SecretsManager::ResourcePolicy\n Properties:\n SecretId: \"\"\n BlockPublicPolicy: true # Critical: prevents policies that allow public access\n ResourcePolicy: # Critical: principal is restricted, not \"*\"\n Version: '2012-10-17'\n Statement:\n - Effect: Allow\n Principal:\n AWS: arn:aws:iam:::root\n Action: secretsmanager:GetSecretValue\n Resource: \"*\"\n```", + "terraform": "```hcl\n# Restrict secret policy and block public access\nresource \"aws_secretsmanager_secret_policy\" \"\" {\n secret_arn = \"\"\n block_public_policy = true # Critical: blocks public policies\n policy = jsonencode({ # Critical: principal is not \"*\"\n Version = \"2012-10-17\"\n Statement = [{\n Effect = \"Allow\"\n Principal = { AWS = \"arn:aws:iam:::root\" }\n Action = \"secretsmanager:GetSecretValue\"\n Resource = \"*\"\n }]\n })\n}\n```", + "other": "1. Open AWS Console > Secrets Manager\n2. Select the secret > Overview tab > Resource permissions > Edit permissions\n3. Remove any statement with Principal set to \"*\" (or AWS: \"*\")\n4. Add an allow statement for only your account root principal: arn:aws:iam:::root\n5. Enable Block public access (if available) and click Save" + }, + "ses_identity_not_publicly_accessible": { + "checkTitle": "SES identity resource policy does not allow public access", + "recommendation": "Restrict SES identity policies to known principals and actions following **least privilege**. Prefer explicit account ARNs for sending authorization, and add conditions like `aws:SourceIp` and `aws:SecureTransport`. Review grants regularly and remove unused access as part of **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/ses_identity_not_publicly_accessible", + "cli": "aws sesv2 delete-email-identity-policy --email-identity --policy-name ", + "nativeIaC": null, + "terraform": "```hcl\nresource \"aws_ses_identity_policy\" \"\" {\n identity = \"\"\n name = \"\"\n\n policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [{\n Effect = \"Allow\"\n Principal = { AWS = [\"\"] } # Critical: restrict to a specific AWS account, not \"*\"\n Action = [\"ses:SendEmail\"]\n Resource = \"arn:aws:ses:::identity/\"\n }]\n })\n}\n```", + "other": "1. In the AWS Console, go to Simple Email Service (SES)\n2. Open Verified identities and select the affected identity\n3. Click Resource policies\n4. Delete the public policy, or Edit it to remove any Principal of \"*\" and restrict to a specific AWS account\n5. Save changes" + }, + "eks_control_plane_logging_all_types_enabled": { + "checkTitle": "EKS cluster has control plane logging enabled for api, audit, authenticator, controllerManager, and scheduler", + "recommendation": "Enable and standardize **EKS control plane logging** for all required types `[\"api\",\"audit\",\"authenticator\",\"controllerManager\",\"scheduler\"]`.\n\nApply least privilege to log access, set retention and alerts, and centralize analysis to support defense in depth, rapid detection, and reliable forensics.", + "recommendationUrl": "https://hub.prowler.com/check/eks_control_plane_logging_all_types_enabled", + "cli": "aws eks update-cluster-config --name --logging '{\"clusterLogging\":[{\"types\":[\"api\",\"audit\",\"authenticator\",\"controllerManager\",\"scheduler\"],\"enabled\":true}]}'", + "nativeIaC": "```yaml\n# CloudFormation: enable all EKS control plane log types\nResources:\n :\n Type: AWS::EKS::Cluster\n Properties:\n RoleArn: \n ResourcesVpcConfig:\n SubnetIds: []\n Logging:\n ClusterLogging:\n - EnabledTypes:\n - Type: api # Critical: enable required control plane log types\n - Type: audit # Critical: enable required control plane log types\n - Type: authenticator # Critical: enable required control plane log types\n - Type: controllerManager # Critical: enable required control plane log types\n - Type: scheduler # Critical: enable required control plane log types\n```", + "terraform": "```hcl\n# Enable all required EKS control plane log types\nresource \"aws_eks_cluster\" \"\" {\n enabled_cluster_log_types = [\n \"api\", # Critical: required control plane log types\n \"audit\", # Critical: required control plane log types\n \"authenticator\", # Critical: required control plane log types\n \"controllerManager\", # Critical: required control plane log types\n \"scheduler\" # Critical: required control plane log types\n ]\n}\n```", + "other": "1. In the AWS console, go to Amazon EKS and open your cluster\n2. Open the Observability (or Logging) tab and click Manage logging\n3. Turn on: api, audit, authenticator, controllerManager, scheduler\n4. Click Save changes" + }, + "eks_cluster_kms_cmk_encryption_in_secrets_enabled": { + "checkTitle": "EKS cluster has Kubernetes secrets encryption enabled", + "recommendation": "Enable cluster-level secrets encryption with **AWS KMS** and prefer a **customer managed KMS key** for control and rotation. Apply **least privilege** to key policies and cluster roles, monitor key usage, and combine with strict **RBAC** to limit who can read or create secrets as part of **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/eks_cluster_kms_cmk_encryption_in_secrets_enabled", + "cli": "aws eks associate-encryption-config --cluster-name --encryption-config '[{\"resources\":[\"secrets\"],\"provider\":{\"keyArn\":\"arn:aws:kms:::key/\"}}]'", + "nativeIaC": "```yaml\n# CloudFormation: enable KMS envelope encryption for Kubernetes secrets\nResources:\n EKSCluster:\n Type: AWS::EKS::Cluster\n Properties:\n Name: \"\"\n RoleArn: \"arn:aws:iam:::role/\"\n ResourcesVpcConfig:\n SubnetIds:\n - \"\"\n - \"\"\n EncryptionConfig: # Critical: enables KMS encryption for Kubernetes secrets\n - Resources:\n - secrets # Critical: encrypts only Kubernetes secrets\n Provider:\n KeyArn: \"arn:aws:kms:::key/\" # Critical: KMS key used for encryption\n```", + "terraform": "```hcl\n# Enable KMS envelope encryption for Kubernetes secrets\nresource \"aws_eks_cluster\" \"main\" {\n name = \"\"\n role_arn = \"arn:aws:iam:::role/\"\n\n vpc_config {\n subnet_ids = [\"\", \"\"]\n }\n\n encryption_config { # Critical: enables KMS encryption for secrets\n resources = [\"secrets\"] # Critical: scope to Kubernetes secrets\n provider {\n key_arn = \"arn:aws:kms:::key/\" # Critical: KMS key\n }\n }\n}\n```", + "other": "1. Open the AWS Management Console and go to Amazon EKS\n2. Select your cluster\n3. On the Overview tab, find Secrets encryption and click Enable\n4. Select the KMS key and click Enable\n5. Click Confirm to apply" + }, + "eks_cluster_not_publicly_accessible": { + "checkTitle": "EKS cluster endpoint is not publicly accessible from 0.0.0.0/0", + "recommendation": "Prefer **private endpoint access** and avoid broad exposure. *If public access is required*, restrict to trusted admin CIDRs (not `0.0.0.0/0`), reach the API via **VPN/Direct Connect or bastions**, and enforce **least privilege** with IAM/RBAC. Apply **defense in depth** through network segmentation and continuous monitoring.", + "recommendationUrl": "https://hub.prowler.com/check/eks_cluster_not_publicly_accessible", + "cli": "aws eks update-cluster-config --region --name --resources-vpc-config endpointPublicAccess=false,endpointPrivateAccess=true", + "nativeIaC": "```yaml\n# CloudFormation: Disable public endpoint and enable private endpoint\nResources:\n :\n Type: AWS::EKS::Cluster\n Properties:\n RoleArn: \n ResourcesVpcConfig:\n SubnetIds:\n - \n - \n EndpointPublicAccess: false # critical: disables public API endpoint\n EndpointPrivateAccess: true # critical: enables private API endpoint\n```", + "terraform": "```hcl\n# Terraform: Disable public endpoint and enable private endpoint\nresource \"aws_eks_cluster\" \"\" {\n name = \"\"\n role_arn = \"\"\n\n vpc_config {\n subnet_ids = [\"\", \"\"]\n endpoint_public_access = false # critical: disables public API endpoint\n endpoint_private_access = true # critical: enables private API endpoint\n }\n}\n```", + "other": "1. Open the Amazon EKS console\n2. Select your cluster\n3. Go to the Networking tab and click Manage endpoint access\n4. Enable Private access and Disable Public access\n5. Click Update/Save" + }, + "eks_cluster_network_policy_enabled": { + "checkTitle": "EKS cluster has network policy enabled", + "recommendation": "Enforce **least privilege** `NetworkPolicy` with a `default-deny` for ingress and egress, then explicitly allow required flows by labels and namespaces. Apply **defense in depth** with security groups for pods and private access, and continuously test and monitor policy effectiveness.", + "recommendationUrl": "https://hub.prowler.com/check/eks_cluster_network_policy_enabled", + "cli": "aws eks update-cluster-config --name --resources-vpc-config securityGroupIds=", + "nativeIaC": "```yaml\n# CloudFormation: attach a security group to the EKS cluster\nResources:\n :\n Type: AWS::EKS::Cluster\n Properties:\n RoleArn: \n ResourcesVpcConfig:\n SubnetIds:\n - \n SecurityGroupIds:\n - # Critical: sets a security group for the cluster, satisfying the check\n```", + "terraform": "```hcl\n# Minimal EKS cluster config with a security group attached\nresource \"aws_eks_cluster\" \"\" {\n name = \"\"\n role_arn = \"\"\n\n vpc_config {\n subnet_ids = [\"\"]\n security_group_ids = [\"\"] # Critical: attaches a security group to pass the check\n }\n}\n```", + "other": "1. Open the AWS Console and go to EKS > Clusters\n2. Select and open the Networking tab\n3. Click Edit (or Update) in the Networking section\n4. Under Security groups, add/select \n5. Click Save to apply" + }, + "eks_cluster_uses_a_supported_version": { + "checkTitle": "EKS cluster uses a supported Kubernetes version", + "recommendation": "Adopt a **version management policy**: keep clusters on a supported minor version, schedule regular upgrades, and test workloads for API deprecations. Upgrade nodes and add-ons with the control plane. Track EKS releases, automate drift alerts, and favor **defense in depth** over deprecated features.", + "recommendationUrl": "https://hub.prowler.com/check/eks_cluster_uses_a_supported_version", + "cli": "aws eks update-cluster-version --name --kubernetes-version ", + "nativeIaC": "```yaml\n# CloudFormation: update EKS cluster to a supported Kubernetes version\nResources:\n :\n Type: AWS::EKS::Cluster\n Properties:\n Name: \n RoleArn: \n ResourcesVpcConfig:\n SubnetIds: [\"\"]\n Version: \"\" # Critical: set a supported Kubernetes version to pass the check\n```", + "terraform": "```hcl\n# Terraform: update EKS cluster to a supported Kubernetes version\nresource \"aws_eks_cluster\" \"\" {\n name = \"\"\n role_arn = \"\"\n\n version = \"\" # Critical: set a supported Kubernetes version to pass the check\n\n vpc_config {\n subnet_ids = [\"\"]\n }\n}\n```", + "other": "1. Open the AWS Management Console and go to Amazon EKS\n2. Select your cluster ()\n3. Click Update (or Edit) next to Kubernetes version\n4. Choose a supported version (>= required) and confirm the update\n5. Wait for the control plane update to complete" + }, + "eks_cluster_deletion_protection_enabled": { + "checkTitle": "EKS cluster has deletion protection enabled", + "recommendation": "Enable **deletion protection** on critical clusters (`deletionProtection=true`). Enforce **least privilege** so only narrowly scoped roles can disable or delete clusters. Require **change control** with approvals and **separation of duties**, and apply guardrails to prevent broad delete permissions.", + "recommendationUrl": "https://hub.prowler.com/check/eks_cluster_deletion_protection_enabled", + "cli": "aws eks update-cluster-config --name --region --deletion-protection", + "nativeIaC": "```yaml\n# CloudFormation: enable deletion protection on the EKS cluster\nResources:\n :\n Type: AWS::EKS::Cluster\n Properties:\n RoleArn: \n ResourcesVpcConfig:\n SubnetIds: [, ]\n DeletionProtection: true # critical: prevents cluster deletion until disabled\n```", + "terraform": "```hcl\n# Enable deletion protection for the EKS cluster\nresource \"aws_eks_cluster\" \"\" {\n name = \"\"\n role_arn = \"\"\n\n vpc_config {\n subnet_ids = [\"\", \"\"]\n }\n\n deletion_protection = true # critical: prevents cluster deletion until disabled\n}\n```", + "other": "1. Open the AWS Management Console and go to Amazon EKS\n2. Select your cluster\n3. Go to the Configuration tab and click Edit\n4. Enable Deletion protection\n5. Click Save changes" + }, + "eks_cluster_private_nodes_enabled": { + "checkTitle": "EKS cluster has private endpoint access enabled", + "recommendation": "Enable **private endpoint access** and disable or tightly restrict the public endpoint.\n\nRequire administration from private networks, enforce **least privilege** with IAM/RBAC, and apply **defense in depth** via segmentation and logging. *If external access is needed*, allow only specific CIDRs and monitor API activity.", + "recommendationUrl": "https://hub.prowler.com/check/eks_cluster_private_nodes_enabled", + "cli": "aws eks update-cluster-config --region --name --resources-vpc-config endpointPrivateAccess=true", + "nativeIaC": "```yaml\n# CloudFormation: Enable private endpoint access for an EKS cluster\nResources:\n :\n Type: AWS::EKS::Cluster\n Properties:\n RoleArn: \n ResourcesVpcConfig:\n SubnetIds:\n - \n EndpointPrivateAccess: true # Critical: enables private endpoint access to pass the check\n```", + "terraform": "```hcl\n# Terraform: Enable private endpoint access for an EKS cluster\nresource \"aws_eks_cluster\" \"\" {\n name = \"\"\n role_arn = \"\"\n\n vpc_config {\n subnet_ids = [\"\"]\n endpoint_private_access = true # Critical: enables private API endpoint access to pass the check\n }\n}\n```", + "other": "1. In the AWS Console, open Amazon EKS and select your cluster\n2. Go to the Networking tab\n3. Click Edit next to Cluster endpoint access\n4. Enable Private access\n5. Click Save" + }, + "resourceexplorer2_indexes_found": { + "checkTitle": "Resource Explorer indexes exist", + "recommendation": "Create **Resource Explorer indexes** in all active Regions and designate an **aggregator index** for cross-Region search. Apply least-privilege access to views, align with tagging standards, and routinely verify indexing status. This improves inventory accuracy, supports defense-in-depth, and speeds detection and remediation.", + "recommendationUrl": "https://hub.prowler.com/check/resourceexplorer2_indexes_found", + "cli": "aws resource-explorer-2 create-index --region ", + "nativeIaC": "```yaml\nResources:\n :\n Type: AWS::ResourceExplorer2::Index\n Properties:\n Type: LOCAL # Critical: creates a local index in this Region so the check finds at least one index\n```", + "terraform": "```hcl\nresource \"aws_resourceexplorer2_index\" \"\" {\n type = \"LOCAL\" # Critical: creates a local index so the check passes\n}\n```", + "other": "1. Sign in to the AWS Management Console and open **AWS Resource Explorer**\n2. Go to **Settings** > **Indexes**\n3. Click **Create indexes**\n4. Select the current Region and click **Create indexes**\n5. Wait until the index state is ACTIVE" + }, + "glue_etl_jobs_job_bookmark_encryption_enabled": { + "checkTitle": "Glue ETL job has Job bookmark encryption enabled", + "recommendation": "Attach a **Glue security configuration** to every job and enable **job bookmark encryption** (e.g., `CSE-KMS`). Use **customer-managed KMS keys**, enforce **least privilege** on key usage, and rotate keys. For **defense in depth**, also encrypt **S3 temp data** and **CloudWatch logs** in the same configuration.", + "recommendationUrl": "https://hub.prowler.com/check/glue_etl_jobs_job_bookmark_encryption_enabled", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: Enable Glue Job bookmark encryption via Security Configuration\nResources:\n :\n Type: AWS::Glue::SecurityConfiguration\n Properties:\n Name: \n EncryptionConfiguration:\n JobBookmarksEncryption:\n JobBookmarksEncryptionMode: CSE-KMS # CRITICAL: Enables job bookmark encryption\n KmsKeyArn: # CRITICAL: KMS key used to encrypt job bookmarks\n```", + "terraform": "```hcl\n# Terraform: Enable Glue Job bookmark encryption via Security Configuration\nresource \"aws_glue_security_configuration\" \"\" {\n name = \"\"\n\n encryption_configuration {\n job_bookmarks_encryption {\n job_bookmarks_encryption_mode = \"CSE-KMS\" # CRITICAL: Enables job bookmark encryption\n kms_key_arn = \"\" # CRITICAL: KMS key for bookmarks\n }\n }\n}\n```", + "other": "1. In the AWS Console, go to AWS Glue > Security configurations > Add security configuration\n2. Enter a name and under Advanced settings enable Job bookmark encryption\n3. Select a KMS key (or paste the key ARN) and click Create\n4. Go to AWS Glue > Jobs, select the job, click Edit\n5. Under Advanced properties, set Security configuration to the one created above\n6. Click Save" + }, + "glue_database_connections_ssl_enabled": { + "checkTitle": "Glue connection has SSL enabled", + "recommendation": "Enforce **TLS** on all Glue connections (set `JDBC_ENFORCE_SSL=true`) and require encryption on target databases.\n\nApply **defense in depth**: validate certificates, restrict network exposure, prefer private connectivity, and use **least-privilege** credentials with rotation.", + "recommendationUrl": "https://hub.prowler.com/check/glue_database_connections_ssl_enabled", + "cli": "aws glue update-connection --name --connection-input '{\"Name\":\"\",\"ConnectionType\":\"JDBC\",\"ConnectionProperties\":{\"JDBC_CONNECTION_URL\":\"\",\"JDBC_ENFORCE_SSL\":\"true\"}}'", + "nativeIaC": "```yaml\n# CloudFormation: Enable SSL on a Glue JDBC connection\nResources:\n :\n Type: AWS::Glue::Connection\n Properties:\n ConnectionInput:\n ConnectionType: JDBC\n ConnectionProperties:\n JDBC_CONNECTION_URL: \"\"\n JDBC_ENFORCE_SSL: \"true\" # Critical: forces SSL for the JDBC connection\n```", + "terraform": "```hcl\n# Terraform: Enable SSL on a Glue JDBC connection\nresource \"aws_glue_connection\" \"\" {\n name = \"\"\n connection_type = \"JDBC\"\n\n connection_properties = {\n JDBC_CONNECTION_URL = \"\"\n JDBC_ENFORCE_SSL = \"true\" # Critical: forces SSL for the JDBC connection\n }\n}\n```", + "other": "1. Open the AWS Console and go to AWS Glue > Data Catalog > Connections\n2. Select the connection and click Edit\n3. In Connection properties (Advanced properties), add key JDBC_ENFORCE_SSL with value true (or check Require SSL)\n4. Click Save" + }, + "glue_development_endpoints_cloudwatch_logs_encryption_enabled": { + "checkTitle": "Glue development endpoint has CloudWatch Logs encryption enabled", + "recommendation": "Attach a **security configuration** to all development endpoints with **CloudWatch Logs encryption** enabled using a tightly scoped **KMS key**.\nApply **least privilege** to key and log access, rotate keys, and standardize configs via IaC to enforce **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/glue_development_endpoints_cloudwatch_logs_encryption_enabled", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: Glue Security Configuration with CloudWatch Logs encryption enabled\nResources:\n :\n Type: AWS::Glue::SecurityConfiguration\n Properties:\n Name: \n EncryptionConfiguration:\n CloudWatchEncryption:\n CloudWatchEncryptionMode: SSE-KMS # Critical: enables CloudWatch Logs encryption\n KmsKeyArn: # Critical: KMS key used for encrypting Glue logs\n```", + "terraform": "```hcl\n# Glue Security Configuration with CloudWatch Logs encryption enabled\nresource \"aws_glue_security_configuration\" \"\" {\n name = \"\"\n\n encryption_configuration {\n cloudwatch_encryption {\n cloudwatch_encryption_mode = \"SSE-KMS\" # Critical: enables CloudWatch Logs encryption\n kms_key_arn = \"\" # Critical: KMS key used for encrypting Glue logs\n }\n\n # Required blocks for valid config (kept minimal)\n job_bookmarks_encryption { job_bookmarks_encryption_mode = \"DISABLED\" }\n s3_encryption { s3_encryption_mode = \"DISABLED\" }\n }\n}\n```", + "other": "1. In the AWS Console, go to Glue > Security configurations > Add security configuration\n2. Enter a name and enable CloudWatch Logs encryption\n3. Select a KMS key (or enter its ARN) and click Create\n4. Go to Glue > Dev endpoints\n5. Create a new Dev endpoint (or delete and recreate the existing one) and select the new Security configuration\n6. Create the endpoint to apply the encryption" + }, + "glue_development_endpoints_job_bookmark_encryption_enabled": { + "checkTitle": "Glue development endpoint has Job Bookmark encryption enabled", + "recommendation": "Attach a **security configuration** to each development endpoint and enable **job bookmark encryption** with a managed KMS key. Apply **least privilege** to S3 and KMS, rotate keys, and align logs and data stores with consistent encryption for **defense in depth**. Regularly audit endpoints for missing or outdated configurations.", + "recommendationUrl": "https://hub.prowler.com/check/glue_development_endpoints_job_bookmark_encryption_enabled", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: Enable Job Bookmark encryption and attach to the Dev Endpoint\nResources:\n GlueSecurityConfiguration:\n Type: AWS::Glue::SecurityConfiguration\n Properties:\n Name: \n EncryptionConfiguration:\n JobBookmarksEncryption:\n JobBookmarksEncryptionMode: CSE-KMS # Critical: enables Job Bookmark encryption\n KmsKeyArn: # Critical: KMS key used for Job Bookmark encryption\n\n GlueDevEndpoint:\n Type: AWS::Glue::DevEndpoint\n Properties:\n RoleArn: \n SecurityConfiguration: !Ref GlueSecurityConfiguration # Critical: attach the security configuration to the Dev Endpoint\n```", + "terraform": "```hcl\n# Terraform: Enable Job Bookmark encryption and attach to the Dev Endpoint\nresource \"aws_glue_security_configuration\" \"\" {\n name = \"\"\n\n encryption_configuration {\n job_bookmarks_encryption {\n job_bookmarks_encryption_mode = \"CSE-KMS\" # Critical: enables Job Bookmark encryption\n kms_key_arn = \"\" # Critical: KMS key used for Job Bookmark encryption\n }\n }\n}\n\nresource \"aws_glue_dev_endpoint\" \"\" {\n name = \"\"\n role_arn = \"\"\n security_configuration = aws_glue_security_configuration..name # Critical: attach the security configuration\n}\n```", + "other": "1. In the AWS Console, go to Glue > Security configurations > Add security configuration\n2. Enter a name, then under Advanced settings enable Job bookmark encryption and select a KMS key (or enter its ARN); Save\n3. Go to Glue > Dev endpoints\n4. Create a new Dev endpoint (or recreate the existing one) and set Security configuration to the configuration created in step 2\n5. Create the endpoint to apply the setting" + }, + "glue_development_endpoints_s3_encryption_enabled": { + "checkTitle": "Glue development endpoint has S3 encryption enabled", + "recommendation": "Attach a **Glue security configuration** to each dev endpoint with **S3 encryption** enabled; prefer `SSE-KMS` with customer-managed keys. Enforce **least privilege** on IAM and KMS key policies, and extend encryption to logs and bookmarks for **defense in depth**.", + "recommendationUrl": "https://hub.prowler.com/check/glue_development_endpoints_s3_encryption_enabled", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: Glue Dev Endpoint with S3 encryption via Security Configuration\nResources:\n SecurityConfig:\n Type: AWS::Glue::SecurityConfiguration\n Properties:\n Name: \n EncryptionConfiguration:\n S3Encryptions:\n - S3EncryptionMode: SSE-S3 # CRITICAL: enables S3 encryption for the security configuration\n\n DevEndpoint:\n Type: AWS::Glue::DevEndpoint\n Properties:\n EndpointName: \n RoleArn: \n SecurityConfiguration: !Ref SecurityConfig # CRITICAL: attaches the encrypted security configuration to the dev endpoint\n```", + "terraform": "```hcl\n# Terraform: Glue Dev Endpoint with S3 encryption\nresource \"aws_glue_security_configuration\" \"secure\" {\n name = \"\"\n encryption_configuration {\n s3_encryption {\n s3_encryption_mode = \"SSE-S3\" # CRITICAL: enables S3 encryption\n }\n }\n}\n\nresource \"aws_glue_dev_endpoint\" \"dev\" {\n name = \"\"\n role_arn = \"\"\n\n security_configuration = aws_glue_security_configuration.secure.name # CRITICAL: attaches encrypted security configuration\n}\n```", + "other": "1. In the AWS Console, go to AWS Glue > Security configurations > Create security configuration\n2. Under S3 encryption, select Server-side encryption (SSE-S3) and save\n3. Go to AWS Glue > Development endpoints > Create development endpoint\n4. Fill required fields and set Security configuration to the one created in step 2\n5. Create the endpoint and delete the old endpoint (without encryption) if it exists" + }, + "glue_data_catalogs_connection_passwords_encryption_enabled": { + "checkTitle": "Glue data catalog connection password is encrypted with a KMS key", + "recommendation": "Enable **connection password encryption** in the Data Catalog with a customer-managed KMS key.\n- Apply **least privilege** to the KMS key and Glue roles\n- Prefer keeping responses encrypted (`ReturnConnectionPasswordEncrypted`)\n- Rotate keys and monitor access for **defense in depth**", + "recommendationUrl": "https://hub.prowler.com/check/glue_data_catalogs_connection_passwords_encryption_enabled", + "cli": "aws glue put-data-catalog-encryption-settings --data-catalog-encryption-settings '{\"ConnectionPasswordEncryption\":{\"ReturnConnectionPasswordEncrypted\":true,\"AwsKmsKeyId\":\"\"}}'", + "nativeIaC": "```yaml\n# CloudFormation: enable Glue Data Catalog connection password encryption\nResources:\n :\n Type: AWS::Glue::DataCatalogEncryptionSettings\n Properties:\n DataCatalogEncryptionSettings:\n ConnectionPasswordEncryption:\n ReturnConnectionPasswordEncrypted: true # Critical: encrypts connection passwords\n KmsKeyId: # Critical: KMS key used for encryption\n```", + "terraform": "```hcl\n# Enable Glue Data Catalog connection password encryption\nresource \"aws_glue_data_catalog_encryption_settings\" \"\" {\n data_catalog_encryption_settings {\n # Critical: enables password encryption with a KMS key\n connection_password_encryption {\n return_connection_password_encrypted = true\n aws_kms_key_id = \"\"\n }\n\n # Required block for this resource; keep minimal\n encryption_at_rest {\n catalog_encryption_mode = \"DISABLED\"\n }\n }\n}\n```", + "other": "1. In the AWS Console, go to AWS Glue\n2. Click Settings (left menu)\n3. Under Data catalog settings, check Encrypt connection passwords\n4. Select your KMS key (symmetric CMK)\n5. Click Save" + }, + "glue_etl_jobs_amazon_s3_encryption_enabled": { + "checkTitle": "Glue job has S3 encryption enabled", + "recommendation": "Require **S3 encryption** for all Glue jobs via security configurations, preferring **SSE-KMS**. Apply **least privilege** to KMS keys, restrict key usage and rotate regularly. Enforce defense-in-depth with bucket policies that require encrypted writes, and monitor with key and S3 access logs.", + "recommendationUrl": "https://hub.prowler.com/check/glue_etl_jobs_amazon_s3_encryption_enabled", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: Attach a Security Configuration with S3 encryption to a Glue job\nResources:\n GlueSecurityConfiguration:\n Type: AWS::Glue::SecurityConfiguration\n Properties:\n Name: \n EncryptionConfiguration:\n S3Encryptions:\n - S3EncryptionMode: SSE-S3 # CRITICAL: Enables S3 encryption for Glue outputs\n\n GlueJob:\n Type: AWS::Glue::Job\n Properties:\n Name: \n Role: \n Command:\n Name: glueetl\n ScriptLocation: s3:///script.py\n SecurityConfiguration: !Ref GlueSecurityConfiguration # CRITICAL: Applies encrypted security configuration to the job\n```", + "terraform": "```hcl\n# Terraform: Attach a Security Configuration with S3 encryption to a Glue job\nresource \"aws_glue_security_configuration\" \"sec\" {\n name = \"\"\n\n s3_encryption {\n s3_encryption_mode = \"SSE-S3\" # CRITICAL: Enables S3 encryption for Glue outputs\n }\n}\n\nresource \"aws_glue_job\" \"job\" {\n name = \"\"\n role_arn = \"\"\n\n command {\n script_location = \"s3:///script.py\"\n }\n\n security_configuration = aws_glue_security_configuration.sec.name # CRITICAL: Applies encrypted security configuration to the job\n}\n```", + "other": "1. In the AWS Console, go to AWS Glue > Security configurations > Create security configuration\n2. Enable S3 encryption and choose SSE-S3 (or SSE-KMS with your key)\n3. Save the configuration\n4. Go to AWS Glue > Jobs > select your job > Edit\n5. Under Job details, set Security configuration to the encrypted configuration you created\n6. Save the job" + }, + "glue_ml_transform_encrypted_at_rest": { + "checkTitle": "Glue ML Transform is encrypted at rest", + "recommendation": "Enable **KMS-backed encryption at rest** for all ML transforms and prefer **customer-managed keys**.\n- Apply **least privilege** key policies and rotate keys\n- Enforce **defense in depth** with network and IAM controls\n- Monitor key usage and transform access with audit logs", + "recommendationUrl": "https://hub.prowler.com/check/glue_ml_transform_encrypted_at_rest", + "cli": "aws glue update-ml-transform --transform-id --transform-encryption '{\"MlUserDataEncryption\":{\"MlUserDataEncryptionMode\":\"SSE-KMS\",\"KmsKeyId\":\"\"}}'", + "nativeIaC": "```yaml\nResources:\n :\n Type: AWS::Glue::MLTransform\n Properties:\n Role: \n InputRecordTables:\n - DatabaseName: \n TableName: \n TransformParameters:\n TransformType: FIND_MATCHES\n FindMatchesParameters:\n PrimaryKeyColumnName: \n TransformEncryption:\n MlUserDataEncryption:\n MlUserDataEncryptionMode: SSE-KMS # Critical: enables ML user data encryption at rest\n KmsKeyId: # Critical: KMS key used for encryption\n```", + "terraform": "```hcl\nresource \"aws_glue_ml_transform\" \"\" {\n name = \"\"\n role_arn = \"\"\n\n input_record_tables {\n database_name = \"\"\n table_name = \"\"\n }\n\n parameters {\n transform_type = \"FIND_MATCHES\"\n find_matches_parameters {\n primary_key_column_name = \"\"\n }\n }\n\n transform_encryption {\n ml_user_data_encryption {\n ml_user_data_encryption_mode = \"SSE-KMS\" # Critical: enables encryption at rest\n kms_key_id = \"\" # Critical: KMS key used for encryption\n }\n }\n}\n```", + "other": "1. In the AWS Management Console, open AWS Glue\n2. Go to Machine learning > Transforms and select the target transform\n3. Click Edit\n4. Under Encryption, enable ML user data encryption\n5. Choose an AWS KMS key\n6. Save changes" + }, + "glue_data_catalogs_not_publicly_accessible": { + "checkTitle": "Glue Data Catalog is not publicly accessible via its resource policy", + "recommendation": "Enforce **least privilege** on catalog resource policies:\n- Avoid `Principal: *` and wildcards\n- Grant only required actions to explicit principals\n- Prefer identity-based access or Lake Formation for sharing\n- Limit scope with precise ARNs/conditions and monitor changes for **defense in depth**", + "recommendationUrl": "https://hub.prowler.com/check/glue_data_catalogs_not_publicly_accessible", + "cli": "aws glue delete-resource-policy", + "nativeIaC": null, + "terraform": "```hcl\nresource \"aws_glue_resource_policy\" \"\" {\n policy = jsonencode({\n Version = \"2012-10-17\",\n Statement = [\n {\n Effect = \"Allow\",\n Principal = { AWS = \"arn:aws:iam:::root\" } # Critical: restricts to your account, removing any public (*) access\n Action = \"glue:*\",\n Resource = \"arn:aws:glue:::catalog\"\n }\n ]\n })\n}\n```", + "other": "1. Sign in to the AWS Console and open the Glue service\n2. In the left menu, click Settings\n3. Under Data catalog settings > Permissions, click Edit resource policy\n4. Remove any statement that has Principal set to * (public) or AWS: \"*\"; or delete the entire policy\n5. Click Save" + }, + "glue_etl_jobs_logging_enabled": { + "checkTitle": "Glue ETL job has continuous CloudWatch logging enabled", + "recommendation": "Enable **continuous logging** to **CloudWatch Logs** for all Glue jobs. Centralize logs with retention and KMS encryption, restrict read access, and alert on anomalies and failures. Apply **least privilege** to job roles and use **defense in depth** by correlating logs across services.", + "recommendationUrl": "https://hub.prowler.com/check/glue_etl_jobs_logging_enabled", + "cli": "aws glue update-job --job-name --job-update '{\"DefaultArguments\":{\"--enable-continuous-cloudwatch-log\":\"true\"}}'", + "nativeIaC": "```yaml\nResources:\n GlueJob:\n Type: AWS::Glue::Job\n Properties:\n Role: \"\"\n Command:\n Name: glueetl\n ScriptLocation: \"s3:///script.py\"\n DefaultArguments:\n \"--enable-continuous-cloudwatch-log\": \"true\" # Critical: enables continuous CloudWatch logging to pass the check\n```", + "terraform": "```hcl\nresource \"aws_glue_job\" \"\" {\n name = \"\"\n role_arn = \"\"\n\n command {\n script_location = \"s3:///script.py\"\n }\n\n default_arguments = {\n \"--enable-continuous-cloudwatch-log\" = \"true\" # Critical: enables continuous CloudWatch logging to pass the check\n }\n}\n```", + "other": "1. Open the AWS Glue console and go to Jobs\n2. Select the job and click Edit\n3. Expand Advanced properties\n4. Under Continuous logging, check Enable logs in CloudWatch\n5. Save" + }, + "glue_data_catalogs_metadata_encryption_enabled": { + "checkTitle": "Glue Data Catalog metadata is encrypted with KMS", + "recommendation": "Enable metadata encryption with **`SSE-KMS`**, preferably using a **customer-managed KMS key** for control and rotation.\n\nApply **least privilege** to KMS and catalog access, restrict who can change settings, and monitor key usage. Use **defense in depth** by encrypting related analytics assets consistently.", + "recommendationUrl": "https://hub.prowler.com/check/glue_data_catalogs_metadata_encryption_enabled", + "cli": "aws glue put-data-catalog-encryption-settings --data-catalog-encryption-settings '{\"EncryptionAtRest\":{\"CatalogEncryptionMode\":\"SSE-KMS\"}}'", + "nativeIaC": "```yaml\n# Enable Glue Data Catalog metadata encryption with KMS\nResources:\n :\n Type: AWS::Glue::DataCatalogEncryptionSettings\n Properties:\n DataCatalogEncryptionSettings:\n EncryptionAtRest:\n CatalogEncryptionMode: SSE-KMS # Critical: enables KMS encryption for catalog metadata\n```", + "terraform": "```hcl\n# Enable Glue Data Catalog metadata encryption with KMS\nresource \"aws_glue_data_catalog_encryption_settings\" \"\" {\n data_catalog_encryption_settings {\n encryption_at_rest {\n catalog_encryption_mode = \"SSE-KMS\" # Critical: turns on KMS encryption for catalog metadata\n }\n }\n}\n```", + "other": "1. In the AWS Console, go to AWS Glue\n2. Open Data Catalog > Settings\n3. Under Security configuration and encryption, check Metadata encryption\n4. Leave the default AWS managed key selected (or choose a KMS key)\n5. Click Save" + }, + "glue_etl_jobs_cloudwatch_logs_encryption_enabled": { + "checkTitle": "Glue ETL job has CloudWatch Logs encryption enabled", + "recommendation": "Enable **at-rest encryption** for Glue logs via a **security configuration** using customer-managed KMS keys. Apply **least privilege** to KMS and CloudWatch Logs, rotate keys, and require all jobs to attach an approved configuration. Embed this baseline in IaC for consistent, **defense-in-depth** coverage.", + "recommendationUrl": "https://hub.prowler.com/check/glue_etl_jobs_cloudwatch_logs_encryption_enabled", + "cli": null, + "nativeIaC": "```yaml\n# CloudFormation: enable CloudWatch Logs encryption and attach to the job\nResources:\n ExampleSecurityConfiguration:\n Type: AWS::Glue::SecurityConfiguration\n Properties:\n Name: \n EncryptionConfiguration:\n CloudWatchEncryption: # Critical: enable CloudWatch Logs encryption for Glue\n CloudWatchEncryptionMode: SSE-KMS # Critical: must not be DISABLED\n KmsKeyArn: # Critical: KMS key used for encryption\n\n ExampleJob:\n Type: AWS::Glue::Job\n Properties:\n Role: \n Command:\n Name: glueetl\n ScriptLocation: s3://\n SecurityConfiguration: !Ref ExampleSecurityConfiguration # Critical: attach security configuration to the job\n```", + "terraform": "```hcl\n# Enable CloudWatch Logs encryption and attach to the Glue job\nresource \"aws_glue_security_configuration\" \"example_resource_name\" {\n name = \"\"\n\n encryption_configuration {\n cloudwatch_encryption {\n cloudwatch_encryption_mode = \"SSE-KMS\" # Critical: enable CW Logs encryption\n kms_key_arn = \"\" # Critical: KMS key for encryption\n }\n }\n}\n\nresource \"aws_glue_job\" \"example_resource_name\" {\n name = \"\"\n role_arn = \"\"\n\n command {\n name = \"glueetl\"\n script_location = \"s3://\"\n }\n\n security_configuration = aws_glue_security_configuration.example_resource_name.name # Critical: attach security config to job\n}\n```", + "other": "1. In the AWS Glue console, go to Security configurations > Add security configuration\n2. Enter a name, enable CloudWatch Logs encryption, select SSE-KMS, and choose/provide the KMS key ARN; Save\n3. Go to Jobs, select the target job, click Edit\n4. Set Security configuration to the one created in step 2\n5. Save changes" + }, + "opensearch_service_domains_fault_tolerant_master_nodes": { + "checkTitle": "OpenSearch domain has at least 3 dedicated master nodes", + "recommendation": "Enable **dedicated master nodes** and set the count to at least `3` (use an odd number) to maintain **quorum**. Use *Multi-AZ with standby* to distribute masters across zones. Right-size master instances and monitor cluster health to uphold high availability and resilience.", + "recommendationUrl": "https://hub.prowler.com/check/opensearch_service_domains_fault_tolerant_master_nodes", + "cli": "aws opensearch update-domain-config --domain-name --cluster-config \"DedicatedMasterEnabled=true,DedicatedMasterType=,DedicatedMasterCount=3\"", + "nativeIaC": "```yaml\n# CloudFormation: set at least 3 dedicated master nodes\nResources:\n :\n Type: AWS::OpenSearchService::Domain\n Properties:\n ClusterConfig:\n DedicatedMasterEnabled: true # Critical: enable dedicated master nodes\n DedicatedMasterCount: 3 # Critical: ensure minimum of 3 masters\n DedicatedMasterType: \"\" # Critical: required when enabling masters\n```", + "terraform": "```hcl\n# Terraform: set at least 3 dedicated master nodes\nresource \"aws_opensearch_domain\" \"\" {\n domain_name = \"\"\n\n cluster_config {\n dedicated_master_enabled = true # Critical: enable dedicated masters\n dedicated_master_count = 3 # Critical: ensure minimum of 3 masters\n dedicated_master_type = \"\" # Critical: required when enabling masters\n }\n}\n```", + "other": "1. Sign in to the AWS Console and open Amazon OpenSearch Service\n2. Select your domain and choose Edit\n3. In Cluster configuration:\n - Enable Dedicated master nodes\n - Set Dedicated master node count to 3\n - Select a Dedicated master instance type\n4. Choose Save changes" + }, + "opensearch_service_domains_encryption_at_rest_enabled": { + "checkTitle": "Amazon OpenSearch Service domain has encryption at rest enabled", + "recommendation": "Enable `encryption at rest` with AWS KMS, preferably using **customer-managed keys**.\n- Enforce **least privilege** key policies and restrict grants\n- Enable automatic key rotation and monitor KMS usage\n- Encrypt logs and any exported snapshots\n- Apply **defense in depth** with network and IAM controls", + "recommendationUrl": "https://hub.prowler.com/check/opensearch_service_domains_encryption_at_rest_enabled", + "cli": "aws opensearch update-domain-config --domain-name --encryption-at-rest-options Enabled=true", + "nativeIaC": "```yaml\n# CloudFormation: Enable encryption at rest for an OpenSearch domain\nResources:\n :\n Type: AWS::OpenSearchService::Domain\n Properties:\n DomainName: \n EncryptionAtRestOptions:\n Enabled: true # Critical: turns on encryption at rest for the domain\n```", + "terraform": "```hcl\n# Terraform: Enable encryption at rest for an OpenSearch domain\nresource \"aws_opensearch_domain\" \"\" {\n domain_name = \"\"\n\n encrypt_at_rest {\n enabled = true # Critical: turns on encryption at rest for the domain\n }\n}\n```", + "other": "1. In the AWS Console, go to OpenSearch Service > Domains and select your domain\n2. Click Actions > Edit security configuration\n3. Under Encryption, check Enable encryption of data at rest\n4. Keep the default AWS owned key (or select a KMS key if required)\n5. Click Save changes\n" + }, + "opensearch_service_domains_https_communications_enforced": { + "checkTitle": "OpenSearch domain has HTTPS enforcement enabled", + "recommendation": "Enable `Require HTTPS for all traffic` on every domain to enforce TLS. Prefer strong protocols (TLS 1.2+), and block HTTP via network controls for defense in depth. Apply **least privilege** access policies and use private connectivity to minimize exposure and downgrade risks.", + "recommendationUrl": "https://hub.prowler.com/check/opensearch_service_domains_https_communications_enforced", + "cli": "aws opensearch update-domain-config --domain-name --domain-endpoint-options EnforceHTTPS=true", + "nativeIaC": "```yaml\n# CloudFormation - Enable HTTPS enforcement on an OpenSearch domain\nResources:\n :\n Type: AWS::OpenSearchService::Domain\n Properties:\n DomainEndpointOptions:\n EnforceHTTPS: true # Critical: requires all traffic to use HTTPS, fixing the finding\n```", + "terraform": "```hcl\n# Enable HTTPS enforcement on an OpenSearch domain\nresource \"aws_opensearch_domain\" \"\" {\n domain_name = \"\"\n\n domain_endpoint_options {\n enforce_https = true # Critical: requires HTTPS for all requests\n }\n}\n```", + "other": "1. Open the Amazon OpenSearch Service console\n2. Go to Domains and select your domain\n3. Click Actions > Edit security configuration\n4. Check \"Require HTTPS for all traffic to the domain\"\n5. Click Save changes" + }, + "opensearch_service_domains_not_publicly_accessible": { + "checkTitle": "Amazon OpenSearch Service domain is not publicly accessible", + "recommendation": "Apply **least privilege** and **defense in depth**:\n- Place domains in a **VPC** and restrict reachability with security groups\n- Use narrow resource policies; avoid `Principal:\"*\"`\n- Require authenticated access (fine-grained controls); *if unavoidable*, limit public endpoints by IP and roles\n- Monitor access with logs and alerts", + "recommendationUrl": "https://hub.prowler.com/check/opensearch_service_domains_not_publicly_accessible", + "cli": "aws opensearch update-domain-config --domain-name --access-policies '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam:::root\"},\"Action\":\"es:*\",\"Resource\":\"arn:aws:es:::domain//*\"}]}'", + "nativeIaC": "```yaml\n# CloudFormation: restrict OpenSearch access policy to your account only\nResources:\n :\n Type: AWS::OpenSearchService::Domain\n Properties:\n DomainName: \n AccessPolicies: # critical: restricts access to your account only, removing public access\n Version: '2012-10-17'\n Statement:\n - Effect: Allow\n Principal:\n AWS: arn:aws:iam:::root # critical: only this account can access\n Action: es:*\n Resource: arn:aws:es:::domain//*\n```", + "terraform": "```hcl\n# Restrict OpenSearch access policy to your account only\nresource \"aws_opensearch_domain\" \"\" {\n domain_name = \"\"\n\n # critical: limits access to the owning account, removing public access\n access_policies = jsonencode({\n Version = \"2012-10-17\"\n Statement = [{\n Effect = \"Allow\"\n Principal = { AWS = \"arn:aws:iam:::root\" }\n Action = \"es:*\"\n Resource = \"arn:aws:es:::domain//*\"\n }]\n })\n}\n```", + "other": "1. In the AWS console, open Amazon OpenSearch Service and select your domain\n2. Go to Security configuration > Edit\n3. Choose Access policy > JSON\n4. Replace the policy with the following (use your values) and Save changes:\n```json\n{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Principal\": {\"AWS\": \"arn:aws:iam:::root\"},\n \"Action\": \"es:*\",\n \"Resource\": \"arn:aws:es:::domain//*\"\n }\n ]\n}\n```\n5. Verify the domain endpoint is no longer accessible publicly except by your account's IAM principals" + }, + "opensearch_service_domains_node_to_node_encryption_enabled": { + "checkTitle": "Amazon OpenSearch Service domain has node-to-node encryption enabled", + "recommendation": "Enable **node-to-node encryption** (`node_to_node_encryption: true`) to enforce TLS for inter-node traffic. Apply **defense in depth**: require HTTPS for clients, restrict network exposure, and use least privilege. Validate performance in staging and plan carefully, as the setting is effectively irreversible.", + "recommendationUrl": "https://hub.prowler.com/check/opensearch_service_domains_node_to_node_encryption_enabled", + "cli": "aws opensearchservice update-domain-config --domain-name --node-to-node-encryption-options Enabled=true", + "nativeIaC": "```yaml\n# CloudFormation: Enable node-to-node encryption for an OpenSearch domain\nResources:\n OpenSearchDomain:\n Type: AWS::OpenSearchService::Domain\n Properties:\n NodeToNodeEncryptionOptions:\n Enabled: true # Critical: enables TLS between nodes to pass the check\n```", + "terraform": "```hcl\n# Terraform: Enable node-to-node encryption for an OpenSearch domain\nresource \"aws_opensearch_domain\" \"\" {\n domain_name = \"\"\n\n node_to_node_encryption {\n enabled = true # Critical: encrypts intra-cluster traffic to pass the check\n }\n}\n```", + "other": "1. In the AWS Console, go to OpenSearch Service > Domains\n2. Select the target domain\n3. Click Edit (or Actions > Edit security configuration)\n4. Under Encryption, enable Node-to-node encryption\n5. Click Save changes" + }, + "opensearch_service_domains_internal_user_database_enabled": { + "checkTitle": "Amazon OpenSearch Service domain has internal user database disabled", + "recommendation": "Prefer **federated authentication** (IAM, SAML, or Amazon Cognito) and disable the **internal user database**. Enforce **least privilege** roles, require **MFA**, centralize credential rotation and offboarding, and log access. Use **VPC access** and restrictive policies; avoid HTTP basic auth to minimize exposure.", + "recommendationUrl": "https://hub.prowler.com/check/opensearch_service_domains_internal_user_database_enabled", + "cli": "aws opensearch update-domain-config --domain-name --advanced-security-options '{\"InternalUserDatabaseEnabled\":false}'", + "nativeIaC": "```yaml\n# CloudFormation: disable internal user database for the domain\nResources:\n :\n Type: AWS::OpenSearchService::Domain\n Properties:\n AdvancedSecurityOptions:\n InternalUserDatabaseEnabled: false # Critical: disables internal user DB to pass the check\n```", + "terraform": "```hcl\n# Terraform: disable internal user database for the domain\nresource \"aws_opensearch_domain\" \"\" {\n domain_name = \"\"\n\n advanced_security_options {\n internal_user_database_enabled = false # Critical: disables internal user DB to pass the check\n }\n}\n```", + "other": "1. In AWS console, go to Amazon OpenSearch Service > Domains\n2. Select the domain and choose Edit security configuration\n3. Under Fine-grained access control, turn off Internal user database\n4. Click Save changes" + }, + "opensearch_service_domains_use_cognito_authentication_for_kibana": { + "checkTitle": "Amazon OpenSearch Service domain has either Amazon Cognito or SAML authentication enabled for Kibana", + "recommendation": "Enable **Cognito** or **SAML** for Dashboards and apply **least privilege** with fine-grained access control. Prefer **SSO with MFA**, avoid shared/basic credentials, and restrict access via **VPC/private endpoints** and network controls. Monitor with audit logs and enforce **separation of duties**.", + "recommendationUrl": "https://hub.prowler.com/check/opensearch_service_domains_use_cognito_authentication_for_kibana", + "cli": "aws opensearch update-domain-config --domain-name --cognito-options Enabled=true,UserPoolId=,IdentityPoolId=,RoleArn=", + "nativeIaC": "```yaml\n# Enable Amazon Cognito authentication for OpenSearch Dashboards\nResources:\n :\n Type: AWS::OpenSearchService::Domain\n Properties:\n DomainName: \n CognitoOptions:\n Enabled: true # Critical: Enables Cognito auth for Dashboards to pass the check\n UserPoolId: \n IdentityPoolId: \n RoleArn: \n```", + "terraform": "```hcl\n# Enable Amazon Cognito authentication for OpenSearch Dashboards\nresource \"aws_opensearch_domain\" \"\" {\n domain_name = \"\"\n\n cognito_options {\n enabled = true # Critical: Enables Cognito auth for Dashboards to pass the check\n user_pool_id = \"\"\n identity_pool_id = \"\"\n role_arn = \"\"\n }\n}\n```", + "other": "1. In the AWS console, go to **OpenSearch Service** > **Domains** and select your domain\n2. Click **Edit**\n3. Under **OpenSearch Dashboards authentication**, choose **Amazon Cognito** and enable it\n4. Enter the **User pool ID**, **Identity pool ID**, and **IAM role** for Cognito\n5. Click **Save changes** and wait for the domain update to complete" + }, + "opensearch_service_domains_audit_logging_enabled": { + "checkTitle": "Amazon OpenSearch Service domain has audit logging enabled", + "recommendation": "Enable `AUDIT_LOGS` for all domains and route them to a centralized, durable log store.\n\nTune categories to record auth failures and sensitive index operations. Apply **least privilege** to log access, enforce retention and immutability, and integrate alerts to provide **defense in depth** and timely response.", + "recommendationUrl": "https://hub.prowler.com/check/opensearch_service_domains_audit_logging_enabled", + "cli": "aws opensearch update-domain-config --domain-name --log-publishing-options \"AUDIT_LOGS={CloudWatchLogsLogGroupArn=,Enabled=true}\"", + "nativeIaC": "```yaml\n# CloudFormation: Enable AUDIT_LOGS for an OpenSearch domain\nResources:\n OpenSearchDomain:\n Type: AWS::OpenSearchService::Domain\n Properties:\n DomainName: \n LogPublishingOptions:\n AUDIT_LOGS:\n CloudWatchLogsLogGroupArn: # Critical: where audit logs are sent\n Enabled: true # Critical: turns on AUDIT_LOGS to pass the check\n```", + "terraform": "```hcl\n# Enable AUDIT_LOGS for an OpenSearch domain\nresource \"aws_opensearch_domain\" \"example\" {\n domain_name = \"\"\n\n log_publishing_options {\n log_type = \"AUDIT_LOGS\"\n cloudwatch_log_group_arn = \"\" # Critical: destination for audit logs\n enabled = true # Critical: turns on AUDIT_LOGS to pass the check\n }\n}\n```", + "other": "1. Open the AWS console and go to OpenSearch Service\n2. Select the domain \n3. Open the Logs tab, find Audit logs, and click Enable\n4. Choose or create a CloudWatch log group and confirm the resource policy if prompted\n5. Click Save changes to enable AUDIT_LOGS\n6. If Fine-grained access control is not enabled, enable it first, then repeat steps 3-5" + }, + "opensearch_service_domains_fault_tolerant_data_nodes": { + "checkTitle": "OpenSearch domain has at least 3 data nodes and Zone Awareness enabled", + "recommendation": "Configure OpenSearch with **>= 3 data nodes** and enable **Zone Awareness** to spread nodes across AZs.\n\n- Prefer Multi-AZ with Standby for resilient failover\n- Use node counts in multiples of three and set index replicas (`>= 1`)\n- Practice capacity planning and failure testing as **defense in depth**", + "recommendationUrl": "https://hub.prowler.com/check/opensearch_service_domains_fault_tolerant_data_nodes", + "cli": "aws opensearch update-domain-config --domain-name --cluster-config InstanceCount=3,ZoneAwarenessEnabled=true", + "nativeIaC": "```yaml\n# CloudFormation: Ensure at least 3 data nodes and enable Zone Awareness\nResources:\n :\n Type: AWS::OpenSearchService::Domain\n Properties:\n DomainName: \n ClusterConfig:\n InstanceType: m5.large.search\n InstanceCount: 3 # Critical: sets at least 3 data nodes for fault tolerance\n ZoneAwarenessEnabled: true # Critical: enables cross-AZ (Zone Awareness)\n```", + "terraform": "```hcl\n# Terraform: Ensure at least 3 data nodes and enable Zone Awareness\nresource \"aws_opensearch_domain\" \"\" {\n domain_name = \"\"\n\n cluster_config {\n instance_type = \"m5.large.search\"\n instance_count = 3 # Critical: sets at least 3 data nodes for fault tolerance\n zone_awareness_enabled = true # Critical: enables cross-AZ (Zone Awareness)\n }\n}\n```", + "other": "1. Open the AWS Console and go to Amazon OpenSearch Service\n2. Select your domain and click Edit domain\n3. Under Cluster configuration:\n - Set Number of data nodes to 3 (or more)\n - Enable Zone awareness\n4. Click Submit to apply the changes" + }, + "opensearch_service_domains_access_control_enabled": { + "checkTitle": "Amazon OpenSearch Service domain has fine-grained access control enabled", + "recommendation": "Enable **fine-grained access control** in `advanced-security-options`. Define granular, role-based permissions (index/document/field) and map them to federated identities. Apply **least privilege**, deny-by-default, and **separation of duties**. Limit public access and regularly review role mappings.", + "recommendationUrl": "https://hub.prowler.com/check/opensearch_service_domains_access_control_enabled", + "cli": "aws opensearch update-domain-config --domain-name --advanced-security-options '{\"Enabled\":true,\"MasterUserOptions\":{\"MasterUserARN\":\"\"}}'", + "nativeIaC": "```yaml\n# CloudFormation: Enable fine-grained access control (FGAC) on an OpenSearch domain\nResources:\n :\n Type: AWS::OpenSearchService::Domain\n Properties:\n DomainName: \n AdvancedSecurityOptions:\n Enabled: true # Critical: Turns on FGAC\n MasterUserOptions:\n MasterUserARN: # Critical: Required to enable FGAC using an IAM principal\n```", + "terraform": "```hcl\n# Enable fine-grained access control (FGAC) on an OpenSearch domain\nresource \"aws_opensearch_domain\" \"\" {\n domain_name = \"\"\n\n advanced_security_options {\n enabled = true # Critical: Turns on FGAC\n master_user_options {\n master_user_arn = \"\" # Critical: Required to enable FGAC using an IAM principal\n }\n }\n}\n```", + "other": "1. In the AWS Console, go to Amazon OpenSearch Service\n2. Select your domain and choose Edit security configuration\n3. Enable Fine-grained access control\n4. Set the master user (choose IAM ARN and enter or create an internal master user)\n5. Save changes and wait for the update to complete" + }, + "opensearch_service_domains_updated_to_the_latest_service_software_version": { + "checkTitle": "Amazon OpenSearch Service domain is updated to the latest service software version", + "recommendation": "Apply the latest **service software updates** promptly. Schedule updates during the domain's **off-peak window** or enable automatic updates. Monitor console or **EventBridge** notifications, and test changes in staging to support **defense in depth** while minimizing downtime.", + "recommendationUrl": "https://hub.prowler.com/check/opensearch_service_domains_updated_to_the_latest_service_software_version", + "cli": "aws opensearch start-service-software-update --domain-name ", + "nativeIaC": null, + "terraform": null, + "other": "1. Sign in to the AWS Console and open Amazon OpenSearch Service\n2. Select the target domain\n3. Click Actions > Update\n4. Choose Apply update now\n5. Click Confirm to start the service software update" + }, + "opensearch_service_domains_cloudwatch_logging_enabled": { + "checkTitle": "Amazon OpenSearch Service domain publishes search and index slow logs to CloudWatch Logs", + "recommendation": "Enable both `SEARCH_SLOW_LOGS` and `INDEX_SLOW_LOGS` for all domains and publish to CloudWatch. Set meaningful thresholds and retention, separate log groups, and alert on anomalies. Apply **least privilege** to log access and use **defense in depth** with complementary error and audit logs.", + "recommendationUrl": "https://hub.prowler.com/check/opensearch_service_domains_cloudwatch_logging_enabled", + "cli": "aws opensearch update-domain-config --domain-name --log-publishing-options \"SEARCH_SLOW_LOGS={CloudWatchLogsLogGroupArn=,Enabled=true},INDEX_SLOW_LOGS={CloudWatchLogsLogGroupArn=,Enabled=true}\"", + "nativeIaC": "```yaml\n# CloudFormation: enable OpenSearch search and index slow logs to CloudWatch\nResources:\n :\n Type: AWS::OpenSearchService::Domain\n Properties:\n DomainName: \n LogPublishingOptions:\n SEARCH_SLOW_LOGS:\n CloudWatchLogsLogGroupArn: \n Enabled: true # Critical: enables SEARCH_SLOW_LOGS publishing\n INDEX_SLOW_LOGS:\n CloudWatchLogsLogGroupArn: \n Enabled: true # Critical: enables INDEX_SLOW_LOGS publishing\n```", + "terraform": "```hcl\n# Enable OpenSearch search and index slow logs to CloudWatch\nresource \"aws_opensearch_domain\" \"\" {\n domain_name = \"\"\n\n # Critical: enables SEARCH_SLOW_LOGS publishing\n log_publishing_options {\n log_type = \"SEARCH_SLOW_LOGS\"\n cloudwatch_log_group_arn = \"\"\n enabled = true\n }\n\n # Critical: enables INDEX_SLOW_LOGS publishing\n log_publishing_options {\n log_type = \"INDEX_SLOW_LOGS\"\n cloudwatch_log_group_arn = \"\"\n enabled = true\n }\n}\n```", + "other": "1. In the AWS Console, open Amazon OpenSearch Service and select your domain\n2. Go to the Logs tab\n3. For Search slow logs, click Enable, choose or create a CloudWatch log group, accept/attach the suggested resource policy, then Save\n4. For Index slow logs, click Enable, choose or create a CloudWatch log group, accept/attach the suggested resource policy, then Save\n5. Wait for domain status to return to Active" + }, + "cloudformation_stacks_termination_protection_enabled": { + "checkTitle": "CloudFormation stack has termination protection enabled", + "recommendation": "Enable **termination protection** on root stacks for critical workloads. Enforce **least privilege** on who can alter this setting or delete stacks, require **change review** via change sets, and apply **stack policies** plus `DeletionPolicy: Retain` for data stores for defense in depth.", + "recommendationUrl": "https://hub.prowler.com/check/cloudformation_stacks_termination_protection_enabled", + "cli": "aws cloudformation update-termination-protection --stack-name --enable-termination-protection", + "nativeIaC": null, + "terraform": "```hcl\nresource \"aws_cloudformation_stack\" \"\" {\n name = \"\"\n template_url = \"https://s3.amazonaws.com//