diff --git a/.scripts/run-opa-tests.ts b/.scripts/run-opa-tests.ts new file mode 100644 index 00000000..77af97db --- /dev/null +++ b/.scripts/run-opa-tests.ts @@ -0,0 +1,175 @@ +#!/usr/bin/env bun +/** + * Script to run Regula OPA tests on all terraform policies. + * + * Usage: + * bun .scripts/run-opa-tests.ts [--filter ] [--verbose] + * + * Options: + * --filter Only run tests matching the pattern (e.g., "aws/AWS Backup") + * --verbose Show verbose output including test details + * + * Examples: + * bun .scripts/run-opa-tests.ts # Run all tests + * bun .scripts/run-opa-tests.ts --filter "AWS Lambda" # Run only Lambda tests + * bun .scripts/run-opa-tests.ts --filter gcp # Run only GCP tests + */ + +import { spawnSync } from "child_process"; +import { readdirSync, statSync } from "fs"; +import { join, dirname } from "path"; + +const TERRAFORM_DIR = join(import.meta.dirname, "..", "terraform"); + +function findRegoFiles(dir: string): string[] { + const results: string[] = []; + + function walk(currentDir: string) { + const entries = readdirSync(currentDir); + for (const entry of entries) { + const fullPath = join(currentDir, entry); + const stat = statSync(fullPath); + if (stat.isDirectory()) { + walk(fullPath); + } else if (entry.endsWith(".rego")) { + results.push(fullPath); + } + } + } + + walk(dir); + return results; +} + +function findTestDirectories(baseDir: string): string[] { + const dirs = new Set(); + const regoFiles = findRegoFiles(baseDir); + + for (const file of regoFiles) { + dirs.add(dirname(file)); + } + + return Array.from(dirs).sort(); +} + +interface TestResult { + path: string; + success: boolean; + output: string; + configsLoaded: number; +} + +function runRegulaTest(dir: string): TestResult { + const result: TestResult = { + path: dir, + success: false, + output: "", + configsLoaded: 0 + }; + + const proc = spawnSync("npx", ["regula-wasi", "test", dir], { + encoding: "utf-8", + timeout: 120000, + cwd: process.cwd() + }); + + result.output = (proc.stdout || "") + (proc.stderr || ""); + result.success = proc.status === 0; + + // Extract number of loaded configs + const match = result.output.match(/Loaded (\d+) IaC configurations/); + if (match) { + result.configsLoaded = parseInt(match[1], 10); + } + + return result; +} + +async function main() { + const args = process.argv.slice(2); + const verbose = args.includes("--verbose"); + const filterIndex = args.indexOf("--filter"); + const filter = filterIndex !== -1 ? args[filterIndex + 1] : null; + + console.log("=== Regula OPA Test Runner ===\n"); + + // Check regula-wasi version + const versionProc = spawnSync("npx", ["regula-wasi", "version"], { + encoding: "utf-8", + timeout: 30000 + }); + + if (versionProc.status !== 0) { + console.error("Error: regula-wasi is not installed. Run: npm install --save-dev regula-wasi"); + process.exit(1); + } + + const versionOutput = (versionProc.stdout || "") + (versionProc.stderr || ""); + const opaVersion = versionOutput.match(/OPA v([\d.]+)/)?.[1] || "unknown"; + console.log(`Using regula-wasi with OPA v${opaVersion}\n`); + + // Find all test directories + let testDirs = findTestDirectories(TERRAFORM_DIR); + + if (filter) { + testDirs = testDirs.filter(d => d.toLowerCase().includes(filter.toLowerCase())); + console.log(`Filtering to ${testDirs.length} directories matching: ${filter}\n`); + } + + console.log(`Found ${testDirs.length} test directories\n`); + + if (testDirs.length === 0) { + console.log("No test directories found."); + return; + } + + const results: TestResult[] = []; + let passed = 0; + let failed = 0; + let totalConfigs = 0; + + for (const dir of testDirs) { + const relativePath = dir.replace(TERRAFORM_DIR + "/", ""); + process.stdout.write(`Testing: ${relativePath}... `); + + const result = runRegulaTest(dir); + totalConfigs += result.configsLoaded; + + if (result.success) { + console.log(`✓ PASS (${result.configsLoaded} configs)`); + passed++; + } else { + console.log("✗ FAIL"); + if (verbose) { + console.log(result.output); + } + failed++; + } + + results.push(result); + } + + console.log("\n=== Summary ==="); + console.log(`Directories: ${testDirs.length}`); + console.log(`IaC Configs: ${totalConfigs}`); + console.log(`Passed: ${passed}`); + console.log(`Failed: ${failed}`); + + if (failed > 0) { + console.log("\nFailed tests:"); + for (const result of results) { + if (!result.success) { + const relativePath = result.path.replace(TERRAFORM_DIR + "/", ""); + console.log(` - ${relativePath}`); + } + } + process.exit(1); + } + + console.log("\n✓ All tests passed!"); +} + +main().catch(e => { + console.error(e); + process.exit(1); +}); diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..be0554df --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,102 @@ +# Starchitect CloudGuard + +Cloud security compliance policies and runtime checks for AWS and GCP. + +## Project Structure + +``` +starchitect-cloudguard/ +├── terraform/ # OPA/Rego security policies (IaC scanning) +│ ├── aws/ # AWS policies (66 service categories) +│ └── gcp/ # GCP policies (11 service categories) +├── runtime/ # TypeScript runtime checks (live cloud API) +│ ├── aws/ # AWS runtime checks with tests +│ └── gcp/ # GCP runtime checks with tests +├── cli/ # CLI application (oclif-based) +└── .scripts/ # Build and test scripts +``` + +## OPA/Rego Policies + +Located in `terraform/aws/` and `terraform/gcp/`. Each policy directory contains: + +- `*.rego` - Policy file using Fugue/Regula framework +- `*_pass.tf` - Terraform fixture that should pass the policy +- `*_fail.tf` - Terraform fixture that should fail the policy + +### Running OPA Tests + +```bash +# Run all OPA tests +npm run test:opa + +# Run with verbose output +npm run test:opa:verbose + +# Filter to specific policies +bun ./.scripts/run-opa-tests.ts --filter "AWS Lambda" +bun ./.scripts/run-opa-tests.ts --filter gcp +``` + +Uses `regula-wasi` (nonfx fork with OPA v1.12.2) to evaluate policies against test fixtures. + +## Runtime Tests + +TypeScript tests in `runtime/` use BUN test runner with Jest-style syntax: + +```bash +# Run all runtime tests +npm test +# or +bun test +``` + +## Commands + +| Command | Description | +| ------------------ | ---------------------------- | +| `npm test` | Run runtime tests (bun test) | +| `npm run test:opa` | Run OPA/Rego policy tests | +| `npm run build` | TypeScript compilation | +| `npm run lint` | Run ESLint | +| `npm run prettify` | Format code with Prettier | + +## Writing Policies + +Rego policies use the Fugue framework: + +```rego +package rules.example_policy + +import data.fugue + +__rego__metadoc__ := { + "id": "EXAMPLE.1", + "title": "Example policy title", + "description": "Description of what this policy checks", + "custom": {"severity": "High", "author": "Starchitect Agent"}, +} + +resource_type := "MULTIPLE" + +resources = fugue.resources("aws_example_resource") + +policy[p] { + resource := resources[_] + # check passes + p = fugue.allow_resource(resource) +} + +policy[p] { + resource := resources[_] + # check fails + p = fugue.deny_resource_with_message(resource, "Error message") +} +``` + +## Dependencies + +- **regula-wasi**: OPA/Rego policy evaluation (WASI build with OPA v1.12.2) +- **bun**: Test runner and TypeScript execution +- **AWS SDK v3**: For runtime checks and test mocking +- **Google Cloud clients**: For GCP runtime checks diff --git a/package-lock.json b/package-lock.json index 38eba85e..a8ba0808 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@nonfx/starchitect-cloudguard", - "version": "0.0.3", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nonfx/starchitect-cloudguard", - "version": "0.0.3", + "version": "0.3.0", "workspaces": [ "cli/" ], @@ -81,6 +81,7 @@ "jest": "^29.7.0", "lint-staged": "^15.2.11", "prettier": "^3.4.2", + "regula-wasi": "^3.2.3", "ts-jest": "^29.2.5", "typescript-eslint": "^8.18.2" } @@ -36042,6 +36043,19 @@ "node": ">=14" } }, + "node_modules/regula-wasi": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/regula-wasi/-/regula-wasi-3.2.3.tgz", + "integrity": "sha512-bZH/BwlFo+jt8dX7nbHUYqHdIJ8zFXqfzs3Yy8MJfZAInzFBJFVOm/1RmIOKO/D11nc6dpqxpsOkH9Qb9/Apxg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "regula": "cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "license": "MIT", diff --git a/package.json b/package.json index aa1b26dc..496f0ea2 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "scripts": { "build": "tsc -b --incremental", "test": "bun test", + "test:opa": "bun ./.scripts/run-opa-tests.ts", + "test:opa:verbose": "bun ./.scripts/run-opa-tests.ts --verbose", "prepare": "husky", "prettier:lint": "prettier --config .prettierrc.cjs --cache --cache-location .cache/prettier --check .", "lint:files": "env TIMING=1 eslint --quiet", @@ -72,8 +74,8 @@ "@google-cloud/monitoring": "^4.1.0", "@google-cloud/resource-manager": "^5.3.0", "@google-cloud/service-usage": "^3.4.0", - "@google-cloud/storage": "^7.15.0", "@google-cloud/sql": "^0.19.0", + "@google-cloud/storage": "^7.15.0", "@googleapis/sqladmin": "^24.0.0", "@types/bun": "^1.1.14", "@types/jest": "^29.5.14", @@ -85,6 +87,7 @@ "jest": "^29.7.0", "lint-staged": "^15.2.11", "prettier": "^3.4.2", + "regula-wasi": "^3.2.3", "ts-jest": "^29.2.5", "typescript-eslint": "^8.18.2" },