From 3fc4474adaa364510e478d812253af65c0f1248b Mon Sep 17 00:00:00 2001 From: Nishu Goel Date: Mon, 16 Mar 2026 14:03:48 +0100 Subject: [PATCH 1/2] Add @epilot/blueprint-tester library for post-export blueprint validation Catches dangling UUIDs, leaked tokens/secrets, source org references, and other issues in exported blueprint ZIP files before publishing. 8 validation rules: - dangling-uuid (error): hardcoded UUIDs not matching any blueprint resource - source-org-ref (error): hardcoded organization IDs - cross-ref-integrity (error): broken depends_on / terraform references - token-detection (warning): API keys, bearer tokens, OAuth secrets - public-journey-safety (warning): public journeys with hardcoded refs - incomplete-webhook (warning): webhooks with hardcoded URLs/auth - environment-url (info): sandbox/staging/dev URLs - email-address (info): hardcoded email addresses Also adds `epilot blueprint-test` CLI command. Co-Authored-By: Claude Opus 4.6 --- .../__tests__/hcl-parser.test.ts | 142 ++++++++++++++ .../rules/cross-ref-integrity.test.ts | 72 +++++++ .../__tests__/rules/dangling-uuids.test.ts | 91 +++++++++ .../__tests__/rules/email-addresses.test.ts | 52 +++++ .../__tests__/rules/environment-urls.test.ts | 52 +++++ .../rules/incomplete-webhooks.test.ts | 52 +++++ .../rules/public-journey-safety.test.ts | 65 +++++++ .../__tests__/rules/source-org-refs.test.ts | 60 ++++++ .../__tests__/rules/token-detection.test.ts | 54 +++++ .../__tests__/validator.test.ts | 130 +++++++++++++ packages/blueprint-tester/package.json | 53 +++++ packages/blueprint-tester/src/hcl-parser.ts | 184 ++++++++++++++++++ packages/blueprint-tester/src/index.ts | 13 ++ .../blueprint-tester/src/report-formatter.ts | 73 +++++++ .../src/rules/cross-ref-integrity.ts | 85 ++++++++ .../src/rules/dangling-uuids.ts | 81 ++++++++ .../src/rules/email-addresses.ts | 49 +++++ .../src/rules/environment-urls.ts | 49 +++++ .../src/rules/incomplete-webhooks.ts | 109 +++++++++++ packages/blueprint-tester/src/rules/index.ts | 32 +++ .../src/rules/public-journey-safety.ts | 89 +++++++++ .../src/rules/source-org-refs.ts | 91 +++++++++ .../src/rules/token-detection.ts | 57 ++++++ packages/blueprint-tester/src/types.ts | 81 ++++++++ .../src/utils/resource-index.ts | 29 +++ .../src/utils/terraform-refs.ts | 59 ++++++ packages/blueprint-tester/src/utils/uuid.ts | 26 +++ packages/blueprint-tester/src/validator.ts | 125 ++++++++++++ packages/blueprint-tester/src/zip-reader.ts | 18 ++ packages/blueprint-tester/tsconfig.json | 19 ++ packages/blueprint-tester/tsup.config.ts | 11 ++ packages/blueprint-tester/vitest.config.ts | 8 + packages/cli/src/commands/blueprint-test.ts | 62 ++++++ packages/cli/src/index.ts | 1 + 34 files changed, 2174 insertions(+) create mode 100644 packages/blueprint-tester/__tests__/hcl-parser.test.ts create mode 100644 packages/blueprint-tester/__tests__/rules/cross-ref-integrity.test.ts create mode 100644 packages/blueprint-tester/__tests__/rules/dangling-uuids.test.ts create mode 100644 packages/blueprint-tester/__tests__/rules/email-addresses.test.ts create mode 100644 packages/blueprint-tester/__tests__/rules/environment-urls.test.ts create mode 100644 packages/blueprint-tester/__tests__/rules/incomplete-webhooks.test.ts create mode 100644 packages/blueprint-tester/__tests__/rules/public-journey-safety.test.ts create mode 100644 packages/blueprint-tester/__tests__/rules/source-org-refs.test.ts create mode 100644 packages/blueprint-tester/__tests__/rules/token-detection.test.ts create mode 100644 packages/blueprint-tester/__tests__/validator.test.ts create mode 100644 packages/blueprint-tester/package.json create mode 100644 packages/blueprint-tester/src/hcl-parser.ts create mode 100644 packages/blueprint-tester/src/index.ts create mode 100644 packages/blueprint-tester/src/report-formatter.ts create mode 100644 packages/blueprint-tester/src/rules/cross-ref-integrity.ts create mode 100644 packages/blueprint-tester/src/rules/dangling-uuids.ts create mode 100644 packages/blueprint-tester/src/rules/email-addresses.ts create mode 100644 packages/blueprint-tester/src/rules/environment-urls.ts create mode 100644 packages/blueprint-tester/src/rules/incomplete-webhooks.ts create mode 100644 packages/blueprint-tester/src/rules/index.ts create mode 100644 packages/blueprint-tester/src/rules/public-journey-safety.ts create mode 100644 packages/blueprint-tester/src/rules/source-org-refs.ts create mode 100644 packages/blueprint-tester/src/rules/token-detection.ts create mode 100644 packages/blueprint-tester/src/types.ts create mode 100644 packages/blueprint-tester/src/utils/resource-index.ts create mode 100644 packages/blueprint-tester/src/utils/terraform-refs.ts create mode 100644 packages/blueprint-tester/src/utils/uuid.ts create mode 100644 packages/blueprint-tester/src/validator.ts create mode 100644 packages/blueprint-tester/src/zip-reader.ts create mode 100644 packages/blueprint-tester/tsconfig.json create mode 100644 packages/blueprint-tester/tsup.config.ts create mode 100644 packages/blueprint-tester/vitest.config.ts create mode 100644 packages/cli/src/commands/blueprint-test.ts diff --git a/packages/blueprint-tester/__tests__/hcl-parser.test.ts b/packages/blueprint-tester/__tests__/hcl-parser.test.ts new file mode 100644 index 00000000..12ccec0c --- /dev/null +++ b/packages/blueprint-tester/__tests__/hcl-parser.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import { parseTerraformFile } from '../src/hcl-parser.js'; + +describe('parseTerraformFile', () => { + it('parses a simple resource block', () => { + const content = ` +resource "epilot_journey" "sample_journey_abc123" { + name = "My Journey" + access_mode = "PUBLIC" +}`; + const result = parseTerraformFile('main.tf', content); + + expect(result.resources).toHaveLength(1); + expect(result.resources[0].type).toBe('epilot_journey'); + expect(result.resources[0].name).toBe('sample_journey_abc123'); + expect(result.resources[0].address).toBe('epilot_journey.sample_journey_abc123'); + expect(result.resources[0].attributes.name).toBe('My Journey'); + expect(result.resources[0].attributes.access_mode).toBe('PUBLIC'); + }); + + it('parses multiple resource blocks', () => { + const content = ` +resource "epilot_journey" "journey_1" { + name = "Journey 1" +} + +resource "epilot_automation" "auto_1" { + name = "Automation 1" +}`; + const result = parseTerraformFile('main.tf', content); + expect(result.resources).toHaveLength(2); + expect(result.resources[0].type).toBe('epilot_journey'); + expect(result.resources[1].type).toBe('epilot_automation'); + }); + + it('parses depends_on arrays', () => { + const content = ` +resource "epilot_automation" "auto_1" { + name = "Automation" + depends_on = [epilot_journey.journey_1, epilot_schema.contact] +}`; + const result = parseTerraformFile('main.tf', content); + expect(result.resources[0].dependsOn).toEqual([ + 'epilot_journey.journey_1', + 'epilot_schema.contact', + ]); + }); + + it('parses nested blocks', () => { + const content = ` +resource "epilot_automation" "auto_1" { + trigger { + type = "journey_submission" + journey_id = "abc-123" + } + name = "Automation" +}`; + const result = parseTerraformFile('main.tf', content); + expect(result.resources[0].attributes.trigger).toBeDefined(); + expect(result.resources[0].attributes.name).toBe('Automation'); + }); + + it('parses boolean and null values', () => { + const content = ` +resource "epilot_journey" "j1" { + is_active = true + is_deleted = false + description = null +}`; + const result = parseTerraformFile('main.tf', content); + expect(result.resources[0].attributes.is_active).toBe(true); + expect(result.resources[0].attributes.is_deleted).toBe(false); + expect(result.resources[0].attributes.description).toBe(null); + }); + + it('preserves terraform references as strings', () => { + const content = ` +resource "epilot_automation" "auto_1" { + journey_id = epilot_journey.sample.journey_id +}`; + const result = parseTerraformFile('main.tf', content); + expect(result.resources[0].attributes.journey_id).toBe( + 'epilot_journey.sample.journey_id', + ); + }); + + it('parses variables', () => { + const content = ` +variable "manifest_id" { + type = string +} + +variable "target_org_id" { + type = string +} + +resource "epilot_journey" "j1" { + name = "test" +}`; + const result = parseTerraformFile('main.tf', content); + expect(result.variables).toHaveProperty('manifest_id'); + expect(result.variables).toHaveProperty('target_org_id'); + }); + + it('tracks line numbers', () => { + const content = ` +resource "epilot_journey" "j1" { + name = "test" +} + +resource "epilot_automation" "a1" { + name = "auto" +}`; + const result = parseTerraformFile('main.tf', content); + expect(result.resources[0].lineStart).toBe(2); + expect(result.resources[1].lineStart).toBe(6); + }); + + it('handles lifecycle blocks', () => { + const content = ` +resource "epilot-taxonomy_taxonomy_classification" "tax_abc123" { + lifecycle { + prevent_destroy = true + } + manifest = distinct([var.manifest_id]) + name = "Test Classification" +}`; + const result = parseTerraformFile('main.tf', content); + expect(result.resources).toHaveLength(1); + expect(result.resources[0].attributes.name).toBe('Test Classification'); + }); + + it('handles jsonencode function calls', () => { + const content = ` +resource "epilot_journey" "j1" { + journey = jsonencode({"steps": [{"name": "step1"}]}) + name = "test" +}`; + const result = parseTerraformFile('main.tf', content); + expect(result.resources[0].attributes.journey).toContain('jsonencode'); + }); +}); diff --git a/packages/blueprint-tester/__tests__/rules/cross-ref-integrity.test.ts b/packages/blueprint-tester/__tests__/rules/cross-ref-integrity.test.ts new file mode 100644 index 00000000..6a829e9f --- /dev/null +++ b/packages/blueprint-tester/__tests__/rules/cross-ref-integrity.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { parseTerraformFile } from '../../src/hcl-parser.js'; +import { crossRefIntegrityRule } from '../../src/rules/cross-ref-integrity.js'; +import { buildResourceIndex } from '../../src/utils/resource-index.js'; + +function runRule(tfContent: string) { + const file = parseTerraformFile('main.tf', tfContent); + const files = [file]; + const resourceIndex = buildResourceIndex(files); + return crossRefIntegrityRule.validate({ files, resourceIndex, options: {} }); +} + +describe('cross-ref-integrity rule', () => { + it('flags depends_on referencing non-existent resource', () => { + const tf = ` +resource "epilot_automation" "auto_1" { + name = "Automation" + depends_on = [epilot_journey.missing_journey] +}`; + const issues = runRule(tf); + expect(issues).toHaveLength(1); + expect(issues[0].ruleId).toBe('cross-ref-integrity'); + expect(issues[0].attributePath).toBe('depends_on'); + expect(issues[0].value).toBe('epilot_journey.missing_journey'); + }); + + it('does NOT flag depends_on referencing existing resource', () => { + const tf = ` +resource "epilot_journey" "journey_1" { + name = "Journey" +} + +resource "epilot_automation" "auto_1" { + name = "Automation" + depends_on = [epilot_journey.journey_1] +}`; + const issues = runRule(tf); + expect(issues).toHaveLength(0); + }); + + it('flags terraform reference to non-existent resource', () => { + const tf = ` +resource "epilot_automation" "auto_1" { + journey_id = "\${epilot_journey.missing.journey_id}" +}`; + const issues = runRule(tf); + expect(issues).toHaveLength(1); + expect(issues[0].value).toBe('epilot_journey.missing'); + }); + + it('does NOT flag terraform reference to existing resource', () => { + const tf = ` +resource "epilot_journey" "sample" { + name = "Journey" +} + +resource "epilot_automation" "auto_1" { + journey_id = "\${epilot_journey.sample.journey_id}" +}`; + const issues = runRule(tf); + expect(issues).toHaveLength(0); + }); + + it('does NOT flag variable references', () => { + const tf = ` +resource "epilot_journey" "j1" { + manifest = "\${var.manifest_id}" +}`; + const issues = runRule(tf); + expect(issues).toHaveLength(0); + }); +}); diff --git a/packages/blueprint-tester/__tests__/rules/dangling-uuids.test.ts b/packages/blueprint-tester/__tests__/rules/dangling-uuids.test.ts new file mode 100644 index 00000000..321c800b --- /dev/null +++ b/packages/blueprint-tester/__tests__/rules/dangling-uuids.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { parseTerraformFile } from '../../src/hcl-parser.js'; +import { danglingUuidsRule } from '../../src/rules/dangling-uuids.js'; +import type { ValidatorOptions } from '../../src/types.js'; +import { buildResourceIndex } from '../../src/utils/resource-index.js'; + +function runRule(tfContent: string, options: ValidatorOptions = {}) { + const file = parseTerraformFile('main.tf', tfContent); + const files = [file]; + const resourceIndex = buildResourceIndex(files); + return danglingUuidsRule.validate({ files, resourceIndex, options }); +} + +describe('dangling-uuids rule', () => { + it('flags a hardcoded UUID in an attribute', () => { + const tf = ` +resource "epilot_automation" "auto_abc123def456abc123def456abc123de" { + journey_id = "d11995ae-368e-4ad3-bf1c-51d6449f8afc" +}`; + const issues = runRule(tf); + expect(issues).toHaveLength(1); + expect(issues[0].ruleId).toBe('dangling-uuid'); + expect(issues[0].severity).toBe('error'); + expect(issues[0].attributePath).toBe('journey_id'); + expect(issues[0].value).toBe('d11995ae-368e-4ad3-bf1c-51d6449f8afc'); + }); + + it('does NOT flag a terraform reference', () => { + const tf = ` +resource "epilot_automation" "auto_1" { + journey_id = epilot_journey.sample.journey_id +}`; + const issues = runRule(tf); + expect(issues).toHaveLength(0); + }); + + it('does NOT flag a UUID that matches another resource identifier', () => { + const tf = ` +resource "epilot_journey" "journey_d11995ae368e4ad3bf1c51d6449f8afc" { + name = "My Journey" +} + +resource "epilot_automation" "auto_abc123def456abc123def456abc123de" { + journey_id = "d11995ae-368e-4ad3-bf1c-51d6449f8afc" +}`; + const issues = runRule(tf); + expect(issues).toHaveLength(0); + }); + + it('does NOT flag UUIDs in knownSafeUuids', () => { + const tf = ` +resource "epilot_automation" "auto_abc123def456abc123def456abc123de" { + some_id = "d11995ae-368e-4ad3-bf1c-51d6449f8afc" +}`; + const issues = runRule(tf, { + knownSafeUuids: ['d11995ae-368e-4ad3-bf1c-51d6449f8afc'], + }); + expect(issues).toHaveLength(0); + }); + + it('does NOT flag variable references', () => { + const tf = ` +resource "epilot_journey" "j1" { + manifest = "distinct([var.manifest_id])" +}`; + // This is stored as a string that contains "var." — the rule checks containsTerraformRef + // Actually the value would be stored as the raw function call string + const issues = runRule(tf); + // The UUID regex won't match "var.manifest_id" since it's not a UUID format + expect(issues).toHaveLength(0); + }); + + it('flags multiple dangling UUIDs in the same resource', () => { + const tf = ` +resource "epilot_automation" "auto_abc123def456abc123def456abc123de" { + journey_id = "d11995ae-368e-4ad3-bf1c-51d6449f8afc" + mapping_id = "e22fb48b-479f-5be4-cf2d-62e7550a9bfd" +}`; + const issues = runRule(tf); + expect(issues).toHaveLength(2); + }); + + it('does NOT flag terraform interpolation expressions', () => { + const tf = ` +resource "epilot_automation" "auto_1" { + journey_id = "\${epilot_journey.sample.journey_id}" +}`; + const issues = runRule(tf); + expect(issues).toHaveLength(0); + }); +}); diff --git a/packages/blueprint-tester/__tests__/rules/email-addresses.test.ts b/packages/blueprint-tester/__tests__/rules/email-addresses.test.ts new file mode 100644 index 00000000..30756261 --- /dev/null +++ b/packages/blueprint-tester/__tests__/rules/email-addresses.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { parseTerraformFile } from '../../src/hcl-parser.js'; +import { emailAddressesRule } from '../../src/rules/email-addresses.js'; +import { buildResourceIndex } from '../../src/utils/resource-index.js'; + +function runRule(tfContent: string) { + const file = parseTerraformFile('main.tf', tfContent); + const files = [file]; + const resourceIndex = buildResourceIndex(files); + return emailAddressesRule.validate({ files, resourceIndex, options: {} }); +} + +describe('email-addresses rule', () => { + it('detects hardcoded email addresses', () => { + const tf = ` +resource "epilot_emailtemplate" "template1" { + to = "john.doe@company.com" + subject = "Welcome" +}`; + const issues = runRule(tf); + expect(issues.length).toBeGreaterThanOrEqual(1); + expect(issues[0].ruleId).toBe('email-address'); + expect(issues[0].value).toBe('john.doe@company.com'); + }); + + it('does NOT flag noreply addresses', () => { + const tf = ` +resource "epilot_emailtemplate" "template1" { + from = "noreply@epilot.cloud" +}`; + const issues = runRule(tf); + expect(issues).toHaveLength(0); + }); + + it('does NOT flag no-reply addresses', () => { + const tf = ` +resource "epilot_emailtemplate" "template1" { + from = "no-reply@company.com" +}`; + const issues = runRule(tf); + expect(issues).toHaveLength(0); + }); + + it('does NOT flag resources without emails', () => { + const tf = ` +resource "epilot_journey" "j1" { + name = "My Journey" +}`; + const issues = runRule(tf); + expect(issues).toHaveLength(0); + }); +}); diff --git a/packages/blueprint-tester/__tests__/rules/environment-urls.test.ts b/packages/blueprint-tester/__tests__/rules/environment-urls.test.ts new file mode 100644 index 00000000..021cb948 --- /dev/null +++ b/packages/blueprint-tester/__tests__/rules/environment-urls.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { parseTerraformFile } from '../../src/hcl-parser.js'; +import { environmentUrlsRule } from '../../src/rules/environment-urls.js'; +import { buildResourceIndex } from '../../src/utils/resource-index.js'; + +function runRule(tfContent: string) { + const file = parseTerraformFile('main.tf', tfContent); + const files = [file]; + const resourceIndex = buildResourceIndex(files); + return environmentUrlsRule.validate({ files, resourceIndex, options: {} }); +} + +describe('environment-urls rule', () => { + it('detects sandbox URLs', () => { + const tf = ` +resource "epilot_webhook" "wh1" { + url = "https://sandbox-api.epilot.cloud/webhook" +}`; + const issues = runRule(tf); + expect(issues.length).toBeGreaterThanOrEqual(1); + expect(issues[0].ruleId).toBe('environment-url'); + expect(issues[0].severity).toBe('info'); + }); + + it('detects localhost URLs', () => { + const tf = ` +resource "epilot_webhook" "wh1" { + url = "http://localhost:3000/api" +}`; + const issues = runRule(tf); + expect(issues.length).toBeGreaterThanOrEqual(1); + }); + + it('detects internal service URLs', () => { + const tf = ` +resource "epilot_webhook" "wh1" { + url = "https://entity-api.sls.epilot.io/v1/entities" +}`; + const issues = runRule(tf); + expect(issues.length).toBeGreaterThanOrEqual(1); + }); + + it('does NOT flag normal production URLs', () => { + const tf = ` +resource "epilot_journey" "j1" { + name = "test" + slug = "my-journey" +}`; + const issues = runRule(tf); + expect(issues).toHaveLength(0); + }); +}); diff --git a/packages/blueprint-tester/__tests__/rules/incomplete-webhooks.test.ts b/packages/blueprint-tester/__tests__/rules/incomplete-webhooks.test.ts new file mode 100644 index 00000000..614846bc --- /dev/null +++ b/packages/blueprint-tester/__tests__/rules/incomplete-webhooks.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { parseTerraformFile } from '../../src/hcl-parser.js'; +import { incompleteWebhooksRule } from '../../src/rules/incomplete-webhooks.js'; +import { buildResourceIndex } from '../../src/utils/resource-index.js'; + +function runRule(tfContent: string) { + const file = parseTerraformFile('main.tf', tfContent); + const files = [file]; + const resourceIndex = buildResourceIndex(files); + return incompleteWebhooksRule.validate({ files, resourceIndex, options: {} }); +} + +describe('incomplete-webhooks rule', () => { + it('flags webhook with hardcoded URL', () => { + const tf = ` +resource "epilot_webhook" "wh1" { + url = "https://api.sandbox.example.com/webhook" +}`; + const issues = runRule(tf); + expect(issues.length).toBeGreaterThanOrEqual(1); + expect(issues[0].ruleId).toBe('incomplete-webhook'); + expect(issues[0].attributePath).toBe('url'); + }); + + it('flags webhook with embedded auth', () => { + const tf = ` +resource "epilot_webhook" "wh1" { + url = "https://example.com/webhook" + oauth_secret = "my-secret-value" +}`; + const issues = runRule(tf); + expect(issues.length).toBeGreaterThanOrEqual(1); + }); + + it('does NOT flag webhook with variable URL', () => { + const tf = ` +resource "epilot_webhook" "wh1" { + url = var.webhook_url +}`; + const issues = runRule(tf); + expect(issues).toHaveLength(0); + }); + + it('does NOT flag non-webhook resources', () => { + const tf = ` +resource "epilot_journey" "j1" { + url = "https://example.com/journey" +}`; + const issues = runRule(tf); + expect(issues).toHaveLength(0); + }); +}); diff --git a/packages/blueprint-tester/__tests__/rules/public-journey-safety.test.ts b/packages/blueprint-tester/__tests__/rules/public-journey-safety.test.ts new file mode 100644 index 00000000..d621bab5 --- /dev/null +++ b/packages/blueprint-tester/__tests__/rules/public-journey-safety.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { parseTerraformFile } from '../../src/hcl-parser.js'; +import { publicJourneySafetyRule } from '../../src/rules/public-journey-safety.js'; +import { buildResourceIndex } from '../../src/utils/resource-index.js'; + +function runRule(tfContent: string) { + const file = parseTerraformFile('main.tf', tfContent); + const files = [file]; + const resourceIndex = buildResourceIndex(files); + return publicJourneySafetyRule.validate({ files, resourceIndex, options: {} }); +} + +describe('public-journey-safety rule', () => { + it('flags public journey with hardcoded mappings_automation_id', () => { + const tf = ` +resource "epilot_journey" "j1" { + access_mode = "PUBLIC" + mappings_automation_id = "d11995ae-368e-4ad3-bf1c-51d6449f8afc" +}`; + const issues = runRule(tf); + expect(issues.length).toBeGreaterThanOrEqual(1); + expect(issues[0].ruleId).toBe('public-journey-safety'); + expect(issues[0].attributePath).toBe('mappings_automation_id'); + }); + + it('does NOT flag private journey with hardcoded ID', () => { + const tf = ` +resource "epilot_journey" "j1" { + access_mode = "PRIVATE" + mappings_automation_id = "d11995ae-368e-4ad3-bf1c-51d6449f8afc" +}`; + const issues = runRule(tf); + expect(issues).toHaveLength(0); + }); + + it('does NOT flag public journey with terraform ref', () => { + const tf = ` +resource "epilot_journey" "j1" { + access_mode = "PUBLIC" + mappings_automation_id = epilot_automation.auto_1.id +}`; + const issues = runRule(tf); + expect(issues).toHaveLength(0); + }); + + it('flags public journey with hardcoded organization_id in body', () => { + const tf = ` +resource "epilot_journey" "j1" { + access_mode = "PUBLIC" + journey = jsonencode({"organization_id": "911690", "name": "test"}) +}`; + const issues = runRule(tf); + expect(issues.some((i) => i.message.includes('organization_id'))).toBe(true); + }); + + it('does NOT flag non-journey resources', () => { + const tf = ` +resource "epilot_automation" "a1" { + access_mode = "PUBLIC" + mappings_automation_id = "d11995ae-368e-4ad3-bf1c-51d6449f8afc" +}`; + const issues = runRule(tf); + expect(issues).toHaveLength(0); + }); +}); diff --git a/packages/blueprint-tester/__tests__/rules/source-org-refs.test.ts b/packages/blueprint-tester/__tests__/rules/source-org-refs.test.ts new file mode 100644 index 00000000..f1b707f0 --- /dev/null +++ b/packages/blueprint-tester/__tests__/rules/source-org-refs.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { parseTerraformFile } from '../../src/hcl-parser.js'; +import { sourceOrgRefsRule } from '../../src/rules/source-org-refs.js'; +import { buildResourceIndex } from '../../src/utils/resource-index.js'; + +function runRule(tfContent: string, sourceOrgId?: string) { + const file = parseTerraformFile('main.tf', tfContent); + const files = [file]; + const resourceIndex = buildResourceIndex(files); + return sourceOrgRefsRule.validate({ files, resourceIndex, options: { sourceOrgId } }); +} + +describe('source-org-refs rule', () => { + it('flags hardcoded org_id', () => { + const tf = ` +resource "epilot_journey" "j1" { + org_id = "911690" +}`; + const issues = runRule(tf); + expect(issues.length).toBeGreaterThanOrEqual(1); + expect(issues[0].ruleId).toBe('source-org-ref'); + expect(issues[0].severity).toBe('error'); + }); + + it('flags hardcoded organization_id', () => { + const tf = ` +resource "epilot_journey" "j1" { + organization_id = "739224" +}`; + const issues = runRule(tf); + expect(issues.length).toBeGreaterThanOrEqual(1); + }); + + it('identifies source org match', () => { + const tf = ` +resource "epilot_journey" "j1" { + org_id = "911690" +}`; + const issues = runRule(tf, '911690'); + expect(issues.some((i) => i.message.includes('matches source org'))).toBe(true); + }); + + it('does NOT flag terraform variable references', () => { + const tf = ` +resource "epilot_journey" "j1" { + org_id = var.target_org_id +}`; + const issues = runRule(tf); + expect(issues).toHaveLength(0); + }); + + it('flags org_id in jsonencode blocks', () => { + const tf = ` +resource "epilot_journey" "j1" { + journey = jsonencode({"organization_id": "911690", "name": "test"}) +}`; + const issues = runRule(tf); + expect(issues.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/packages/blueprint-tester/__tests__/rules/token-detection.test.ts b/packages/blueprint-tester/__tests__/rules/token-detection.test.ts new file mode 100644 index 00000000..a1efae93 --- /dev/null +++ b/packages/blueprint-tester/__tests__/rules/token-detection.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { parseTerraformFile } from '../../src/hcl-parser.js'; +import { tokenDetectionRule } from '../../src/rules/token-detection.js'; +import { buildResourceIndex } from '../../src/utils/resource-index.js'; + +function runRule(tfContent: string) { + const file = parseTerraformFile('main.tf', tfContent); + const files = [file]; + const resourceIndex = buildResourceIndex(files); + return tokenDetectionRule.validate({ files, resourceIndex, options: {} }); +} + +describe('token-detection rule', () => { + it('detects API key patterns', () => { + const tf = ` +resource "epilot_webhook" "wh1" { + token_id = "api_5ZugdRXasLfWBypHi93Fk" +}`; + const issues = runRule(tf); + expect(issues.length).toBeGreaterThanOrEqual(1); + expect(issues[0].ruleId).toBe('token-detection'); + expect(issues[0].severity).toBe('warning'); + // Value should be truncated + expect(issues[0].value).toContain('***'); + }); + + it('detects Bearer tokens', () => { + const tf = ` +resource "epilot_webhook" "wh1" { + auth_header = "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test" +}`; + const issues = runRule(tf); + expect(issues.length).toBeGreaterThanOrEqual(1); + }); + + it('detects secrets in assignments', () => { + const tf = ` +resource "epilot_webhook" "wh1" { + secret = "my-super-secret-value-123" +}`; + const issues = runRule(tf); + expect(issues.length).toBeGreaterThanOrEqual(1); + }); + + it('does NOT flag normal attribute values', () => { + const tf = ` +resource "epilot_journey" "j1" { + name = "My Journey" + slug = "my-journey" +}`; + const issues = runRule(tf); + expect(issues).toHaveLength(0); + }); +}); diff --git a/packages/blueprint-tester/__tests__/validator.test.ts b/packages/blueprint-tester/__tests__/validator.test.ts new file mode 100644 index 00000000..16860257 --- /dev/null +++ b/packages/blueprint-tester/__tests__/validator.test.ts @@ -0,0 +1,130 @@ +import { resolve } from 'node:path'; +import AdmZip from 'adm-zip'; +import { describe, expect, it } from 'vitest'; +import { validateBlueprint } from '../src/validator.js'; + +function createBlueprintZip(files: Record): Buffer { + const zip = new AdmZip(); + for (const [name, content] of Object.entries(files)) { + zip.addFile(name, Buffer.from(content, 'utf-8')); + } + return zip.toBuffer(); +} + +describe('validateBlueprint', () => { + it('returns clean report for a well-formed blueprint', async () => { + const zip = createBlueprintZip({ + 'main.tf': ` +variable "manifest_id" { + type = string +} + +resource "epilot_journey" "journey_abc123def456abc123def456abc123de" { + lifecycle { + prevent_destroy = true + } + manifest = distinct([var.manifest_id]) + name = "My Journey" + access_mode = "PRIVATE" +} + +resource "epilot_automation" "auto_def456abc123def456abc123def456ab" { + name = "My Automation" + journey_id = epilot_journey.journey_abc123def456abc123def456abc123de.journey_id + depends_on = [epilot_journey.journey_abc123def456abc123def456abc123de] +}`, + }); + + const report = await validateBlueprint(zip); + expect(report.valid).toBe(true); + expect(report.summary.errors).toBe(0); + expect(report.summary.filesScanned).toBe(1); + expect(report.summary.resourcesFound).toBe(2); + expect(report.metadata.resourceTypes).toHaveProperty('epilot_journey'); + expect(report.metadata.resourceTypes).toHaveProperty('epilot_automation'); + }); + + it('detects dangling UUIDs in a broken blueprint', async () => { + const zip = createBlueprintZip({ + 'main.tf': ` +resource "epilot_automation" "auto_abc123def456abc123def456abc123de" { + journey_id = "d11995ae-368e-4ad3-bf1c-51d6449f8afc" +}`, + }); + + const report = await validateBlueprint(zip); + expect(report.valid).toBe(false); + expect(report.summary.errors).toBeGreaterThanOrEqual(1); + expect(report.issues.some((i) => i.ruleId === 'dangling-uuid')).toBe(true); + }); + + it('detects source org references', async () => { + const zip = createBlueprintZip({ + 'journey.tf': ` +resource "epilot_journey" "j1" { + org_id = "911690" + name = "Journey" +}`, + }); + + const report = await validateBlueprint(zip, { sourceOrgId: '911690' }); + expect(report.valid).toBe(false); + expect(report.issues.some((i) => i.ruleId === 'source-org-ref')).toBe(true); + expect(report.issues.some((i) => i.message.includes('matches source org'))).toBe(true); + }); + + it('detects tokens and secrets', async () => { + const zip = createBlueprintZip({ + 'webhook.tf': ` +resource "epilot_webhook" "wh1" { + token_id = "api_5ZugdRXasLfWBypHi93Fk" + url = "https://example.com/webhook" +}`, + }); + + const report = await validateBlueprint(zip); + expect(report.issues.some((i) => i.ruleId === 'token-detection')).toBe(true); + }); + + it('respects rule filtering', async () => { + const zip = createBlueprintZip({ + 'main.tf': ` +resource "epilot_automation" "auto_abc123def456abc123def456abc123de" { + journey_id = "d11995ae-368e-4ad3-bf1c-51d6449f8afc" + org_id = "911690" +}`, + }); + + // Only run dangling-uuid rule + const report = await validateBlueprint(zip, { rules: ['dangling-uuid'] }); + expect(report.issues.every((i) => i.ruleId === 'dangling-uuid')).toBe(true); + }); + + it('respects severity filtering', async () => { + const zip = createBlueprintZip({ + 'main.tf': ` +resource "epilot_webhook" "wh1" { + url = "https://sandbox-api.epilot.cloud/webhook" + secret = "my-secret-123456789" +}`, + }); + + // Only show errors (should filter out warnings and infos) + const report = await validateBlueprint(zip, { severity: 'error' }); + expect(report.issues.every((i) => i.severity === 'error')).toBe(true); + }); + + it('handles empty ZIP gracefully', async () => { + const zip = createBlueprintZip({}); + const report = await validateBlueprint(zip); + expect(report.valid).toBe(true); + expect(report.summary.filesScanned).toBe(0); + }); + + it('handles ZIP with no .tf files', async () => { + const zip = createBlueprintZip({ 'readme.md': '# Blueprint' }); + const report = await validateBlueprint(zip); + expect(report.valid).toBe(true); + expect(report.summary.filesScanned).toBe(0); + }); +}); diff --git a/packages/blueprint-tester/package.json b/packages/blueprint-tester/package.json new file mode 100644 index 00000000..7bb6f9be --- /dev/null +++ b/packages/blueprint-tester/package.json @@ -0,0 +1,53 @@ +{ + "name": "@epilot/blueprint-tester", + "version": "0.1.0", + "description": "Validation library for epilot blueprint exports — catches dangling UUIDs, leaked tokens, and org-specific references", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "scripts": { + "build": "tsup", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "epilot", + "blueprint", + "terraform", + "validation", + "testing" + ], + "author": "epilot GmbH", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/epilot-dev/sdk-js.git", + "directory": "packages/blueprint-tester" + }, + "dependencies": { + "adm-zip": "^0.5.16" + }, + "devDependencies": { + "@types/adm-zip": "^0.5.5", + "tsup": "^8.0.0", + "typescript": "^5.3.0", + "vitest": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ] +} diff --git a/packages/blueprint-tester/src/hcl-parser.ts b/packages/blueprint-tester/src/hcl-parser.ts new file mode 100644 index 00000000..f8ab7e96 --- /dev/null +++ b/packages/blueprint-tester/src/hcl-parser.ts @@ -0,0 +1,184 @@ +import type { ParsedTerraformFile, TerraformResource } from './types.js'; + +/** + * Lightweight HCL parser for epilot blueprint .tf files. + * + * Epilot blueprint .tf files follow predictable patterns from Speakeasy-generated + * Terraform providers. This parser extracts resource blocks, their attributes, + * and depends_on references without requiring a full HCL parser dependency. + * + * For each resource block, we extract: + * - type and name from the resource declaration + * - All attribute key-value pairs (including nested blocks as raw strings) + * - depends_on arrays + * - The raw HCL text and starting line number + */ + +/** Parse a .tf file into structured TerraformResource objects */ +export function parseTerraformFile(path: string, content: string): ParsedTerraformFile { + const resources = extractResourceBlocks(content, path); + const variables = extractVariables(content); + + return { path, resources, variables, rawContent: content }; +} + +/** Extract all `resource "type" "name" { ... }` blocks from HCL content */ +function extractResourceBlocks(content: string, filePath: string): TerraformResource[] { + const resources: TerraformResource[] = []; + const lines = content.split('\n'); + + // Match: resource "epilot_journey" "journey_abc123" { + const resourceStartRegex = /^resource\s+"([^"]+)"\s+"([^"]+)"\s*\{/; + + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(resourceStartRegex); + if (!match) continue; + + const type = match[1]; + const name = match[2]; + const lineStart = i + 1; // 1-indexed + + // Find the closing brace for this resource block + const blockEnd = findClosingBrace(lines, i); + const rawHcl = lines.slice(i, blockEnd + 1).join('\n'); + const blockContent = lines.slice(i + 1, blockEnd).join('\n'); + + const attributes = parseAttributes(blockContent); + const dependsOn = extractDependsOn(blockContent); + + resources.push({ + type, + name, + address: `${type}.${name}`, + attributes, + dependsOn, + rawHcl, + file: filePath, + lineStart, + }); + } + + return resources; +} + +/** Find the line index of the closing brace that matches the opening brace at startLine */ +function findClosingBrace(lines: string[], startLine: number): number { + let depth = 0; + for (let i = startLine; i < lines.length; i++) { + for (const char of lines[i]) { + if (char === '{') depth++; + else if (char === '}') { + depth--; + if (depth === 0) return i; + } + } + } + return lines.length - 1; +} + +/** Parse attribute key-value pairs from the body of a resource block */ +function parseAttributes(blockContent: string): Record { + const attrs: Record = {}; + const lines = blockContent.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + // Skip comments, empty lines, lifecycle blocks, and closing braces + if (!line || line.startsWith('#') || line.startsWith('//') || line === '}') continue; + + // Handle simple key = value assignments + const kvMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.+)$/); + if (kvMatch) { + const key = kvMatch[1]; + const rawValue = kvMatch[2].trim(); + attrs[key] = parseValue(rawValue, lines, i); + continue; + } + + // Handle nested block: `blockname {` — store as raw string + const blockMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*\{/); + if (blockMatch) { + const key = blockMatch[1]; + const blockEnd = findClosingBraceInBlock(lines, i); + const nested = lines.slice(i, blockEnd + 1).join('\n'); + // If key already exists (repeated blocks), convert to array + if (key in attrs) { + const existing = attrs[key]; + if (Array.isArray(existing)) { + existing.push(nested); + } else { + attrs[key] = [existing, nested]; + } + } else { + attrs[key] = nested; + } + i = blockEnd; + } + } + + return attrs; +} + +/** Parse a value string from HCL */ +function parseValue(raw: string, _lines: string[], _lineIndex: number): unknown { + // String value: "something" + if (raw.startsWith('"') && raw.endsWith('"')) { + return raw.slice(1, -1); + } + // Boolean + if (raw === 'true') return true; + if (raw === 'false') return false; + // null + if (raw === 'null') return null; + // Number + if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw); + // Array: [...] + if (raw.startsWith('[') && raw.endsWith(']')) { + return raw; // Keep as raw string for scanning + } + // Function calls like jsonencode(...), distinct(...) + if (/^[a-zA-Z_]+\(/.test(raw)) { + return raw; + } + // Terraform reference (bare): epilot_journey.name.attr + return raw; +} + +/** Find closing brace for a nested block starting at given index within block lines */ +function findClosingBraceInBlock(lines: string[], startIndex: number): number { + let depth = 0; + for (let i = startIndex; i < lines.length; i++) { + for (const char of lines[i]) { + if (char === '{') depth++; + else if (char === '}') { + depth--; + if (depth === 0) return i; + } + } + } + return lines.length - 1; +} + +/** Extract depends_on array from block content */ +function extractDependsOn(blockContent: string): string[] { + const match = blockContent.match(/depends_on\s*=\s*\[([^\]]*)\]/); + if (!match) return []; + + return match[1] + .split(',') + .map((s) => s.trim().replace(/"/g, '')) + .filter(Boolean); +} + +/** Extract variable blocks from .tf content */ +function extractVariables(content: string): Record { + const vars: Record = {}; + const varRegex = /variable\s+"([^"]+)"\s*\{/g; + + for (const match of content.matchAll(varRegex)) { + vars[match[1]] = true; + } + + return vars; +} diff --git a/packages/blueprint-tester/src/index.ts b/packages/blueprint-tester/src/index.ts new file mode 100644 index 00000000..8438cc42 --- /dev/null +++ b/packages/blueprint-tester/src/index.ts @@ -0,0 +1,13 @@ +export { validateBlueprint, BlueprintValidator } from './validator.js'; +export { formatReport, formatReportJson } from './report-formatter.js'; +export type { + ValidationReport, + ValidationIssue, + ValidatorOptions, + Severity, + ValidationRule, + ValidationContext, + TerraformResource, + ParsedTerraformFile, + ResourceIndex, +} from './types.js'; diff --git a/packages/blueprint-tester/src/report-formatter.ts b/packages/blueprint-tester/src/report-formatter.ts new file mode 100644 index 00000000..e3b5a7b9 --- /dev/null +++ b/packages/blueprint-tester/src/report-formatter.ts @@ -0,0 +1,73 @@ +import type { Severity, ValidationReport } from './types.js'; + +const SEVERITY_ORDER: Record = { error: 0, warning: 1, info: 2 }; + +/** Format a validation report as human-readable text */ +export function formatReport(report: ValidationReport): string { + const lines: string[] = []; + + lines.push(''); + lines.push(`Blueprint Validation Report`); + lines.push('='.repeat(40)); + lines.push(''); + + // Summary + lines.push(`Files scanned: ${report.summary.filesScanned}`); + lines.push(`Resources found: ${report.summary.resourcesFound}`); + lines.push(''); + + if (report.issues.length === 0) { + lines.push('No issues found. Blueprint looks clean!'); + lines.push(''); + return lines.join('\n'); + } + + // Group by severity + const sorted = [...report.issues].sort( + (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity], + ); + + const grouped = new Map(); + for (const issue of sorted) { + const group = grouped.get(issue.severity) ?? []; + group.push(issue); + grouped.set(issue.severity, group); + } + + for (const severity of ['error', 'warning', 'info'] as Severity[]) { + const group = grouped.get(severity); + if (!group || group.length === 0) continue; + + const label = severity === 'error' ? 'ERRORS' : severity === 'warning' ? 'WARNINGS' : 'INFO'; + lines.push(`${label} (${group.length})`); + lines.push('-'.repeat(30)); + + for (const issue of group) { + const location = issue.line ? `${issue.file}:${issue.line}` : issue.file; + const resource = issue.resourceAddress ? ` [${issue.resourceAddress}]` : ''; + lines.push(` ${location}${resource}`); + lines.push(` ${issue.message}`); + if (issue.attributePath) { + lines.push(` attribute: ${issue.attributePath}`); + } + lines.push(''); + } + } + + // Summary line + const parts: string[] = []; + if (report.summary.errors > 0) parts.push(`${report.summary.errors} error(s)`); + if (report.summary.warnings > 0) parts.push(`${report.summary.warnings} warning(s)`); + if (report.summary.infos > 0) parts.push(`${report.summary.infos} info(s)`); + + lines.push('-'.repeat(30)); + lines.push(`Result: ${report.valid ? 'PASS' : 'FAIL'} — ${parts.join(', ')}`); + lines.push(''); + + return lines.join('\n'); +} + +/** Format a validation report as JSON string */ +export function formatReportJson(report: ValidationReport): string { + return JSON.stringify(report, null, 2); +} diff --git a/packages/blueprint-tester/src/rules/cross-ref-integrity.ts b/packages/blueprint-tester/src/rules/cross-ref-integrity.ts new file mode 100644 index 00000000..f556b671 --- /dev/null +++ b/packages/blueprint-tester/src/rules/cross-ref-integrity.ts @@ -0,0 +1,85 @@ +import type { ValidationContext, ValidationIssue, ValidationRule } from '../types.js'; +import { extractReferencedAddresses } from '../utils/terraform-refs.js'; + +export const crossRefIntegrityRule: ValidationRule = { + id: 'cross-ref-integrity', + name: 'Cross-Reference Integrity', + description: 'Ensures all depends_on and resource references point to resources that exist in the blueprint', + severity: 'error', + + validate(context: ValidationContext): ValidationIssue[] { + const issues: ValidationIssue[] = []; + const { resourceIndex } = context; + + for (const file of context.files) { + for (const resource of file.resources) { + // Check depends_on references + for (const dep of resource.dependsOn) { + if (!resourceIndex.allAddresses.has(dep)) { + issues.push({ + ruleId: 'cross-ref-integrity', + severity: 'error', + message: `depends_on references "${dep}" which does not exist in the blueprint. The dependency was likely not exported.`, + file: resource.file, + line: resource.lineStart, + resourceAddress: resource.address, + attributePath: 'depends_on', + value: dep, + }); + } + } + + // Check terraform references in attribute values + scanForReferences(resource.attributes, '', (path, value) => { + if (typeof value !== 'string') return; + + const addresses = extractReferencedAddresses(value); + for (const addr of addresses) { + // Skip variable references (var.xxx) + if (addr.startsWith('var.')) continue; + // Skip data source references (data.xxx) + if (addr.startsWith('data.')) continue; + + if (!resourceIndex.allAddresses.has(addr)) { + issues.push({ + ruleId: 'cross-ref-integrity', + severity: 'error', + message: `References resource "${addr}" which does not exist in the blueprint. The referenced resource was likely not exported.`, + file: resource.file, + line: resource.lineStart, + resourceAddress: resource.address, + attributePath: path, + value: addr, + }); + } + } + }); + } + } + + return issues; + }, +}; + +function scanForReferences( + attrs: Record, + prefix: string, + callback: (path: string, value: unknown) => void, +): void { + for (const [key, value] of Object.entries(attrs)) { + const path = prefix ? `${prefix}.${key}` : key; + if (key === 'lifecycle' || key === 'depends_on') continue; + + if (typeof value === 'string') { + callback(path, value); + } else if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + if (typeof value[i] === 'string') { + callback(`${path}[${i}]`, value[i]); + } + } + } else if (typeof value === 'object' && value !== null) { + scanForReferences(value as Record, path, callback); + } + } +} diff --git a/packages/blueprint-tester/src/rules/dangling-uuids.ts b/packages/blueprint-tester/src/rules/dangling-uuids.ts new file mode 100644 index 00000000..b0415b8c --- /dev/null +++ b/packages/blueprint-tester/src/rules/dangling-uuids.ts @@ -0,0 +1,81 @@ +import type { ValidationContext, ValidationIssue, ValidationRule } from '../types.js'; +import { containsTerraformRef, isReferenceExpression, isVariableReference } from '../utils/terraform-refs.js'; +import { extractUuids, isLocalUuid } from '../utils/uuid.js'; + +export const danglingUuidsRule: ValidationRule = { + id: 'dangling-uuid', + name: 'Dangling UUID Detection', + description: 'Detects hardcoded UUIDs in attribute values that are not Terraform references and do not match any resource in the blueprint', + severity: 'error', + + validate(context: ValidationContext): ValidationIssue[] { + const issues: ValidationIssue[] = []; + const { resourceIndex, options } = context; + const safeUuids = new Set((options.knownSafeUuids ?? []).map((u) => u.toLowerCase())); + + for (const file of context.files) { + for (const resource of file.resources) { + scanAttributeValues(resource.attributes, '', (path, value) => { + if (typeof value !== 'string') return; + + // Skip if the entire value is a terraform reference + if (isReferenceExpression(value)) return; + if (isVariableReference(value)) return; + // Skip if value contains terraform interpolations (partially resolved is ok) + if (containsTerraformRef(value)) return; + + const uuids = extractUuids(value); + for (const uuid of uuids) { + const lower = uuid.toLowerCase(); + + // Safe: UUID belongs to a resource in this blueprint + if (isLocalUuid(lower, resourceIndex.allUuidsInIdentifiers)) continue; + // Safe: explicitly allowlisted + if (safeUuids.has(lower)) continue; + + issues.push({ + ruleId: 'dangling-uuid', + severity: 'error', + message: `UUID "${uuid}" in attribute "${path}" is not a Terraform reference and does not match any resource in the blueprint. This likely references a resource in the source org that was not exported.`, + file: resource.file, + line: resource.lineStart, + resourceAddress: resource.address, + attributePath: path, + value: uuid, + }); + } + }); + } + } + + return issues; + }, +}; + +/** Recursively walk attribute values, calling callback for each leaf value */ +function scanAttributeValues( + attrs: Record, + prefix: string, + callback: (path: string, value: unknown) => void, +): void { + for (const [key, value] of Object.entries(attrs)) { + const path = prefix ? `${prefix}.${key}` : key; + + // Skip lifecycle and depends_on — not user-facing attributes + if (key === 'lifecycle' || key === 'depends_on') continue; + + if (typeof value === 'string') { + callback(path, value); + } else if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + if (typeof value[i] === 'string') { + callback(`${path}[${i}]`, value[i]); + } else if (typeof value[i] === 'object' && value[i] !== null) { + scanAttributeValues(value[i] as Record, `${path}[${i}]`, callback); + } + } + } else if (typeof value === 'object' && value !== null) { + scanAttributeValues(value as Record, path, callback); + } + } +} diff --git a/packages/blueprint-tester/src/rules/email-addresses.ts b/packages/blueprint-tester/src/rules/email-addresses.ts new file mode 100644 index 00000000..9e6f524d --- /dev/null +++ b/packages/blueprint-tester/src/rules/email-addresses.ts @@ -0,0 +1,49 @@ +import type { ValidationContext, ValidationIssue, ValidationRule } from '../types.js'; + +const EMAIL_REGEX = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g; + +/** Email patterns that are safe/expected in blueprint files */ +const SAFE_PATTERNS = [ + /^noreply@/i, + /^no-reply@/i, + /^manifest@epilot/i, + /^support@epilot/i, + /^\{\{.*\}\}$/, // Template variables like {{email}} +]; + +export const emailAddressesRule: ValidationRule = { + id: 'email-address', + name: 'Hardcoded Email Address Detection', + description: 'Flags literal email addresses in templates and configs that may be source-org specific', + severity: 'info', + + validate(context: ValidationContext): ValidationIssue[] { + const issues: ValidationIssue[] = []; + + for (const file of context.files) { + for (const resource of file.resources) { + EMAIL_REGEX.lastIndex = 0; + const matches = [...resource.rawHcl.matchAll(EMAIL_REGEX)]; + + for (const match of matches) { + const email = match[0]; + + // Skip safe patterns + if (SAFE_PATTERNS.some((p) => p.test(email))) continue; + + issues.push({ + ruleId: 'email-address', + severity: 'info', + message: `Found email address "${email}". This may be specific to the source org and need updating after install.`, + file: resource.file, + line: resource.lineStart, + resourceAddress: resource.address, + value: email, + }); + } + } + } + + return issues; + }, +}; diff --git a/packages/blueprint-tester/src/rules/environment-urls.ts b/packages/blueprint-tester/src/rules/environment-urls.ts new file mode 100644 index 00000000..10cab2e7 --- /dev/null +++ b/packages/blueprint-tester/src/rules/environment-urls.ts @@ -0,0 +1,49 @@ +import type { ValidationContext, ValidationIssue, ValidationRule } from '../types.js'; + +interface UrlPattern { + name: string; + regex: RegExp; +} + +const ENV_URL_PATTERNS: UrlPattern[] = [ + { name: 'sandbox URL', regex: /sandbox[.\-_][a-z]+\.epilot\.cloud/gi }, + { name: 'staging URL', regex: /staging[.\-_][a-z]+\.epilot\.cloud/gi }, + { name: 'dev URL', regex: /dev[.\-_][a-z]+\.epilot\.cloud/gi }, + { name: 'localhost URL', regex: /(?:localhost|127\.0\.0\.1)(?::\d+)?/g }, + { name: 'org-specific S3 path', regex: /(?:s3:\/\/|amazonaws\.com\/)[^\s"']*\d{5,}[^\s"']*/g }, + { name: 'internal service URL', regex: /https?:\/\/[a-z-]+\.sls\.epilot\.io/g }, +]; + +export const environmentUrlsRule: ValidationRule = { + id: 'environment-url', + name: 'Environment-Specific URL Detection', + description: 'Detects sandbox, staging, dev URLs, and org-specific S3 paths that should not transfer between orgs', + severity: 'info', + + validate(context: ValidationContext): ValidationIssue[] { + const issues: ValidationIssue[] = []; + + for (const file of context.files) { + for (const resource of file.resources) { + for (const pattern of ENV_URL_PATTERNS) { + pattern.regex.lastIndex = 0; + const matches = [...resource.rawHcl.matchAll(pattern.regex)]; + + for (const match of matches) { + issues.push({ + ruleId: 'environment-url', + severity: 'info', + message: `Found ${pattern.name}: "${match[0]}". This may be environment-specific and could need updating in the target org.`, + file: resource.file, + line: resource.lineStart, + resourceAddress: resource.address, + value: match[0], + }); + } + } + } + } + + return issues; + }, +}; diff --git a/packages/blueprint-tester/src/rules/incomplete-webhooks.ts b/packages/blueprint-tester/src/rules/incomplete-webhooks.ts new file mode 100644 index 00000000..35b91f45 --- /dev/null +++ b/packages/blueprint-tester/src/rules/incomplete-webhooks.ts @@ -0,0 +1,109 @@ +import type { ValidationContext, ValidationIssue, ValidationRule } from '../types.js'; +import { isReferenceExpression, isVariableReference } from '../utils/terraform-refs.js'; + +const WEBHOOK_TYPES = new Set([ + 'epilot_webhook', + 'epilot-webhooks_webhook', +]); + +export const incompleteWebhooksRule: ValidationRule = { + id: 'incomplete-webhook', + name: 'Incomplete Webhook Detection', + description: 'Flags webhooks with hardcoded URLs or embedded auth credentials that will break in the target org', + severity: 'warning', + + validate(context: ValidationContext): ValidationIssue[] { + const issues: ValidationIssue[] = []; + + for (const file of context.files) { + for (const resource of file.resources) { + if (!WEBHOOK_TYPES.has(resource.type)) continue; + + // Check for hardcoded URL + checkForHardcodedUrl(resource, issues); + + // Check for embedded auth in raw HCL + checkForEmbeddedAuth(resource, issues); + } + } + + // Also check automation resources that contain webhook actions + for (const file of context.files) { + for (const resource of file.resources) { + if (!resource.type.includes('automation')) continue; + checkAutomationWebhookActions(resource, issues); + } + } + + return issues; + }, +}; + +function checkForHardcodedUrl(resource: import('../types.js').TerraformResource, issues: ValidationIssue[]): void { + for (const [key, value] of Object.entries(resource.attributes)) { + if (key !== 'url' && key !== 'webhook_url' && key !== 'endpoint') continue; + if (typeof value !== 'string') continue; + if (isReferenceExpression(value)) continue; + if (isVariableReference(value)) continue; + + if (value.startsWith('http://') || value.startsWith('https://')) { + issues.push({ + ruleId: 'incomplete-webhook', + severity: 'warning', + message: `Webhook has hardcoded URL "${truncateUrl(value)}". This URL is specific to the source org and will likely need to be updated after install.`, + file: resource.file, + line: resource.lineStart, + resourceAddress: resource.address, + attributePath: key, + value: truncateUrl(value), + }); + } + } +} + +function checkForEmbeddedAuth(resource: import('../types.js').TerraformResource, issues: ValidationIssue[]): void { + const authPatterns = [ + /oauth_secret["'\s]*[:=]["'\s]*["'][^"']+["']/i, + /auth_token["'\s]*[:=]["'\s]*["'][^"']+["']/i, + /api_key["'\s]*[:=]["'\s]*["'][^"']+["']/i, + ]; + + for (const pattern of authPatterns) { + if (pattern.test(resource.rawHcl)) { + issues.push({ + ruleId: 'incomplete-webhook', + severity: 'warning', + message: 'Webhook contains embedded authentication credentials. These should be configured manually after install or use environment variables.', + file: resource.file, + line: resource.lineStart, + resourceAddress: resource.address, + }); + break; // One auth warning per webhook is enough + } + } +} + +function checkAutomationWebhookActions(resource: import('../types.js').TerraformResource, issues: ValidationIssue[]): void { + // Check raw HCL for webhook-related actions with hardcoded URLs + const webhookUrlInAction = resource.rawHcl.match(/(?:webhook_url|url)["'\s]*[:=]["'\s]*["'](https?:\/\/[^"']+)["']/g); + if (webhookUrlInAction) { + for (const match of webhookUrlInAction) { + const urlMatch = match.match(/(https?:\/\/[^"']+)/); + if (urlMatch) { + issues.push({ + ruleId: 'incomplete-webhook', + severity: 'warning', + message: `Automation contains hardcoded webhook URL "${truncateUrl(urlMatch[1])}". This may need to be updated for the target org.`, + file: resource.file, + line: resource.lineStart, + resourceAddress: resource.address, + value: truncateUrl(urlMatch[1]), + }); + } + } + } +} + +function truncateUrl(url: string): string { + return url.length > 60 ? `${url.slice(0, 57)}...` : url; +} diff --git a/packages/blueprint-tester/src/rules/index.ts b/packages/blueprint-tester/src/rules/index.ts new file mode 100644 index 00000000..ba8154f6 --- /dev/null +++ b/packages/blueprint-tester/src/rules/index.ts @@ -0,0 +1,32 @@ +import type { ValidationRule } from '../types.js'; +import { crossRefIntegrityRule } from './cross-ref-integrity.js'; +import { danglingUuidsRule } from './dangling-uuids.js'; +import { emailAddressesRule } from './email-addresses.js'; +import { environmentUrlsRule } from './environment-urls.js'; +import { incompleteWebhooksRule } from './incomplete-webhooks.js'; +import { publicJourneySafetyRule } from './public-journey-safety.js'; +import { sourceOrgRefsRule } from './source-org-refs.js'; +import { tokenDetectionRule } from './token-detection.js'; + +/** All built-in validation rules in priority order */ +export const allRules: ValidationRule[] = [ + danglingUuidsRule, + sourceOrgRefsRule, + crossRefIntegrityRule, + tokenDetectionRule, + publicJourneySafetyRule, + incompleteWebhooksRule, + environmentUrlsRule, + emailAddressesRule, +]; + +export { + danglingUuidsRule, + sourceOrgRefsRule, + crossRefIntegrityRule, + tokenDetectionRule, + publicJourneySafetyRule, + incompleteWebhooksRule, + environmentUrlsRule, + emailAddressesRule, +}; diff --git a/packages/blueprint-tester/src/rules/public-journey-safety.ts b/packages/blueprint-tester/src/rules/public-journey-safety.ts new file mode 100644 index 00000000..5dd93ebd --- /dev/null +++ b/packages/blueprint-tester/src/rules/public-journey-safety.ts @@ -0,0 +1,89 @@ +import type { ValidationContext, ValidationIssue, ValidationRule } from '../types.js'; +import { isReferenceExpression, isVariableReference } from '../utils/terraform-refs.js'; +import { extractUuids } from '../utils/uuid.js'; + +const JOURNEY_TYPES = new Set([ + 'epilot_journey', + 'epilot-journey_journey', +]); + +/** Attribute keys in journey resources that reference other resources and must be terraform refs */ +const CRITICAL_REF_KEYS = new Set([ + 'mappings_automation_id', + 'design_id', + 'automation_id', +]); + +export const publicJourneySafetyRule: ValidationRule = { + id: 'public-journey-safety', + name: 'Public Journey Safety', + description: 'Checks that public journeys have all critical references properly terraformed to prevent wrong-org submissions', + severity: 'warning', + + validate(context: ValidationContext): ValidationIssue[] { + const issues: ValidationIssue[] = []; + + for (const file of context.files) { + for (const resource of file.resources) { + if (!JOURNEY_TYPES.has(resource.type)) continue; + + const isPublic = isPublicJourney(resource.attributes, resource.rawHcl); + if (!isPublic) continue; + + // Check critical reference attributes + checkCriticalRefs(resource, issues); + + // Check for hardcoded organization_id in the journey body + checkOrgIdInJourney(resource, issues); + } + } + + return issues; + }, +}; + +function isPublicJourney(attrs: Record, rawHcl: string): boolean { + if (attrs.access_mode === 'PUBLIC') return true; + // Also check in raw HCL for nested access_mode within jsonencode + if (/access_mode["'\s]*[:=]["'\s]*["']?PUBLIC/i.test(rawHcl)) return true; + return false; +} + +function checkCriticalRefs(resource: import('../types.js').TerraformResource, issues: ValidationIssue[]): void { + for (const [key, value] of Object.entries(resource.attributes)) { + if (!CRITICAL_REF_KEYS.has(key)) continue; + if (typeof value !== 'string') continue; + if (isReferenceExpression(value)) continue; + if (isVariableReference(value)) continue; + + const uuids = extractUuids(value); + if (uuids.length > 0) { + issues.push({ + ruleId: 'public-journey-safety', + severity: 'warning', + message: `Public journey has hardcoded "${key}" = "${value}". This should be a Terraform reference to prevent submissions routing to the source org.`, + file: resource.file, + line: resource.lineStart, + resourceAddress: resource.address, + attributePath: key, + value, + }); + } + } +} + +function checkOrgIdInJourney(resource: import('../types.js').TerraformResource, issues: ValidationIssue[]): void { + const orgIdMatch = resource.rawHcl.match(/organization_id["'\s]*[:=]["'\s]*["']?(\d{4,10})/); + if (orgIdMatch) { + issues.push({ + ruleId: 'public-journey-safety', + severity: 'warning', + message: `Public journey contains hardcoded organization_id "${orgIdMatch[1]}". Submissions may be routed to the source org instead of the target.`, + file: resource.file, + line: resource.lineStart, + resourceAddress: resource.address, + attributePath: 'organization_id', + value: orgIdMatch[1], + }); + } +} diff --git a/packages/blueprint-tester/src/rules/source-org-refs.ts b/packages/blueprint-tester/src/rules/source-org-refs.ts new file mode 100644 index 00000000..7d29c0fa --- /dev/null +++ b/packages/blueprint-tester/src/rules/source-org-refs.ts @@ -0,0 +1,91 @@ +import type { ValidationContext, ValidationIssue, ValidationRule } from '../types.js'; +import { isReferenceExpression, isVariableReference } from '../utils/terraform-refs.js'; + +/** Attribute keys that typically hold org IDs */ +const ORG_ID_KEYS = new Set(['org_id', 'organization_id', 'source_org_id', 'target_org_id']); + +/** Pattern for numeric org IDs (epilot org IDs are typically 5-10 digit numbers) */ +const NUMERIC_ORG_ID_REGEX = /^\d{4,10}$/; + +export const sourceOrgRefsRule: ValidationRule = { + id: 'source-org-ref', + name: 'Source Org Reference Detection', + description: 'Detects hardcoded organization IDs that should be Terraform variables', + severity: 'error', + + validate(context: ValidationContext): ValidationIssue[] { + const issues: ValidationIssue[] = []; + const { options } = context; + + for (const file of context.files) { + for (const resource of file.resources) { + checkAttributes(resource.attributes, '', (path, key, value) => { + if (typeof value !== 'string') return; + if (isReferenceExpression(value)) return; + if (isVariableReference(value)) return; + + // Check if the key is an org_id field with a hardcoded value + if (ORG_ID_KEYS.has(key) && NUMERIC_ORG_ID_REGEX.test(value)) { + const isSourceOrg = options.sourceOrgId && value === options.sourceOrgId; + issues.push({ + ruleId: 'source-org-ref', + severity: 'error', + message: `Hardcoded organization ID "${value}" in "${path}"${isSourceOrg ? ' (matches source org)' : ''}. This should be a Terraform variable reference like var.target_org_id.`, + file: resource.file, + line: resource.lineStart, + resourceAddress: resource.address, + attributePath: path, + value, + }); + } + }); + + // Also scan raw HCL for org_id patterns in jsonencode blocks + const orgIdInJson = resource.rawHcl.match(/"org(?:anization)?_id"\s*[:=]\s*"?(\d{4,10})"?/g); + if (orgIdInJson) { + for (const match of orgIdInJson) { + const valueMatch = match.match(/(\d{4,10})/); + if (valueMatch) { + issues.push({ + ruleId: 'source-org-ref', + severity: 'error', + message: `Hardcoded organization ID "${valueMatch[1]}" found in resource body. This should not transfer between orgs.`, + file: resource.file, + line: resource.lineStart, + resourceAddress: resource.address, + value: valueMatch[1], + }); + } + } + } + } + } + + return deduplicateIssues(issues); + }, +}; + +function checkAttributes( + attrs: Record, + prefix: string, + callback: (path: string, key: string, value: unknown) => void, +): void { + for (const [key, value] of Object.entries(attrs)) { + const path = prefix ? `${prefix}.${key}` : key; + if (typeof value === 'string') { + callback(path, key, value); + } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + checkAttributes(value as Record, path, callback); + } + } +} + +function deduplicateIssues(issues: ValidationIssue[]): ValidationIssue[] { + const seen = new Set(); + return issues.filter((issue) => { + const key = `${issue.resourceAddress}:${issue.value}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} diff --git a/packages/blueprint-tester/src/rules/token-detection.ts b/packages/blueprint-tester/src/rules/token-detection.ts new file mode 100644 index 00000000..d2438fbb --- /dev/null +++ b/packages/blueprint-tester/src/rules/token-detection.ts @@ -0,0 +1,57 @@ +import type { ValidationContext, ValidationIssue, ValidationRule } from '../types.js'; + +interface TokenPattern { + name: string; + regex: RegExp; +} + +const TOKEN_PATTERNS: TokenPattern[] = [ + { name: 'API key (api_ prefix)', regex: /api_[A-Za-z0-9]{15,}/g }, + { name: 'Stripe secret key', regex: /sk_live_[A-Za-z0-9]+/g }, + { name: 'Stripe publishable key', regex: /pk_live_[A-Za-z0-9]+/g }, + { name: 'Bearer token', regex: /Bearer\s+[A-Za-z0-9\-._~+/]{20,}=*/g }, + { name: 'OAuth client secret', regex: /client_secret["'\s:=]+["']?[A-Za-z0-9\-._~+/]{15,}/g }, + { name: 'Authorization header value', regex: /[Aa]uthorization["'\s:=]+["']?(?:Bearer|Basic|Token)\s+[A-Za-z0-9\-._~+/]{10,}/g }, + { name: 'Token in URL query param', regex: /[?&](?:token|key|api_key|access_token|secret)=[A-Za-z0-9\-._~+/]{10,}/g }, + { name: 'AWS access key', regex: /AKIA[A-Z0-9]{16}/g }, + { name: 'Generic secret assignment', regex: /(?:secret|password|passwd|api_key|apikey)["'\s]*[:=]["'\s]*["'][^"']{8,}["']/gi }, +]; + +export const tokenDetectionRule: ValidationRule = { + id: 'token-detection', + name: 'Token/Secret Detection', + description: 'Scans for API keys, bearer tokens, OAuth secrets, and other credentials that should not be in blueprints', + severity: 'warning', + + validate(context: ValidationContext): ValidationIssue[] { + const issues: ValidationIssue[] = []; + + for (const file of context.files) { + for (const resource of file.resources) { + for (const pattern of TOKEN_PATTERNS) { + // Reset regex state + pattern.regex.lastIndex = 0; + const matches = [...resource.rawHcl.matchAll(pattern.regex)]; + + for (const match of matches) { + const rawValue = match[0]; + // Truncate for safety: show first 8 chars + *** + const truncated = rawValue.length > 12 ? `${rawValue.slice(0, 12)}***` : rawValue; + + issues.push({ + ruleId: 'token-detection', + severity: 'warning', + message: `Possible ${pattern.name} detected: "${truncated}". Credentials should not be included in blueprints.`, + file: resource.file, + line: resource.lineStart, + resourceAddress: resource.address, + value: truncated, + }); + } + } + } + } + + return issues; + }, +}; diff --git a/packages/blueprint-tester/src/types.ts b/packages/blueprint-tester/src/types.ts new file mode 100644 index 00000000..a9ca4b17 --- /dev/null +++ b/packages/blueprint-tester/src/types.ts @@ -0,0 +1,81 @@ +export type Severity = 'error' | 'warning' | 'info'; + +export interface ValidationIssue { + ruleId: string; + severity: Severity; + message: string; + file: string; + line?: number; + resourceAddress?: string; + attributePath?: string; + /** The offending value (truncated for secrets) */ + value?: string; +} + +export interface ValidationReport { + valid: boolean; + summary: { + errors: number; + warnings: number; + infos: number; + filesScanned: number; + resourcesFound: number; + }; + issues: ValidationIssue[]; + metadata: { + validatedAt: string; + blueprintFiles: string[]; + resourceTypes: Record; + }; +} + +export interface ValidatorOptions { + /** Specific rule IDs to run (default: all) */ + rules?: string[]; + /** Minimum severity to report (default: 'info') */ + severity?: Severity; + /** UUIDs to skip in dangling check */ + knownSafeUuids?: string[]; + /** If known, flag this org_id specifically */ + sourceOrgId?: string; +} + +export interface TerraformResource { + type: string; + name: string; + address: string; + attributes: Record; + dependsOn: string[]; + rawHcl: string; + file: string; + lineStart: number; +} + +export interface ParsedTerraformFile { + path: string; + resources: TerraformResource[]; + variables: Record; + rawContent: string; +} + +export interface ValidationRule { + id: string; + name: string; + description: string; + severity: Severity; + validate(context: ValidationContext): ValidationIssue[]; +} + +export interface ValidationContext { + files: ParsedTerraformFile[]; + resourceIndex: ResourceIndex; + options: ValidatorOptions; +} + +export interface ResourceIndex { + byAddress: Map; + byType: Map; + allAddresses: Set; + /** UUIDs embedded in resource identifier names */ + allUuidsInIdentifiers: Set; +} diff --git a/packages/blueprint-tester/src/utils/resource-index.ts b/packages/blueprint-tester/src/utils/resource-index.ts new file mode 100644 index 00000000..22399bf8 --- /dev/null +++ b/packages/blueprint-tester/src/utils/resource-index.ts @@ -0,0 +1,29 @@ +import type { ParsedTerraformFile, ResourceIndex, TerraformResource } from '../types.js'; +import { extractUuidFromResourceName } from './uuid.js'; + +/** Build a searchable index of all resources across all parsed .tf files */ +export function buildResourceIndex(files: ParsedTerraformFile[]): ResourceIndex { + const byAddress = new Map(); + const byType = new Map(); + const allAddresses = new Set(); + const allUuidsInIdentifiers = new Set(); + + for (const file of files) { + for (const resource of file.resources) { + byAddress.set(resource.address, resource); + allAddresses.add(resource.address); + + const existing = byType.get(resource.type) ?? []; + existing.push(resource); + byType.set(resource.type, existing); + + // Extract UUID from the resource name (e.g., "journey_abc123def456...") + const uuid = extractUuidFromResourceName(resource.name); + if (uuid) { + allUuidsInIdentifiers.add(uuid.toLowerCase()); + } + } + } + + return { byAddress, byType, allAddresses, allUuidsInIdentifiers }; +} diff --git a/packages/blueprint-tester/src/utils/terraform-refs.ts b/packages/blueprint-tester/src/utils/terraform-refs.ts new file mode 100644 index 00000000..28b21ad2 --- /dev/null +++ b/packages/blueprint-tester/src/utils/terraform-refs.ts @@ -0,0 +1,59 @@ +/** + * Terraform reference patterns in HCL. + * + * In the parsed JSON from hcl2json, terraform references appear as: + * - "${epilot_journey.name.attr}" (interpolation in strings) + * - "epilot_journey.name.attr" (bare references in certain contexts) + * + * After HCL-to-JSON conversion, references appear as string expressions + * like "${epilot_type.name.attribute}". + */ + +/** Matches terraform interpolation expressions like ${resource.name.attr} */ +const TF_INTERPOLATION_REGEX = /\$\{([^}]+)\}/g; + +/** Matches a terraform resource reference like epilot_journey.sample.journey_id */ +const TF_RESOURCE_REF_REGEX = /^[a-zA-Z_][a-zA-Z0-9_-]*\.[a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*$/; + +/** Check if a value contains terraform reference expressions */ +export function containsTerraformRef(value: string): boolean { + return TF_INTERPOLATION_REGEX.test(value) || TF_RESOURCE_REF_REGEX.test(value); +} + +/** Extract terraform resource addresses from a value's references */ +export function extractReferencedAddresses(value: string): string[] { + const addresses: string[] = []; + + // Check interpolations: ${type.name.attr} + for (const match of value.matchAll(TF_INTERPOLATION_REGEX)) { + const expr = match[1].trim(); + const parts = expr.split('.'); + if (parts.length >= 2) { + addresses.push(`${parts[0]}.${parts[1]}`); + } + } + + // Check bare reference + if (TF_RESOURCE_REF_REGEX.test(value.trim())) { + const parts = value.trim().split('.'); + addresses.push(`${parts[0]}.${parts[1]}`); + } + + return [...new Set(addresses)]; +} + +/** Check if a string value is purely a terraform reference (not a literal) */ +export function isReferenceExpression(value: string): boolean { + const trimmed = value.trim(); + // Bare reference + if (TF_RESOURCE_REF_REGEX.test(trimmed)) return true; + // Pure interpolation: "${resource.name.attr}" + if (/^\$\{[^}]+\}$/.test(trimmed)) return true; + return false; +} + +/** Check if value references a terraform variable like var.manifest_id */ +export function isVariableReference(value: string): boolean { + const trimmed = value.trim(); + return trimmed.startsWith('var.') || trimmed.includes('${var.'); +} diff --git a/packages/blueprint-tester/src/utils/uuid.ts b/packages/blueprint-tester/src/utils/uuid.ts new file mode 100644 index 00000000..eec0ed72 --- /dev/null +++ b/packages/blueprint-tester/src/utils/uuid.ts @@ -0,0 +1,26 @@ +/** Matches UUID v4 format (case-insensitive) */ +const UUID_REGEX = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi; + +/** Matches UUID without dashes (as used in terraform resource names) */ +const UUID_NO_DASH_REGEX = /[0-9a-f]{32}/gi; + +/** Extract all UUIDs from a string */ +export function extractUuids(text: string): string[] { + return [...text.matchAll(UUID_REGEX)].map((m) => m[0].toLowerCase()); +} + +/** Extract UUID suffix from a terraform resource name like "journey_abc12345def..." */ +export function extractUuidFromResourceName(name: string): string | null { + const noDashMatches = [...name.matchAll(UUID_NO_DASH_REGEX)]; + if (noDashMatches.length === 0) return null; + + // Take the last match (resource names typically end with the UUID) + const hex = noDashMatches[noDashMatches.length - 1][0]; + // Convert to dashed format for comparison + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +/** Check if a UUID appears in any resource identifier across the blueprint */ +export function isLocalUuid(uuid: string, identifierUuids: Set): boolean { + return identifierUuids.has(uuid.toLowerCase()); +} diff --git a/packages/blueprint-tester/src/validator.ts b/packages/blueprint-tester/src/validator.ts new file mode 100644 index 00000000..d90972bb --- /dev/null +++ b/packages/blueprint-tester/src/validator.ts @@ -0,0 +1,125 @@ +import { readFileSync } from 'node:fs'; +import { parseTerraformFile } from './hcl-parser.js'; +import { allRules } from './rules/index.js'; +import type { + ParsedTerraformFile, + Severity, + ValidationContext, + ValidationIssue, + ValidationReport, + ValidationRule, + ValidatorOptions, +} from './types.js'; +import { buildResourceIndex } from './utils/resource-index.js'; +import { extractTerraformFiles } from './zip-reader.js'; + +const SEVERITY_LEVELS: Record = { error: 0, warning: 1, info: 2 }; + +/** Validate a blueprint ZIP file and return a report of issues found */ +export async function validateBlueprint( + input: Buffer | string, + options: ValidatorOptions = {}, +): Promise { + const validator = new BlueprintValidator(options); + return validator.validate(input); +} + +export class BlueprintValidator { + private rules: ValidationRule[]; + private options: ValidatorOptions; + + constructor(options: ValidatorOptions = {}) { + this.options = options; + this.rules = this.selectRules(); + } + + /** Register an additional custom validation rule */ + registerRule(rule: ValidationRule): void { + this.rules.push(rule); + } + + /** Run validation on a blueprint ZIP */ + async validate(input: Buffer | string): Promise { + // Read the ZIP + const zipInput = typeof input === 'string' ? readFileSync(input) : input; + const extractedFiles = extractTerraformFiles(zipInput); + + if (extractedFiles.length === 0) { + return this.emptyReport([]); + } + + // Parse all .tf files + const parsedFiles: ParsedTerraformFile[] = extractedFiles.map((f) => + parseTerraformFile(f.path, f.content), + ); + + // Build resource index + const resourceIndex = buildResourceIndex(parsedFiles); + + // Create validation context + const context: ValidationContext = { + files: parsedFiles, + resourceIndex, + options: this.options, + }; + + // Run all rules and collect issues + const allIssues: ValidationIssue[] = []; + const minSeverity = SEVERITY_LEVELS[this.options.severity ?? 'info']; + + for (const rule of this.rules) { + const issues = rule.validate(context); + for (const issue of issues) { + if (SEVERITY_LEVELS[issue.severity] <= minSeverity) { + allIssues.push(issue); + } + } + } + + // Build report + const errors = allIssues.filter((i) => i.severity === 'error').length; + const warnings = allIssues.filter((i) => i.severity === 'warning').length; + const infos = allIssues.filter((i) => i.severity === 'info').length; + + const resourceTypes: Record = {}; + for (const file of parsedFiles) { + for (const resource of file.resources) { + resourceTypes[resource.type] = (resourceTypes[resource.type] ?? 0) + 1; + } + } + + return { + valid: errors === 0, + summary: { + errors, + warnings, + infos, + filesScanned: parsedFiles.length, + resourcesFound: parsedFiles.reduce((sum, f) => sum + f.resources.length, 0), + }, + issues: allIssues, + metadata: { + validatedAt: new Date().toISOString(), + blueprintFiles: parsedFiles.map((f) => f.path), + resourceTypes, + }, + }; + } + + private selectRules(): ValidationRule[] { + if (!this.options.rules || this.options.rules.length === 0) { + return [...allRules]; + } + const selected = new Set(this.options.rules); + return allRules.filter((r) => selected.has(r.id)); + } + + private emptyReport(files: string[]): ValidationReport { + return { + valid: true, + summary: { errors: 0, warnings: 0, infos: 0, filesScanned: 0, resourcesFound: 0 }, + issues: [], + metadata: { validatedAt: new Date().toISOString(), blueprintFiles: files, resourceTypes: {} }, + }; + } +} diff --git a/packages/blueprint-tester/src/zip-reader.ts b/packages/blueprint-tester/src/zip-reader.ts new file mode 100644 index 00000000..592ee935 --- /dev/null +++ b/packages/blueprint-tester/src/zip-reader.ts @@ -0,0 +1,18 @@ +import AdmZip from 'adm-zip'; + +export interface ExtractedFile { + path: string; + content: string; +} + +export function extractTerraformFiles(input: Buffer | string): ExtractedFile[] { + const zip = new AdmZip(input); + const entries = zip.getEntries(); + + return entries + .filter((entry) => entry.entryName.endsWith('.tf') && !entry.isDirectory) + .map((entry) => ({ + path: entry.entryName, + content: entry.getData().toString('utf-8'), + })); +} diff --git a/packages/blueprint-tester/tsconfig.json b/packages/blueprint-tester/tsconfig.json new file mode 100644 index 00000000..fc7ba761 --- /dev/null +++ b/packages/blueprint-tester/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "node", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist", + "rootDir": "src", + "resolveJsonModule": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "__tests__"] +} diff --git a/packages/blueprint-tester/tsup.config.ts b/packages/blueprint-tester/tsup.config.ts new file mode 100644 index 00000000..ec32ec98 --- /dev/null +++ b/packages/blueprint-tester/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + target: 'node18', + dts: true, + clean: true, + outDir: 'dist', + platform: 'node', +}); diff --git a/packages/blueprint-tester/vitest.config.ts b/packages/blueprint-tester/vitest.config.ts new file mode 100644 index 00000000..2131bd41 --- /dev/null +++ b/packages/blueprint-tester/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + include: ['__tests__/**/*.test.ts'], + }, +}); diff --git a/packages/cli/src/commands/blueprint-test.ts b/packages/cli/src/commands/blueprint-test.ts new file mode 100644 index 00000000..573c553f --- /dev/null +++ b/packages/cli/src/commands/blueprint-test.ts @@ -0,0 +1,62 @@ +import { defineCommand } from 'citty'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +export default defineCommand({ + meta: { + name: 'blueprint-test', + description: 'Validate an exported blueprint ZIP file for common issues', + }, + args: { + file: { + type: 'positional', + description: 'Path to blueprint .zip file', + required: true, + }, + rules: { + type: 'string', + description: 'Comma-separated list of rules to run (default: all)', + }, + severity: { + type: 'string', + description: 'Minimum severity to report: error, warning, info (default: info)', + }, + json: { + type: 'boolean', + description: 'Output as JSON', + }, + 'source-org-id': { + type: 'string', + description: 'Source org ID to flag specifically', + }, + 'known-safe-uuids': { + type: 'string', + description: 'Comma-separated UUIDs to skip in dangling check', + }, + }, + async run({ args }) { + const { validateBlueprint, formatReport, formatReportJson } = await import( + '@epilot/blueprint-tester' + ); + + const filePath = resolve(args.file as string); + const zipBuffer = readFileSync(filePath); + + const report = await validateBlueprint(zipBuffer, { + rules: args.rules ? (args.rules as string).split(',') : undefined, + severity: args.severity as 'error' | 'warning' | 'info' | undefined, + sourceOrgId: args['source-org-id'] as string | undefined, + knownSafeUuids: args['known-safe-uuids'] + ? (args['known-safe-uuids'] as string).split(',') + : undefined, + }); + + if (args.json) { + process.stdout.write(formatReportJson(report) + '\n'); + } else { + process.stdout.write(formatReport(report)); + } + + process.exitCode = report.valid ? 0 : 1; + }, +}); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 5389a79d..bfee8a27 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -30,6 +30,7 @@ export const main = defineCommand({ automation: () => import('./commands/apis/automation.js').then((m) => m.default), billing: () => import('./commands/apis/billing.js').then((m) => m.default), 'blueprint-manifest': () => import('./commands/apis/blueprint-manifest.js').then((m) => m.default), + 'blueprint-test': () => import('./commands/blueprint-test.js').then((m) => m.default), consent: () => import('./commands/apis/consent.js').then((m) => m.default), 'customer-portal': () => import('./commands/apis/customer-portal.js').then((m) => m.default), dashboard: () => import('./commands/apis/dashboard.js').then((m) => m.default), From 1b7b4c7d07521046e2f20877e39dd638c95073e7 Mon Sep 17 00:00:00 2001 From: Nishu Goel Date: Mon, 16 Mar 2026 16:38:46 +0100 Subject: [PATCH 2/2] Refactor blueprint-tester to be format-agnostic with adapter pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accept any blueprint input format (ZIP, JSON manifest, API response) via adapters that normalize to a common BlueprintData type. Rules now use rawContent instead of rawHcl and conditionally apply terraform-specific checks based on the input format. - Add BlueprintResource, BlueprintData, BlueprintManifest types - Add terraform-adapter (ZIP → BlueprintData) and json-adapter (manifest → BlueprintData) - Add auto-detection in normalizeBlueprintInput - Update all 8 rules to be format-aware - Update CLI to accept .json files - Add JSON adapter tests (6 new, 62 total) Co-Authored-By: Claude Opus 4.6 --- .../__tests__/json-adapter.test.ts | 148 ++++++++++++++++++ .../rules/cross-ref-integrity.test.ts | 2 +- .../__tests__/rules/dangling-uuids.test.ts | 2 +- .../__tests__/rules/email-addresses.test.ts | 2 +- .../__tests__/rules/environment-urls.test.ts | 2 +- .../rules/incomplete-webhooks.test.ts | 2 +- .../rules/public-journey-safety.test.ts | 2 +- .../__tests__/rules/source-org-refs.test.ts | 2 +- .../__tests__/rules/token-detection.test.ts | 2 +- .../blueprint-tester/src/adapters/index.ts | 72 +++++++++ .../src/adapters/json-adapter.ts | 59 +++++++ .../src/adapters/terraform-adapter.ts | 39 +++++ packages/blueprint-tester/src/hcl-parser.ts | 1 + packages/blueprint-tester/src/index.ts | 7 + .../blueprint-tester/src/report-formatter.ts | 4 + .../src/rules/cross-ref-integrity.ts | 46 +++--- .../src/rules/dangling-uuids.ts | 18 ++- .../src/rules/email-addresses.ts | 3 +- .../src/rules/environment-urls.ts | 3 +- .../src/rules/incomplete-webhooks.ts | 29 ++-- .../src/rules/public-journey-safety.ts | 38 ++--- .../src/rules/source-org-refs.ts | 23 +-- .../src/rules/token-detection.ts | 3 +- packages/blueprint-tester/src/types.ts | 58 ++++++- .../src/utils/resource-index.ts | 46 +++++- packages/blueprint-tester/src/validator.ts | 88 +++++++---- packages/cli/src/commands/blueprint-test.ts | 16 +- 27 files changed, 591 insertions(+), 126 deletions(-) create mode 100644 packages/blueprint-tester/__tests__/json-adapter.test.ts create mode 100644 packages/blueprint-tester/src/adapters/index.ts create mode 100644 packages/blueprint-tester/src/adapters/json-adapter.ts create mode 100644 packages/blueprint-tester/src/adapters/terraform-adapter.ts diff --git a/packages/blueprint-tester/__tests__/json-adapter.test.ts b/packages/blueprint-tester/__tests__/json-adapter.test.ts new file mode 100644 index 00000000..f6359d75 --- /dev/null +++ b/packages/blueprint-tester/__tests__/json-adapter.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; +import { fromManifestJson, fromResourceArray } from '../src/adapters/json-adapter.js'; +import { validateBlueprint } from '../src/validator.js'; +import type { BlueprintManifest } from '../src/types.js'; + +describe('JSON adapter', () => { + it('converts a manifest to BlueprintData', () => { + const manifest: BlueprintManifest = { + blueprint_id: 'test-bp-123', + name: 'Test Blueprint', + source_type: 'custom', + resources: [ + { + type: 'journey', + id: 'j-001', + name: 'Onboarding Journey', + address: 'epilot_journey.onboarding', + is_root: true, + is_ready: true, + depends_on_addresses: [], + config: { + name: 'Onboarding Journey', + access_mode: 'PRIVATE', + }, + }, + { + type: 'automation_flow', + id: 'a-001', + name: 'Auto Flow', + address: 'epilot_automation.auto_flow', + depends_on_addresses: ['epilot_journey.onboarding'], + config: { + name: 'Auto Flow', + trigger: { type: 'journey_submission', journey_id: 'j-001' }, + }, + }, + ], + }; + + const data = fromManifestJson(manifest); + + expect(data.format).toBe('json'); + expect(data.resources).toHaveLength(2); + expect(data.metadata?.blueprintId).toBe('test-bp-123'); + expect(data.resources[0].type).toBe('epilot_journey'); + expect(data.resources[0].name).toBe('Onboarding Journey'); + expect(data.resources[1].dependsOn).toEqual(['epilot_journey.onboarding']); + }); + + it('converts a resource array to BlueprintData', () => { + const data = fromResourceArray([ + { + type: 'schema', + id: 's-001', + name: 'Contact Schema', + config: { slug: 'contact', name: 'Contact' }, + }, + ]); + + expect(data.format).toBe('json'); + expect(data.resources).toHaveLength(1); + expect(data.resources[0].type).toBe('epilot_schema'); + }); + + it('validates a JSON manifest for dangling UUIDs', async () => { + const manifest: BlueprintManifest = { + blueprint_id: 'bp-dangling', + resources: [ + { + type: 'automation_flow', + id: 'a-001', + name: 'Auto Flow', + config: { + name: 'Auto Flow', + trigger: { + type: 'journey_submission', + journey_id: 'd11995ae-368e-4ad3-bf1c-51d6449f8afc', + }, + }, + }, + ], + }; + + const report = await validateBlueprint(manifest); + expect(report.valid).toBe(false); + expect(report.metadata.format).toBe('json'); + expect(report.issues.some((i) => i.ruleId === 'dangling-uuid')).toBe(true); + }); + + it('validates a clean JSON manifest', async () => { + const manifest: BlueprintManifest = { + blueprint_id: 'bp-clean', + resources: [ + { + type: 'journey', + id: 'j-001', + name: 'Journey', + config: { name: 'My Journey', access_mode: 'PRIVATE' }, + }, + ], + }; + + const report = await validateBlueprint(manifest); + expect(report.valid).toBe(true); + expect(report.metadata.format).toBe('json'); + expect(report.metadata.blueprintId).toBe('bp-clean'); + }); + + it('detects org IDs in JSON manifest resources', async () => { + const manifest: BlueprintManifest = { + blueprint_id: 'bp-org', + resources: [ + { + type: 'journey', + id: 'j-001', + name: 'Journey', + config: { + name: 'My Journey', + organization_id: '911690', + }, + }, + ], + }; + + const report = await validateBlueprint(manifest); + expect(report.issues.some((i) => i.ruleId === 'source-org-ref')).toBe(true); + }); + + it('detects tokens in JSON manifest resources', async () => { + const manifest: BlueprintManifest = { + blueprint_id: 'bp-token', + resources: [ + { + type: 'webhook', + id: 'wh-001', + name: 'Webhook', + config: { + url: 'https://example.com', + auth_token: 'api_5ZugdRXasLfWBypHi93Fk', + }, + }, + ], + }; + + const report = await validateBlueprint(manifest); + expect(report.issues.some((i) => i.ruleId === 'token-detection')).toBe(true); + }); +}); diff --git a/packages/blueprint-tester/__tests__/rules/cross-ref-integrity.test.ts b/packages/blueprint-tester/__tests__/rules/cross-ref-integrity.test.ts index 6a829e9f..4c667cbb 100644 --- a/packages/blueprint-tester/__tests__/rules/cross-ref-integrity.test.ts +++ b/packages/blueprint-tester/__tests__/rules/cross-ref-integrity.test.ts @@ -7,7 +7,7 @@ function runRule(tfContent: string) { const file = parseTerraformFile('main.tf', tfContent); const files = [file]; const resourceIndex = buildResourceIndex(files); - return crossRefIntegrityRule.validate({ files, resourceIndex, options: {} }); + return crossRefIntegrityRule.validate({ files, resourceIndex, options: {}, format: 'terraform' }); } describe('cross-ref-integrity rule', () => { diff --git a/packages/blueprint-tester/__tests__/rules/dangling-uuids.test.ts b/packages/blueprint-tester/__tests__/rules/dangling-uuids.test.ts index 321c800b..e95d6b7a 100644 --- a/packages/blueprint-tester/__tests__/rules/dangling-uuids.test.ts +++ b/packages/blueprint-tester/__tests__/rules/dangling-uuids.test.ts @@ -8,7 +8,7 @@ function runRule(tfContent: string, options: ValidatorOptions = {}) { const file = parseTerraformFile('main.tf', tfContent); const files = [file]; const resourceIndex = buildResourceIndex(files); - return danglingUuidsRule.validate({ files, resourceIndex, options }); + return danglingUuidsRule.validate({ files, resourceIndex, options, format: 'terraform' }); } describe('dangling-uuids rule', () => { diff --git a/packages/blueprint-tester/__tests__/rules/email-addresses.test.ts b/packages/blueprint-tester/__tests__/rules/email-addresses.test.ts index 30756261..10c296f6 100644 --- a/packages/blueprint-tester/__tests__/rules/email-addresses.test.ts +++ b/packages/blueprint-tester/__tests__/rules/email-addresses.test.ts @@ -7,7 +7,7 @@ function runRule(tfContent: string) { const file = parseTerraformFile('main.tf', tfContent); const files = [file]; const resourceIndex = buildResourceIndex(files); - return emailAddressesRule.validate({ files, resourceIndex, options: {} }); + return emailAddressesRule.validate({ files, resourceIndex, options: {}, format: 'terraform' }); } describe('email-addresses rule', () => { diff --git a/packages/blueprint-tester/__tests__/rules/environment-urls.test.ts b/packages/blueprint-tester/__tests__/rules/environment-urls.test.ts index 021cb948..1f67d484 100644 --- a/packages/blueprint-tester/__tests__/rules/environment-urls.test.ts +++ b/packages/blueprint-tester/__tests__/rules/environment-urls.test.ts @@ -7,7 +7,7 @@ function runRule(tfContent: string) { const file = parseTerraformFile('main.tf', tfContent); const files = [file]; const resourceIndex = buildResourceIndex(files); - return environmentUrlsRule.validate({ files, resourceIndex, options: {} }); + return environmentUrlsRule.validate({ files, resourceIndex, options: {}, format: 'terraform' }); } describe('environment-urls rule', () => { diff --git a/packages/blueprint-tester/__tests__/rules/incomplete-webhooks.test.ts b/packages/blueprint-tester/__tests__/rules/incomplete-webhooks.test.ts index 614846bc..23601ec6 100644 --- a/packages/blueprint-tester/__tests__/rules/incomplete-webhooks.test.ts +++ b/packages/blueprint-tester/__tests__/rules/incomplete-webhooks.test.ts @@ -7,7 +7,7 @@ function runRule(tfContent: string) { const file = parseTerraformFile('main.tf', tfContent); const files = [file]; const resourceIndex = buildResourceIndex(files); - return incompleteWebhooksRule.validate({ files, resourceIndex, options: {} }); + return incompleteWebhooksRule.validate({ files, resourceIndex, options: {}, format: 'terraform' }); } describe('incomplete-webhooks rule', () => { diff --git a/packages/blueprint-tester/__tests__/rules/public-journey-safety.test.ts b/packages/blueprint-tester/__tests__/rules/public-journey-safety.test.ts index d621bab5..e64a5d23 100644 --- a/packages/blueprint-tester/__tests__/rules/public-journey-safety.test.ts +++ b/packages/blueprint-tester/__tests__/rules/public-journey-safety.test.ts @@ -7,7 +7,7 @@ function runRule(tfContent: string) { const file = parseTerraformFile('main.tf', tfContent); const files = [file]; const resourceIndex = buildResourceIndex(files); - return publicJourneySafetyRule.validate({ files, resourceIndex, options: {} }); + return publicJourneySafetyRule.validate({ files, resourceIndex, options: {}, format: 'terraform' }); } describe('public-journey-safety rule', () => { diff --git a/packages/blueprint-tester/__tests__/rules/source-org-refs.test.ts b/packages/blueprint-tester/__tests__/rules/source-org-refs.test.ts index f1b707f0..d082a7a1 100644 --- a/packages/blueprint-tester/__tests__/rules/source-org-refs.test.ts +++ b/packages/blueprint-tester/__tests__/rules/source-org-refs.test.ts @@ -7,7 +7,7 @@ function runRule(tfContent: string, sourceOrgId?: string) { const file = parseTerraformFile('main.tf', tfContent); const files = [file]; const resourceIndex = buildResourceIndex(files); - return sourceOrgRefsRule.validate({ files, resourceIndex, options: { sourceOrgId } }); + return sourceOrgRefsRule.validate({ files, resourceIndex, options: { sourceOrgId }, format: 'terraform' }); } describe('source-org-refs rule', () => { diff --git a/packages/blueprint-tester/__tests__/rules/token-detection.test.ts b/packages/blueprint-tester/__tests__/rules/token-detection.test.ts index a1efae93..c2386c7a 100644 --- a/packages/blueprint-tester/__tests__/rules/token-detection.test.ts +++ b/packages/blueprint-tester/__tests__/rules/token-detection.test.ts @@ -7,7 +7,7 @@ function runRule(tfContent: string) { const file = parseTerraformFile('main.tf', tfContent); const files = [file]; const resourceIndex = buildResourceIndex(files); - return tokenDetectionRule.validate({ files, resourceIndex, options: {} }); + return tokenDetectionRule.validate({ files, resourceIndex, options: {}, format: 'terraform' }); } describe('token-detection rule', () => { diff --git a/packages/blueprint-tester/src/adapters/index.ts b/packages/blueprint-tester/src/adapters/index.ts new file mode 100644 index 00000000..08c71d60 --- /dev/null +++ b/packages/blueprint-tester/src/adapters/index.ts @@ -0,0 +1,72 @@ +import { readFileSync } from 'node:fs'; +import type { BlueprintData, BlueprintManifest } from '../types.js'; +import { fromManifestJson, fromResourceArray } from './json-adapter.js'; +import { fromTerraformZip } from './terraform-adapter.js'; + +export { fromTerraformZip } from './terraform-adapter.js'; +export { fromManifestJson, fromResourceArray } from './json-adapter.js'; + +export type BlueprintInput = + | Buffer // ZIP file buffer + | string // file path (ZIP or JSON) + | BlueprintManifest // API response + | BlueprintData; // Already normalized + +/** Auto-detect input format and normalize to BlueprintData */ +export function normalizeBlueprintInput(input: BlueprintInput): BlueprintData { + // Already normalized + if (isBlueprintData(input)) { + return input; + } + + // Buffer — treat as ZIP + if (Buffer.isBuffer(input)) { + return fromTerraformZip(input); + } + + // String — file path + if (typeof input === 'string') { + return fromFilePath(input); + } + + // Object — check if it's a manifest or resource array + if (typeof input === 'object' && input !== null) { + if ('blueprint_id' in input) { + return fromManifestJson(input as BlueprintManifest); + } + if ('resources' in input && Array.isArray((input as Record).resources)) { + return fromResourceArray((input as Record).resources as never[]); + } + } + + throw new Error('Unsupported blueprint input format. Expected ZIP buffer, file path, blueprint manifest, or BlueprintData.'); +} + +function fromFilePath(filePath: string): BlueprintData { + const content = readFileSync(filePath); + + if (filePath.endsWith('.json')) { + const json = JSON.parse(content.toString('utf-8')); + if (json.blueprint_id) { + return fromManifestJson(json); + } + if (json.resources && Array.isArray(json.resources)) { + return fromResourceArray(json.resources, { blueprintId: json.blueprint_id }); + } + throw new Error(`JSON file does not contain a recognized blueprint format: ${filePath}`); + } + + // Default: treat as ZIP + return fromTerraformZip(content); +} + +function isBlueprintData(input: unknown): input is BlueprintData { + return ( + typeof input === 'object' && + input !== null && + 'resources' in input && + 'format' in input && + 'sourceFiles' in input && + Array.isArray((input as BlueprintData).resources) + ); +} diff --git a/packages/blueprint-tester/src/adapters/json-adapter.ts b/packages/blueprint-tester/src/adapters/json-adapter.ts new file mode 100644 index 00000000..528b0169 --- /dev/null +++ b/packages/blueprint-tester/src/adapters/json-adapter.ts @@ -0,0 +1,59 @@ +import type { BlueprintData, BlueprintManifest, BlueprintResource, ManifestResource } from '../types.js'; + +/** Convert a blueprint manifest JSON (from getBlueprint API) to BlueprintData */ +export function fromManifestJson(manifest: BlueprintManifest): BlueprintData { + const resources: BlueprintResource[] = (manifest.resources ?? []).map(manifestResourceToBlueprint); + + return { + resources, + variables: {}, + sourceFiles: ['api'], + format: 'json', + metadata: { + blueprintId: manifest.blueprint_id, + sourceType: manifest.source_type, + }, + }; +} + +/** Convert a raw array of resources (with configs) to BlueprintData */ +export function fromResourceArray( + resources: ManifestResource[], + metadata?: { blueprintId?: string; sourceOrgId?: string }, +): BlueprintData { + return { + resources: resources.map(manifestResourceToBlueprint), + variables: {}, + sourceFiles: ['json'], + format: 'json', + metadata, + }; +} + +function manifestResourceToBlueprint(res: ManifestResource): BlueprintResource { + const config = res.config ?? {}; + const rawContent = JSON.stringify(config, null, 2); + + return { + type: normalizeResourceType(res.type), + name: res.name ?? res.id, + address: res.address ?? `${normalizeResourceType(res.type)}.${res.id}`, + attributes: flattenConfig(config), + dependsOn: res.depends_on_addresses ?? [], + rawContent, + source: 'api', + lineStart: undefined, + }; +} + +/** Normalize resource type to match terraform resource types (e.g., "journey" → "epilot_journey") */ +function normalizeResourceType(type: string): string { + if (type.startsWith('epilot_') || type.startsWith('epilot-')) return type; + return `epilot_${type}`; +} + +/** Flatten nested config into a flat attribute map for rule scanning */ +function flattenConfig(config: Record): Record { + // Keep the original structure — rules handle nested scanning already + return config; +} diff --git a/packages/blueprint-tester/src/adapters/terraform-adapter.ts b/packages/blueprint-tester/src/adapters/terraform-adapter.ts new file mode 100644 index 00000000..06bc1c51 --- /dev/null +++ b/packages/blueprint-tester/src/adapters/terraform-adapter.ts @@ -0,0 +1,39 @@ +import { parseTerraformFile } from '../hcl-parser.js'; +import type { BlueprintData, BlueprintResource } from '../types.js'; +import { extractTerraformFiles } from '../zip-reader.js'; + +/** Convert a blueprint ZIP (containing .tf files) to format-agnostic BlueprintData */ +export function fromTerraformZip(input: Buffer): BlueprintData { + const extractedFiles = extractTerraformFiles(input); + + const resources: BlueprintResource[] = []; + const variables: Record = {}; + const sourceFiles: string[] = []; + + for (const file of extractedFiles) { + sourceFiles.push(file.path); + const parsed = parseTerraformFile(file.path, file.content); + + Object.assign(variables, parsed.variables); + + for (const tfResource of parsed.resources) { + resources.push({ + type: tfResource.type, + name: tfResource.name, + address: tfResource.address, + attributes: tfResource.attributes, + dependsOn: tfResource.dependsOn, + rawContent: tfResource.rawHcl, + source: tfResource.file, + lineStart: tfResource.lineStart, + }); + } + } + + return { + resources, + variables, + sourceFiles, + format: 'terraform', + }; +} diff --git a/packages/blueprint-tester/src/hcl-parser.ts b/packages/blueprint-tester/src/hcl-parser.ts index f8ab7e96..0520c525 100644 --- a/packages/blueprint-tester/src/hcl-parser.ts +++ b/packages/blueprint-tester/src/hcl-parser.ts @@ -53,6 +53,7 @@ function extractResourceBlocks(content: string, filePath: string): TerraformReso attributes, dependsOn, rawHcl, + rawContent: rawHcl, file: filePath, lineStart, }); diff --git a/packages/blueprint-tester/src/index.ts b/packages/blueprint-tester/src/index.ts index 8438cc42..db5e24fd 100644 --- a/packages/blueprint-tester/src/index.ts +++ b/packages/blueprint-tester/src/index.ts @@ -1,10 +1,17 @@ export { validateBlueprint, BlueprintValidator } from './validator.js'; export { formatReport, formatReportJson } from './report-formatter.js'; +export { fromTerraformZip, fromManifestJson, fromResourceArray, normalizeBlueprintInput } from './adapters/index.js'; +export type { BlueprintInput } from './adapters/index.js'; export type { ValidationReport, ValidationIssue, ValidatorOptions, Severity, + BlueprintFormat, + BlueprintResource, + BlueprintData, + BlueprintManifest, + ManifestResource, ValidationRule, ValidationContext, TerraformResource, diff --git a/packages/blueprint-tester/src/report-formatter.ts b/packages/blueprint-tester/src/report-formatter.ts index e3b5a7b9..7405e790 100644 --- a/packages/blueprint-tester/src/report-formatter.ts +++ b/packages/blueprint-tester/src/report-formatter.ts @@ -12,6 +12,10 @@ export function formatReport(report: ValidationReport): string { lines.push(''); // Summary + lines.push(`Format: ${report.metadata.format ?? 'terraform'}`); + if (report.metadata.blueprintId) { + lines.push(`Blueprint ID: ${report.metadata.blueprintId}`); + } lines.push(`Files scanned: ${report.summary.filesScanned}`); lines.push(`Resources found: ${report.summary.resourcesFound}`); lines.push(''); diff --git a/packages/blueprint-tester/src/rules/cross-ref-integrity.ts b/packages/blueprint-tester/src/rules/cross-ref-integrity.ts index f556b671..3541c1a7 100644 --- a/packages/blueprint-tester/src/rules/cross-ref-integrity.ts +++ b/packages/blueprint-tester/src/rules/cross-ref-integrity.ts @@ -9,7 +9,7 @@ export const crossRefIntegrityRule: ValidationRule = { validate(context: ValidationContext): ValidationIssue[] { const issues: ValidationIssue[] = []; - const { resourceIndex } = context; + const { resourceIndex, format } = context; for (const file of context.files) { for (const resource of file.resources) { @@ -29,31 +29,31 @@ export const crossRefIntegrityRule: ValidationRule = { } } - // Check terraform references in attribute values - scanForReferences(resource.attributes, '', (path, value) => { - if (typeof value !== 'string') return; + // Terraform reference checking only applies to terraform format + if (format === 'terraform') { + scanForReferences(resource.attributes, '', (path, value) => { + if (typeof value !== 'string') return; - const addresses = extractReferencedAddresses(value); - for (const addr of addresses) { - // Skip variable references (var.xxx) - if (addr.startsWith('var.')) continue; - // Skip data source references (data.xxx) - if (addr.startsWith('data.')) continue; + const addresses = extractReferencedAddresses(value); + for (const addr of addresses) { + if (addr.startsWith('var.')) continue; + if (addr.startsWith('data.')) continue; - if (!resourceIndex.allAddresses.has(addr)) { - issues.push({ - ruleId: 'cross-ref-integrity', - severity: 'error', - message: `References resource "${addr}" which does not exist in the blueprint. The referenced resource was likely not exported.`, - file: resource.file, - line: resource.lineStart, - resourceAddress: resource.address, - attributePath: path, - value: addr, - }); + if (!resourceIndex.allAddresses.has(addr)) { + issues.push({ + ruleId: 'cross-ref-integrity', + severity: 'error', + message: `References resource "${addr}" which does not exist in the blueprint. The referenced resource was likely not exported.`, + file: resource.file, + line: resource.lineStart, + resourceAddress: resource.address, + attributePath: path, + value: addr, + }); + } } - } - }); + }); + } } } diff --git a/packages/blueprint-tester/src/rules/dangling-uuids.ts b/packages/blueprint-tester/src/rules/dangling-uuids.ts index b0415b8c..c36b70b7 100644 --- a/packages/blueprint-tester/src/rules/dangling-uuids.ts +++ b/packages/blueprint-tester/src/rules/dangling-uuids.ts @@ -5,12 +5,13 @@ import { extractUuids, isLocalUuid } from '../utils/uuid.js'; export const danglingUuidsRule: ValidationRule = { id: 'dangling-uuid', name: 'Dangling UUID Detection', - description: 'Detects hardcoded UUIDs in attribute values that are not Terraform references and do not match any resource in the blueprint', + description: 'Detects hardcoded UUIDs in attribute values that do not match any resource in the blueprint', severity: 'error', validate(context: ValidationContext): ValidationIssue[] { const issues: ValidationIssue[] = []; - const { resourceIndex, options } = context; + const { resourceIndex, options, format } = context; + const isTerraform = format === 'terraform'; const safeUuids = new Set((options.knownSafeUuids ?? []).map((u) => u.toLowerCase())); for (const file of context.files) { @@ -18,11 +19,12 @@ export const danglingUuidsRule: ValidationRule = { scanAttributeValues(resource.attributes, '', (path, value) => { if (typeof value !== 'string') return; - // Skip if the entire value is a terraform reference - if (isReferenceExpression(value)) return; - if (isVariableReference(value)) return; - // Skip if value contains terraform interpolations (partially resolved is ok) - if (containsTerraformRef(value)) return; + // For terraform format, skip properly referenced values + if (isTerraform) { + if (isReferenceExpression(value)) return; + if (isVariableReference(value)) return; + if (containsTerraformRef(value)) return; + } const uuids = extractUuids(value); for (const uuid of uuids) { @@ -36,7 +38,7 @@ export const danglingUuidsRule: ValidationRule = { issues.push({ ruleId: 'dangling-uuid', severity: 'error', - message: `UUID "${uuid}" in attribute "${path}" is not a Terraform reference and does not match any resource in the blueprint. This likely references a resource in the source org that was not exported.`, + message: `UUID "${uuid}" in attribute "${path}" does not match any resource in the blueprint. This likely references a resource in the source org that was not exported.`, file: resource.file, line: resource.lineStart, resourceAddress: resource.address, diff --git a/packages/blueprint-tester/src/rules/email-addresses.ts b/packages/blueprint-tester/src/rules/email-addresses.ts index 9e6f524d..606019c2 100644 --- a/packages/blueprint-tester/src/rules/email-addresses.ts +++ b/packages/blueprint-tester/src/rules/email-addresses.ts @@ -22,8 +22,9 @@ export const emailAddressesRule: ValidationRule = { for (const file of context.files) { for (const resource of file.resources) { + const content = resource.rawContent ?? resource.rawHcl; EMAIL_REGEX.lastIndex = 0; - const matches = [...resource.rawHcl.matchAll(EMAIL_REGEX)]; + const matches = [...content.matchAll(EMAIL_REGEX)]; for (const match of matches) { const email = match[0]; diff --git a/packages/blueprint-tester/src/rules/environment-urls.ts b/packages/blueprint-tester/src/rules/environment-urls.ts index 10cab2e7..1fb32166 100644 --- a/packages/blueprint-tester/src/rules/environment-urls.ts +++ b/packages/blueprint-tester/src/rules/environment-urls.ts @@ -25,9 +25,10 @@ export const environmentUrlsRule: ValidationRule = { for (const file of context.files) { for (const resource of file.resources) { + const content = resource.rawContent ?? resource.rawHcl; for (const pattern of ENV_URL_PATTERNS) { pattern.regex.lastIndex = 0; - const matches = [...resource.rawHcl.matchAll(pattern.regex)]; + const matches = [...content.matchAll(pattern.regex)]; for (const match of matches) { issues.push({ diff --git a/packages/blueprint-tester/src/rules/incomplete-webhooks.ts b/packages/blueprint-tester/src/rules/incomplete-webhooks.ts index 35b91f45..6d0fbbac 100644 --- a/packages/blueprint-tester/src/rules/incomplete-webhooks.ts +++ b/packages/blueprint-tester/src/rules/incomplete-webhooks.ts @@ -1,4 +1,4 @@ -import type { ValidationContext, ValidationIssue, ValidationRule } from '../types.js'; +import type { TerraformResource, ValidationContext, ValidationIssue, ValidationRule } from '../types.js'; import { isReferenceExpression, isVariableReference } from '../utils/terraform-refs.js'; const WEBHOOK_TYPES = new Set([ @@ -14,15 +14,13 @@ export const incompleteWebhooksRule: ValidationRule = { validate(context: ValidationContext): ValidationIssue[] { const issues: ValidationIssue[] = []; + const isTerraform = context.format === 'terraform'; for (const file of context.files) { for (const resource of file.resources) { if (!WEBHOOK_TYPES.has(resource.type)) continue; - // Check for hardcoded URL - checkForHardcodedUrl(resource, issues); - - // Check for embedded auth in raw HCL + checkForHardcodedUrl(resource, issues, isTerraform); checkForEmbeddedAuth(resource, issues); } } @@ -39,12 +37,14 @@ export const incompleteWebhooksRule: ValidationRule = { }, }; -function checkForHardcodedUrl(resource: import('../types.js').TerraformResource, issues: ValidationIssue[]): void { +function checkForHardcodedUrl(resource: TerraformResource, issues: ValidationIssue[], isTerraform: boolean): void { for (const [key, value] of Object.entries(resource.attributes)) { if (key !== 'url' && key !== 'webhook_url' && key !== 'endpoint') continue; if (typeof value !== 'string') continue; - if (isReferenceExpression(value)) continue; - if (isVariableReference(value)) continue; + if (isTerraform) { + if (isReferenceExpression(value)) continue; + if (isVariableReference(value)) continue; + } if (value.startsWith('http://') || value.startsWith('https://')) { issues.push({ @@ -61,7 +61,8 @@ function checkForHardcodedUrl(resource: import('../types.js').TerraformResource, } } -function checkForEmbeddedAuth(resource: import('../types.js').TerraformResource, issues: ValidationIssue[]): void { +function checkForEmbeddedAuth(resource: TerraformResource, issues: ValidationIssue[]): void { + const rawContent = resource.rawContent ?? resource.rawHcl; const authPatterns = [ /oauth_secret["'\s]*[:=]["'\s]*["'][^"']+["']/i, /auth_token["'\s]*[:=]["'\s]*["'][^"']+["']/i, @@ -69,7 +70,7 @@ function checkForEmbeddedAuth(resource: import('../types.js').TerraformResource, ]; for (const pattern of authPatterns) { - if (pattern.test(resource.rawHcl)) { + if (pattern.test(rawContent)) { issues.push({ ruleId: 'incomplete-webhook', severity: 'warning', @@ -78,14 +79,14 @@ function checkForEmbeddedAuth(resource: import('../types.js').TerraformResource, line: resource.lineStart, resourceAddress: resource.address, }); - break; // One auth warning per webhook is enough + break; } } } -function checkAutomationWebhookActions(resource: import('../types.js').TerraformResource, issues: ValidationIssue[]): void { - // Check raw HCL for webhook-related actions with hardcoded URLs - const webhookUrlInAction = resource.rawHcl.match(/(?:webhook_url|url)["'\s]*[:=]["'\s]*["'](https?:\/\/[^"']+)["']/g); +function checkAutomationWebhookActions(resource: TerraformResource, issues: ValidationIssue[]): void { + const rawContent = resource.rawContent ?? resource.rawHcl; + const webhookUrlInAction = rawContent.match(/(?:webhook_url|url)["'\s]*[:=]["'\s]*["'](https?:\/\/[^"']+)["']/g); if (webhookUrlInAction) { for (const match of webhookUrlInAction) { const urlMatch = match.match(/(https?:\/\/[^"']+)/); diff --git a/packages/blueprint-tester/src/rules/public-journey-safety.ts b/packages/blueprint-tester/src/rules/public-journey-safety.ts index 5dd93ebd..396ee5e5 100644 --- a/packages/blueprint-tester/src/rules/public-journey-safety.ts +++ b/packages/blueprint-tester/src/rules/public-journey-safety.ts @@ -1,4 +1,4 @@ -import type { ValidationContext, ValidationIssue, ValidationRule } from '../types.js'; +import type { TerraformResource, ValidationContext, ValidationIssue, ValidationRule } from '../types.js'; import { isReferenceExpression, isVariableReference } from '../utils/terraform-refs.js'; import { extractUuids } from '../utils/uuid.js'; @@ -7,7 +7,7 @@ const JOURNEY_TYPES = new Set([ 'epilot-journey_journey', ]); -/** Attribute keys in journey resources that reference other resources and must be terraform refs */ +/** Attribute keys in journey resources that reference other resources */ const CRITICAL_REF_KEYS = new Set([ 'mappings_automation_id', 'design_id', @@ -17,24 +17,23 @@ const CRITICAL_REF_KEYS = new Set([ export const publicJourneySafetyRule: ValidationRule = { id: 'public-journey-safety', name: 'Public Journey Safety', - description: 'Checks that public journeys have all critical references properly terraformed to prevent wrong-org submissions', + description: 'Checks that public journeys have all critical references properly handled to prevent wrong-org submissions', severity: 'warning', validate(context: ValidationContext): ValidationIssue[] { const issues: ValidationIssue[] = []; + const isTerraform = context.format === 'terraform'; for (const file of context.files) { for (const resource of file.resources) { if (!JOURNEY_TYPES.has(resource.type)) continue; - const isPublic = isPublicJourney(resource.attributes, resource.rawHcl); + const rawContent = resource.rawContent ?? resource.rawHcl; + const isPublic = isPublicJourney(resource.attributes, rawContent); if (!isPublic) continue; - // Check critical reference attributes - checkCriticalRefs(resource, issues); - - // Check for hardcoded organization_id in the journey body - checkOrgIdInJourney(resource, issues); + checkCriticalRefs(resource, issues, isTerraform); + checkOrgIdInJourney(resource, rawContent, issues); } } @@ -42,26 +41,29 @@ export const publicJourneySafetyRule: ValidationRule = { }, }; -function isPublicJourney(attrs: Record, rawHcl: string): boolean { +function isPublicJourney(attrs: Record, rawContent: string): boolean { if (attrs.access_mode === 'PUBLIC') return true; - // Also check in raw HCL for nested access_mode within jsonencode - if (/access_mode["'\s]*[:=]["'\s]*["']?PUBLIC/i.test(rawHcl)) return true; + if (attrs.accessMode === 'PUBLIC') return true; + if (/access_?[Mm]ode["'\s]*[:=]["'\s]*["']?PUBLIC/i.test(rawContent)) return true; return false; } -function checkCriticalRefs(resource: import('../types.js').TerraformResource, issues: ValidationIssue[]): void { +function checkCriticalRefs(resource: TerraformResource, issues: ValidationIssue[], isTerraform: boolean): void { for (const [key, value] of Object.entries(resource.attributes)) { if (!CRITICAL_REF_KEYS.has(key)) continue; if (typeof value !== 'string') continue; - if (isReferenceExpression(value)) continue; - if (isVariableReference(value)) continue; + // For terraform, skip proper references + if (isTerraform) { + if (isReferenceExpression(value)) continue; + if (isVariableReference(value)) continue; + } const uuids = extractUuids(value); if (uuids.length > 0) { issues.push({ ruleId: 'public-journey-safety', severity: 'warning', - message: `Public journey has hardcoded "${key}" = "${value}". This should be a Terraform reference to prevent submissions routing to the source org.`, + message: `Public journey has hardcoded "${key}" = "${value}". This reference should be properly managed to prevent submissions routing to the source org.`, file: resource.file, line: resource.lineStart, resourceAddress: resource.address, @@ -72,8 +74,8 @@ function checkCriticalRefs(resource: import('../types.js').TerraformResource, is } } -function checkOrgIdInJourney(resource: import('../types.js').TerraformResource, issues: ValidationIssue[]): void { - const orgIdMatch = resource.rawHcl.match(/organization_id["'\s]*[:=]["'\s]*["']?(\d{4,10})/); +function checkOrgIdInJourney(resource: TerraformResource, rawContent: string, issues: ValidationIssue[]): void { + const orgIdMatch = rawContent.match(/organization_id["'\s]*[:=]["'\s]*["']?(\d{4,10})/); if (orgIdMatch) { issues.push({ ruleId: 'public-journey-safety', diff --git a/packages/blueprint-tester/src/rules/source-org-refs.ts b/packages/blueprint-tester/src/rules/source-org-refs.ts index 7d29c0fa..de586825 100644 --- a/packages/blueprint-tester/src/rules/source-org-refs.ts +++ b/packages/blueprint-tester/src/rules/source-org-refs.ts @@ -10,19 +10,23 @@ const NUMERIC_ORG_ID_REGEX = /^\d{4,10}$/; export const sourceOrgRefsRule: ValidationRule = { id: 'source-org-ref', name: 'Source Org Reference Detection', - description: 'Detects hardcoded organization IDs that should be Terraform variables', + description: 'Detects hardcoded organization IDs that should not transfer between orgs', severity: 'error', validate(context: ValidationContext): ValidationIssue[] { const issues: ValidationIssue[] = []; - const { options } = context; + const { options, format } = context; + const isTerraform = format === 'terraform'; for (const file of context.files) { for (const resource of file.resources) { checkAttributes(resource.attributes, '', (path, key, value) => { if (typeof value !== 'string') return; - if (isReferenceExpression(value)) return; - if (isVariableReference(value)) return; + // For terraform format, skip values that are proper references + if (isTerraform) { + if (isReferenceExpression(value)) return; + if (isVariableReference(value)) return; + } // Check if the key is an org_id field with a hardcoded value if (ORG_ID_KEYS.has(key) && NUMERIC_ORG_ID_REGEX.test(value)) { @@ -30,7 +34,7 @@ export const sourceOrgRefsRule: ValidationRule = { issues.push({ ruleId: 'source-org-ref', severity: 'error', - message: `Hardcoded organization ID "${value}" in "${path}"${isSourceOrg ? ' (matches source org)' : ''}. This should be a Terraform variable reference like var.target_org_id.`, + message: `Hardcoded organization ID "${value}" in "${path}"${isSourceOrg ? ' (matches source org)' : ''}. This should not transfer between orgs.`, file: resource.file, line: resource.lineStart, resourceAddress: resource.address, @@ -40,10 +44,11 @@ export const sourceOrgRefsRule: ValidationRule = { } }); - // Also scan raw HCL for org_id patterns in jsonencode blocks - const orgIdInJson = resource.rawHcl.match(/"org(?:anization)?_id"\s*[:=]\s*"?(\d{4,10})"?/g); - if (orgIdInJson) { - for (const match of orgIdInJson) { + // Also scan raw content for org_id patterns in encoded blocks + const rawContent = resource.rawContent ?? resource.rawHcl; + const orgIdInContent = rawContent.match(/"org(?:anization)?_id"\s*[:=]\s*"?(\d{4,10})"?/g); + if (orgIdInContent) { + for (const match of orgIdInContent) { const valueMatch = match.match(/(\d{4,10})/); if (valueMatch) { issues.push({ diff --git a/packages/blueprint-tester/src/rules/token-detection.ts b/packages/blueprint-tester/src/rules/token-detection.ts index d2438fbb..45026593 100644 --- a/packages/blueprint-tester/src/rules/token-detection.ts +++ b/packages/blueprint-tester/src/rules/token-detection.ts @@ -28,10 +28,11 @@ export const tokenDetectionRule: ValidationRule = { for (const file of context.files) { for (const resource of file.resources) { + const content = resource.rawContent ?? resource.rawHcl; for (const pattern of TOKEN_PATTERNS) { // Reset regex state pattern.regex.lastIndex = 0; - const matches = [...resource.rawHcl.matchAll(pattern.regex)]; + const matches = [...content.matchAll(pattern.regex)]; for (const match of matches) { const rawValue = match[0]; diff --git a/packages/blueprint-tester/src/types.ts b/packages/blueprint-tester/src/types.ts index a9ca4b17..bcd75139 100644 --- a/packages/blueprint-tester/src/types.ts +++ b/packages/blueprint-tester/src/types.ts @@ -1,5 +1,7 @@ export type Severity = 'error' | 'warning' | 'info'; +export type BlueprintFormat = 'terraform' | 'json'; + export interface ValidationIssue { ruleId: string; severity: Severity; @@ -26,6 +28,8 @@ export interface ValidationReport { validatedAt: string; blueprintFiles: string[]; resourceTypes: Record; + format: BlueprintFormat; + blueprintId?: string; }; } @@ -40,6 +44,33 @@ export interface ValidatorOptions { sourceOrgId?: string; } +/** Format-agnostic resource representation */ +export interface BlueprintResource { + type: string; + name: string; + address: string; + attributes: Record; + dependsOn: string[]; + /** Raw content for pattern scanning (HCL text or JSON string) */ + rawContent: string; + source: string; + lineStart?: number; +} + +/** Normalized blueprint data from any input format */ +export interface BlueprintData { + resources: BlueprintResource[]; + variables: Record; + sourceFiles: string[]; + format: BlueprintFormat; + metadata?: { + blueprintId?: string; + sourceOrgId?: string; + sourceType?: string; + }; +} + +/** @deprecated Use BlueprintResource instead */ export interface TerraformResource { type: string; name: string; @@ -47,6 +78,7 @@ export interface TerraformResource { attributes: Record; dependsOn: string[]; rawHcl: string; + rawContent: string; file: string; lineStart: number; } @@ -70,12 +102,34 @@ export interface ValidationContext { files: ParsedTerraformFile[]; resourceIndex: ResourceIndex; options: ValidatorOptions; + format: BlueprintFormat; } export interface ResourceIndex { - byAddress: Map; - byType: Map; + byAddress: Map; + byType: Map; allAddresses: Set; /** UUIDs embedded in resource identifier names */ allUuidsInIdentifiers: Set; } + +/** Blueprint manifest resource as returned by the getBlueprint API */ +export interface ManifestResource { + type: string; + id: string; + name?: string; + address?: string; + is_root?: boolean; + is_ready?: boolean; + depends_on_addresses?: string[]; + config?: Record; +} + +/** Blueprint manifest as returned by the getBlueprint API */ +export interface BlueprintManifest { + blueprint_id: string; + name?: string; + source_type?: string; + resources?: ManifestResource[]; + installation_status?: string; +} diff --git a/packages/blueprint-tester/src/utils/resource-index.ts b/packages/blueprint-tester/src/utils/resource-index.ts index 22399bf8..0d0e3c32 100644 --- a/packages/blueprint-tester/src/utils/resource-index.ts +++ b/packages/blueprint-tester/src/utils/resource-index.ts @@ -1,23 +1,57 @@ -import type { ParsedTerraformFile, ResourceIndex, TerraformResource } from '../types.js'; +import type { BlueprintData, BlueprintResource, ParsedTerraformFile, ResourceIndex } from '../types.js'; import { extractUuidFromResourceName } from './uuid.js'; +/** Build a searchable index from BlueprintData */ +export function buildResourceIndexFromData(data: BlueprintData): ResourceIndex { + const byAddress = new Map(); + const byType = new Map(); + const allAddresses = new Set(); + const allUuidsInIdentifiers = new Set(); + + for (const resource of data.resources) { + byAddress.set(resource.address, resource); + allAddresses.add(resource.address); + + const existing = byType.get(resource.type) ?? []; + existing.push(resource); + byType.set(resource.type, existing); + + const uuid = extractUuidFromResourceName(resource.name); + if (uuid) { + allUuidsInIdentifiers.add(uuid.toLowerCase()); + } + } + + return { byAddress, byType, allAddresses, allUuidsInIdentifiers }; +} + /** Build a searchable index of all resources across all parsed .tf files */ export function buildResourceIndex(files: ParsedTerraformFile[]): ResourceIndex { - const byAddress = new Map(); - const byType = new Map(); + const byAddress = new Map(); + const byType = new Map(); const allAddresses = new Set(); const allUuidsInIdentifiers = new Set(); for (const file of files) { for (const resource of file.resources) { - byAddress.set(resource.address, resource); + const blueprintResource: BlueprintResource = { + type: resource.type, + name: resource.name, + address: resource.address, + attributes: resource.attributes, + dependsOn: resource.dependsOn, + rawContent: resource.rawContent ?? resource.rawHcl, + source: resource.file, + lineStart: resource.lineStart, + }; + + byAddress.set(resource.address, blueprintResource); allAddresses.add(resource.address); const existing = byType.get(resource.type) ?? []; - existing.push(resource); + existing.push(blueprintResource); byType.set(resource.type, existing); - // Extract UUID from the resource name (e.g., "journey_abc123def456...") const uuid = extractUuidFromResourceName(resource.name); if (uuid) { allUuidsInIdentifiers.add(uuid.toLowerCase()); diff --git a/packages/blueprint-tester/src/validator.ts b/packages/blueprint-tester/src/validator.ts index d90972bb..c9a804d3 100644 --- a/packages/blueprint-tester/src/validator.ts +++ b/packages/blueprint-tester/src/validator.ts @@ -1,7 +1,8 @@ -import { readFileSync } from 'node:fs'; -import { parseTerraformFile } from './hcl-parser.js'; +import { type BlueprintInput, normalizeBlueprintInput } from './adapters/index.js'; import { allRules } from './rules/index.js'; import type { + BlueprintData, + BlueprintFormat, ParsedTerraformFile, Severity, ValidationContext, @@ -10,14 +11,13 @@ import type { ValidationRule, ValidatorOptions, } from './types.js'; -import { buildResourceIndex } from './utils/resource-index.js'; -import { extractTerraformFiles } from './zip-reader.js'; +import { buildResourceIndexFromData } from './utils/resource-index.js'; const SEVERITY_LEVELS: Record = { error: 0, warning: 1, info: 2 }; -/** Validate a blueprint ZIP file and return a report of issues found */ +/** Validate a blueprint from any supported input format */ export async function validateBlueprint( - input: Buffer | string, + input: BlueprintInput, options: ValidatorOptions = {}, ): Promise { const validator = new BlueprintValidator(options); @@ -38,29 +38,28 @@ export class BlueprintValidator { this.rules.push(rule); } - /** Run validation on a blueprint ZIP */ - async validate(input: Buffer | string): Promise { - // Read the ZIP - const zipInput = typeof input === 'string' ? readFileSync(input) : input; - const extractedFiles = extractTerraformFiles(zipInput); + /** Run validation on any supported blueprint input */ + async validate(input: BlueprintInput): Promise { + const data = normalizeBlueprintInput(input); + return this.validateData(data); + } - if (extractedFiles.length === 0) { - return this.emptyReport([]); + /** Run validation on already-normalized BlueprintData */ + async validateData(data: BlueprintData): Promise { + if (data.resources.length === 0) { + return this.emptyReport(data.sourceFiles, data.format); } - // Parse all .tf files - const parsedFiles: ParsedTerraformFile[] = extractedFiles.map((f) => - parseTerraformFile(f.path, f.content), - ); + const resourceIndex = buildResourceIndexFromData(data); - // Build resource index - const resourceIndex = buildResourceIndex(parsedFiles); + // Wrap resources into ParsedTerraformFile structure for rule compatibility + const files = this.buildFiles(data); - // Create validation context const context: ValidationContext = { - files: parsedFiles, + files, resourceIndex, options: this.options, + format: data.format, }; // Run all rules and collect issues @@ -76,16 +75,13 @@ export class BlueprintValidator { } } - // Build report const errors = allIssues.filter((i) => i.severity === 'error').length; const warnings = allIssues.filter((i) => i.severity === 'warning').length; const infos = allIssues.filter((i) => i.severity === 'info').length; const resourceTypes: Record = {}; - for (const file of parsedFiles) { - for (const resource of file.resources) { - resourceTypes[resource.type] = (resourceTypes[resource.type] ?? 0) + 1; - } + for (const resource of data.resources) { + resourceTypes[resource.type] = (resourceTypes[resource.type] ?? 0) + 1; } return { @@ -94,18 +90,48 @@ export class BlueprintValidator { errors, warnings, infos, - filesScanned: parsedFiles.length, - resourcesFound: parsedFiles.reduce((sum, f) => sum + f.resources.length, 0), + filesScanned: data.sourceFiles.length, + resourcesFound: data.resources.length, }, issues: allIssues, metadata: { validatedAt: new Date().toISOString(), - blueprintFiles: parsedFiles.map((f) => f.path), + blueprintFiles: data.sourceFiles, resourceTypes, + format: data.format, + blueprintId: data.metadata?.blueprintId, }, }; } + private buildFiles(data: BlueprintData): ParsedTerraformFile[] { + // Group resources by source file + const byFile = new Map(); + for (const resource of data.resources) { + const file = resource.source; + const existing = byFile.get(file) ?? []; + existing.push(resource); + byFile.set(file, existing); + } + + return Array.from(byFile.entries()).map(([path, resources]) => ({ + path, + resources: resources.map((r) => ({ + type: r.type, + name: r.name, + address: r.address, + attributes: r.attributes, + dependsOn: r.dependsOn, + rawHcl: r.rawContent, + rawContent: r.rawContent, + file: r.source, + lineStart: r.lineStart ?? 1, + })), + variables: data.variables, + rawContent: resources.map((r) => r.rawContent).join('\n'), + })); + } + private selectRules(): ValidationRule[] { if (!this.options.rules || this.options.rules.length === 0) { return [...allRules]; @@ -114,12 +140,12 @@ export class BlueprintValidator { return allRules.filter((r) => selected.has(r.id)); } - private emptyReport(files: string[]): ValidationReport { + private emptyReport(files: string[], format: BlueprintFormat): ValidationReport { return { valid: true, summary: { errors: 0, warnings: 0, infos: 0, filesScanned: 0, resourcesFound: 0 }, issues: [], - metadata: { validatedAt: new Date().toISOString(), blueprintFiles: files, resourceTypes: {} }, + metadata: { validatedAt: new Date().toISOString(), blueprintFiles: files, resourceTypes: {}, format }, }; } } diff --git a/packages/cli/src/commands/blueprint-test.ts b/packages/cli/src/commands/blueprint-test.ts index 573c553f..63a5e13a 100644 --- a/packages/cli/src/commands/blueprint-test.ts +++ b/packages/cli/src/commands/blueprint-test.ts @@ -5,12 +5,12 @@ import { resolve } from 'node:path'; export default defineCommand({ meta: { name: 'blueprint-test', - description: 'Validate an exported blueprint ZIP file for common issues', + description: 'Validate a blueprint for common issues (ZIP file, JSON file, or blueprint manifest)', }, args: { file: { type: 'positional', - description: 'Path to blueprint .zip file', + description: 'Path to blueprint .zip or .json file', required: true, }, rules: { @@ -40,9 +40,17 @@ export default defineCommand({ ); const filePath = resolve(args.file as string); - const zipBuffer = readFileSync(filePath); + let input: Buffer | string; - const report = await validateBlueprint(zipBuffer, { + if (filePath.endsWith('.json')) { + // Pass the file path — the adapter handles JSON parsing + input = filePath; + } else { + // ZIP file — read as buffer + input = readFileSync(filePath); + } + + const report = await validateBlueprint(input, { rules: args.rules ? (args.rules as string).split(',') : undefined, severity: args.severity as 'error' | 'warning' | 'info' | undefined, sourceOrgId: args['source-org-id'] as string | undefined,