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__/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 new file mode 100644 index 00000000..4c667cbb --- /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: {}, format: 'terraform' }); +} + +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..e95d6b7a --- /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, format: 'terraform' }); +} + +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..10c296f6 --- /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: {}, format: 'terraform' }); +} + +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..1f67d484 --- /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: {}, format: 'terraform' }); +} + +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..23601ec6 --- /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: {}, format: 'terraform' }); +} + +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..e64a5d23 --- /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: {}, format: 'terraform' }); +} + +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..d082a7a1 --- /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 }, format: 'terraform' }); +} + +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..c2386c7a --- /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: {}, format: 'terraform' }); +} + +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/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 new file mode 100644 index 00000000..0520c525 --- /dev/null +++ b/packages/blueprint-tester/src/hcl-parser.ts @@ -0,0 +1,185 @@ +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, + rawContent: 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..db5e24fd --- /dev/null +++ b/packages/blueprint-tester/src/index.ts @@ -0,0 +1,20 @@ +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, + 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..7405e790 --- /dev/null +++ b/packages/blueprint-tester/src/report-formatter.ts @@ -0,0 +1,77 @@ +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(`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(''); + + 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..3541c1a7 --- /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, format } = 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, + }); + } + } + + // 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) { + 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, + }); + } + } + }); + } + } + } + + 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..c36b70b7 --- /dev/null +++ b/packages/blueprint-tester/src/rules/dangling-uuids.ts @@ -0,0 +1,83 @@ +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 do not match any resource in the blueprint', + severity: 'error', + + validate(context: ValidationContext): ValidationIssue[] { + const issues: ValidationIssue[] = []; + 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) { + for (const resource of file.resources) { + scanAttributeValues(resource.attributes, '', (path, value) => { + if (typeof value !== 'string') 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) { + 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}" 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..606019c2 --- /dev/null +++ b/packages/blueprint-tester/src/rules/email-addresses.ts @@ -0,0 +1,50 @@ +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) { + const content = resource.rawContent ?? resource.rawHcl; + EMAIL_REGEX.lastIndex = 0; + const matches = [...content.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..1fb32166 --- /dev/null +++ b/packages/blueprint-tester/src/rules/environment-urls.ts @@ -0,0 +1,50 @@ +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) { + const content = resource.rawContent ?? resource.rawHcl; + for (const pattern of ENV_URL_PATTERNS) { + pattern.regex.lastIndex = 0; + const matches = [...content.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..6d0fbbac --- /dev/null +++ b/packages/blueprint-tester/src/rules/incomplete-webhooks.ts @@ -0,0 +1,110 @@ +import type { TerraformResource, 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[] = []; + const isTerraform = context.format === 'terraform'; + + for (const file of context.files) { + for (const resource of file.resources) { + if (!WEBHOOK_TYPES.has(resource.type)) continue; + + checkForHardcodedUrl(resource, issues, isTerraform); + 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: 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 (isTerraform) { + 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: TerraformResource, issues: ValidationIssue[]): void { + const rawContent = resource.rawContent ?? resource.rawHcl; + 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(rawContent)) { + 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; + } + } +} + +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?:\/\/[^"']+)/); + 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..396ee5e5 --- /dev/null +++ b/packages/blueprint-tester/src/rules/public-journey-safety.ts @@ -0,0 +1,91 @@ +import type { TerraformResource, 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 */ +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 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 rawContent = resource.rawContent ?? resource.rawHcl; + const isPublic = isPublicJourney(resource.attributes, rawContent); + if (!isPublic) continue; + + checkCriticalRefs(resource, issues, isTerraform); + checkOrgIdInJourney(resource, rawContent, issues); + } + } + + return issues; + }, +}; + +function isPublicJourney(attrs: Record, rawContent: string): boolean { + if (attrs.access_mode === 'PUBLIC') 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: 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; + // 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 reference should be properly managed to prevent submissions routing to the source org.`, + file: resource.file, + line: resource.lineStart, + resourceAddress: resource.address, + attributePath: key, + value, + }); + } + } +} + +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', + 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..de586825 --- /dev/null +++ b/packages/blueprint-tester/src/rules/source-org-refs.ts @@ -0,0 +1,96 @@ +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 not transfer between orgs', + severity: 'error', + + validate(context: ValidationContext): ValidationIssue[] { + const issues: ValidationIssue[] = []; + 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; + // 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)) { + 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 not transfer between orgs.`, + file: resource.file, + line: resource.lineStart, + resourceAddress: resource.address, + attributePath: path, + value, + }); + } + }); + + // 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({ + 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..45026593 --- /dev/null +++ b/packages/blueprint-tester/src/rules/token-detection.ts @@ -0,0 +1,58 @@ +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) { + const content = resource.rawContent ?? resource.rawHcl; + for (const pattern of TOKEN_PATTERNS) { + // Reset regex state + pattern.regex.lastIndex = 0; + const matches = [...content.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..bcd75139 --- /dev/null +++ b/packages/blueprint-tester/src/types.ts @@ -0,0 +1,135 @@ +export type Severity = 'error' | 'warning' | 'info'; + +export type BlueprintFormat = 'terraform' | 'json'; + +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; + format: BlueprintFormat; + blueprintId?: string; + }; +} + +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; +} + +/** 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; + address: string; + attributes: Record; + dependsOn: string[]; + rawHcl: string; + rawContent: 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; + format: BlueprintFormat; +} + +export interface ResourceIndex { + 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 new file mode 100644 index 00000000..0d0e3c32 --- /dev/null +++ b/packages/blueprint-tester/src/utils/resource-index.ts @@ -0,0 +1,63 @@ +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 allAddresses = new Set(); + const allUuidsInIdentifiers = new Set(); + + for (const file of files) { + for (const resource of file.resources) { + 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(blueprintResource); + byType.set(resource.type, existing); + + 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..c9a804d3 --- /dev/null +++ b/packages/blueprint-tester/src/validator.ts @@ -0,0 +1,151 @@ +import { type BlueprintInput, normalizeBlueprintInput } from './adapters/index.js'; +import { allRules } from './rules/index.js'; +import type { + BlueprintData, + BlueprintFormat, + ParsedTerraformFile, + Severity, + ValidationContext, + ValidationIssue, + ValidationReport, + ValidationRule, + ValidatorOptions, +} from './types.js'; +import { buildResourceIndexFromData } from './utils/resource-index.js'; + +const SEVERITY_LEVELS: Record = { error: 0, warning: 1, info: 2 }; + +/** Validate a blueprint from any supported input format */ +export async function validateBlueprint( + input: BlueprintInput, + 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 any supported blueprint input */ + async validate(input: BlueprintInput): Promise { + const data = normalizeBlueprintInput(input); + return this.validateData(data); + } + + /** Run validation on already-normalized BlueprintData */ + async validateData(data: BlueprintData): Promise { + if (data.resources.length === 0) { + return this.emptyReport(data.sourceFiles, data.format); + } + + const resourceIndex = buildResourceIndexFromData(data); + + // Wrap resources into ParsedTerraformFile structure for rule compatibility + const files = this.buildFiles(data); + + const context: ValidationContext = { + files, + resourceIndex, + options: this.options, + format: data.format, + }; + + // 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); + } + } + } + + 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 resource of data.resources) { + resourceTypes[resource.type] = (resourceTypes[resource.type] ?? 0) + 1; + } + + return { + valid: errors === 0, + summary: { + errors, + warnings, + infos, + filesScanned: data.sourceFiles.length, + resourcesFound: data.resources.length, + }, + issues: allIssues, + metadata: { + validatedAt: new Date().toISOString(), + 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]; + } + const selected = new Set(this.options.rules); + return allRules.filter((r) => selected.has(r.id)); + } + + 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: {}, format }, + }; + } +} 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..63a5e13a --- /dev/null +++ b/packages/cli/src/commands/blueprint-test.ts @@ -0,0 +1,70 @@ +import { defineCommand } from 'citty'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +export default defineCommand({ + meta: { + name: 'blueprint-test', + description: 'Validate a blueprint for common issues (ZIP file, JSON file, or blueprint manifest)', + }, + args: { + file: { + type: 'positional', + description: 'Path to blueprint .zip or .json 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); + let input: Buffer | string; + + 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, + 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),