diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 93707a5..ba83255 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,23 @@ jobs: - run: npm install -g npm@latest - - run: npm publish --access public --provenance + - name: Verify version sync + run: node scripts/version.mjs --check + + - name: Publish @guard0/g0 + run: npm publish --access public --provenance + + - name: Publish guard0 wrapper + working-directory: packages/guard0 + run: | + LOCAL_VERSION=$(node -p "require('./package.json').version") + REMOTE_VERSION=$(npm view guard0 version 2>/dev/null || echo "0.0.0") + if [ "$LOCAL_VERSION" = "$REMOTE_VERSION" ]; then + echo "guard0 wrapper v${LOCAL_VERSION} already published — skipping" + else + echo "Publishing guard0 wrapper v${LOCAL_VERSION} (registry has v${REMOTE_VERSION})" + npm publish --access public --provenance + fi - name: Publish OpenClaw plugin working-directory: packages/g0-openclaw-plugin diff --git a/.gitignore b/.gitignore index 032315a..ade1aa0 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,6 @@ GAPS.md docs/openclaw-plugin-validation.md docs/v1.5.0-validation-runbook.md docs/v1.5.0-customer-brief.md +docs/EVALUATION.md +docs/ARCHITECTURE_REVIEW.md tests/integration/openclaw-plugin-hooks.test.mjs diff --git a/package.json b/package.json index 9920a1e..db1881e 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,9 @@ "lint": "tsc --noEmit", "typecheck": "tsc --noEmit", "g0": "tsx bin/g0.ts", - "prepublishOnly": "tsup && vitest run" + "version:check": "node scripts/version.mjs --check", + "version:bump": "node scripts/version.mjs", + "prepublishOnly": "node scripts/version.mjs --check && tsup && vitest run" }, "repository": { "type": "git", @@ -92,7 +94,7 @@ "dependencies": { "chalk": "^5.3.0", "commander": "^12.1.0", -"handlebars": "^4.7.8", + "handlebars": "^4.7.8", "ignore": "^6.0.2", "ora": "^8.1.0", "yaml": "^2.5.0", diff --git a/packages/guard0/README.md b/packages/guard0/README.md new file mode 100644 index 0000000..2b577b3 --- /dev/null +++ b/packages/guard0/README.md @@ -0,0 +1,34 @@ +

+ Guard0 +

+ +

Guard0 — The Control Layer for AI Agents

+ +

+ npm version + Node.js >= 20 + OWASP Agentic +

+ +This is the convenience package for **[Guard0](https://guard0.ai)** (`@guard0/g0`). It lets you install Guard0 without the scoped package name. + +## Install + +```bash +npm install -g guard0 +``` + +## Usage + +```bash +g0 scan ./my-agent # Security assessment +g0 scan https://github.com/org/repo # Remote repo scan +g0 inventory . # AI Bill of Materials +g0 test --target http://localhost:3000/api/chat # Adversarial testing +g0 endpoint # Developer machine assessment +g0 detect # AI tool & MDM detection +``` + +## Documentation + +See the full documentation at **[@guard0/g0](https://github.com/guard0-ai/g0)**. diff --git a/packages/guard0/package.json b/packages/guard0/package.json new file mode 100644 index 0000000..6547617 --- /dev/null +++ b/packages/guard0/package.json @@ -0,0 +1,49 @@ +{ + "name": "guard0", + "version": "1.7.2", + "description": "The control layer for AI agents — discover, assess, and govern your agent infrastructure", + "dependencies": { + "@guard0/g0": "1.7.2" + }, + "bin": { + "g0": "./node_modules/@guard0/g0/dist/bin/g0.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/guard0-ai/g0.git", + "directory": "packages/guard0" + }, + "homepage": "https://guard0.ai", + "bugs": { + "url": "https://github.com/guard0-ai/g0/issues" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "ai", + "agent", + "security", + "guard0", + "ai-security", + "agent-security", + "mcp", + "mcp-security", + "owasp", + "owasp-agentic", + "agentic", + "sast", + "dast", + "adversarial-testing", + "control-layer", + "ai-governance", + "compliance", + "langchain", + "crewai", + "openai", + "vercel-ai", + "llm-security" + ], + "author": "Rakshan AI Inc. (dba Guard0)", + "license": "AGPL-3.0-or-later" +} diff --git a/scripts/version.mjs b/scripts/version.mjs new file mode 100644 index 0000000..ed64f5a --- /dev/null +++ b/scripts/version.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node + +/** + * Version management script for guard0 monorepo. + * + * Usage: + * node scripts/version.mjs + * node scripts/version.mjs patch|minor|major + * node scripts/version.mjs --check # verify all packages are in sync + * + * Updates version across: + * - package.json (@guard0/g0 — main package) + * - packages/guard0/ (guard0 — wrapper / vanity package) + * + * The wrapper's @guard0/g0 dependency is pinned to the exact version (no caret) + * so `npm install guard0@1.8.0` always resolves to `@guard0/g0@1.8.0`. + */ + +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '..'); + +const PACKAGES = [ + { path: 'package.json', label: '@guard0/g0' }, + { path: 'packages/guard0/package.json', label: 'guard0 (wrapper)' }, +]; + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +function readPkg(rel) { + const abs = resolve(root, rel); + return JSON.parse(readFileSync(abs, 'utf-8')); +} + +function writePkg(rel, obj) { + const abs = resolve(root, rel); + writeFileSync(abs, JSON.stringify(obj, null, 2) + '\n'); +} + +function bumpVersion(current, level) { + const [major, minor, patch] = current.split('.').map(Number); + switch (level) { + case 'major': return `${major + 1}.0.0`; + case 'minor': return `${major}.${minor + 1}.0`; + case 'patch': return `${major}.${minor}.${patch + 1}`; + default: throw new Error(`Unknown bump level: ${level}`); + } +} + +function isValidSemver(v) { + return /^\d+\.\d+\.\d+(-[\w.]+)?$/.test(v); +} + +// --------------------------------------------------------------------------- +// --check mode +// --------------------------------------------------------------------------- + +function checkSync() { + const mainPkg = readPkg('package.json'); + const wrapperPkg = readPkg('packages/guard0/package.json'); + const mainVersion = mainPkg.version; + const wrapperVersion = wrapperPkg.version; + const wrapperDep = wrapperPkg.dependencies?.['@guard0/g0']; + + let ok = true; + + if (wrapperVersion !== mainVersion) { + console.error(` MISMATCH guard0 wrapper is ${wrapperVersion}, main is ${mainVersion}`); + ok = false; + } + if (wrapperDep !== mainVersion) { + console.error(` MISMATCH guard0 wrapper depends on @guard0/g0@${wrapperDep}, main is ${mainVersion}`); + ok = false; + } + + if (ok) { + console.log(` OK all packages at v${mainVersion}`); + } else { + console.error('\nRun: node scripts/version.mjs to fix'); + process.exit(1); + } +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + +const arg = process.argv[2]; + +if (!arg) { + console.error('Usage: node scripts/version.mjs '); + process.exit(1); +} + +if (arg === '--check') { + checkSync(); + process.exit(0); +} + +const mainPkg = readPkg('package.json'); +const currentVersion = mainPkg.version; + +const newVersion = ['patch', 'minor', 'major'].includes(arg) + ? bumpVersion(currentVersion, arg) + : arg; + +if (!isValidSemver(newVersion)) { + console.error(`Invalid version: ${newVersion}`); + process.exit(1); +} + +if (newVersion === currentVersion) { + console.log(`Already at v${currentVersion} — nothing to do`); + process.exit(0); +} + +console.log(`\n ${currentVersion} → ${newVersion}\n`); + +// 1. Main package +mainPkg.version = newVersion; +writePkg('package.json', mainPkg); +console.log(` updated @guard0/g0 → ${newVersion}`); + +// 2. Wrapper package — version + pinned dependency +const wrapperPkg = readPkg('packages/guard0/package.json'); +wrapperPkg.version = newVersion; +wrapperPkg.dependencies['@guard0/g0'] = newVersion; +writePkg('packages/guard0/package.json', wrapperPkg); +console.log(` updated guard0 (wrapper) → ${newVersion}`); +console.log(` pinned guard0 → @guard0/g0@${newVersion}`); + +console.log(`\n Done. All packages at v${newVersion}\n`); diff --git a/src/analyzers/engine.ts b/src/analyzers/engine.ts index c03908e..c5ecc50 100644 --- a/src/analyzers/engine.ts +++ b/src/analyzers/engine.ts @@ -29,6 +29,7 @@ const TEST_FILE_PATTERNS = [ /\/examples?\//, /\/docs?\//, /\/tutorials?\//, /\/notebooks?\//, /\/demo\//, /\/samples?\//, /\/quickstart\//, /\/cookbook\//, /\/benchmarks?\//, /\/e2e\//, /\/integration_tests?\//, + /\/\.claude\/worktrees\//, /\/advisories?\//, /\/cve[s-]?\//i, ]; const TEST_SEVERITY_DOWNGRADE: Record = { @@ -94,15 +95,13 @@ export function runAnalysis(graph: AgentGraph, options?: AnalysisOptions): Findi for (const rule of rules) { // For project_missing rules, skip if the control registry shows the control exists - const ruleRecord = rule as Rule & Record; - if (ruleRecord.requiresControl && registry) { - if (registry.hasControl(ruleRecord.requiresControl as SecurityControlType)) continue; + if (rule.requiresControl && registry) { + if (registry.hasControl(rule.requiresControl)) continue; } // For rules with suppressed_by, skip if ALL listed controls are present - if (ruleRecord.suppressedBy && registry) { - const suppressors = ruleRecord.suppressedBy as string[]; - const allPresent = suppressors.every((s: string) => registry.hasControl(s as SecurityControlType)); + if (rule.suppressedBy && registry) { + const allPresent = rule.suppressedBy.every(s => registry.hasControl(s)); if (allPresent) continue; } @@ -191,6 +190,46 @@ export function runAnalysis(graph: AgentGraph, options?: AnalysisOptions): Findi result = result.filter(f => SEVERITY_ORDER[f.severity] <= minLevel); } + // Non-agent project guard: if no agents or tools detected, drop hardening findings + if (graph.agents.length === 0 && graph.tools.length === 0) { + result = result.filter(f => { + // Keep findings that are already categorized as vulnerability + if (f.category === 'vulnerability') return true; + // For uncategorized findings, use title heuristic + const t = f.title.toLowerCase(); + const isAbsence = t.startsWith('no ') || t.startsWith('missing ') || t.startsWith('lacks '); + return !isAbsence; + }); + } + + // Derive finding category (vulnerability vs hardening vs informational) + const absenceCheckTypes = new Set([ + 'prompt_missing', 'tool_missing_property', 'project_missing', 'absence_check', + ]); + for (const f of result) { + if (f.category) continue; // already set by rule + if (f.severity === 'info') { + f.category = 'informational'; + } else if (f.checkType && absenceCheckTypes.has(f.checkType)) { + f.category = 'hardening'; + } else if (f.checkType === 'agent_property') { + const t = f.title.toLowerCase(); + if (t.startsWith('no ') || t.includes('missing') || t.includes('lacks') || t.includes('without')) { + f.category = 'hardening'; + } else { + f.category = 'vulnerability'; + } + } else { + // Default: TS rules with absence-indicating titles + const t = f.title.toLowerCase(); + if (t.startsWith('no ') || t.startsWith('missing ') || t.startsWith('lacks ')) { + f.category = 'hardening'; + } else { + f.category = 'vulnerability'; + } + } + } + return result; } diff --git a/src/analyzers/parsers/shared.ts b/src/analyzers/parsers/shared.ts index bac4038..5d4eea6 100644 --- a/src/analyzers/parsers/shared.ts +++ b/src/analyzers/parsers/shared.ts @@ -117,6 +117,14 @@ export function checkInstructionGuarding(prompt: string): boolean { /do\s+not\s+reveal\s+your\s+(instructions|prompt|system)/i, /instruction\s+(boundary|boundaries)/i, /prompt\s+injection\s+(protect|guard|prevent|detect)/i, + // Broader explicit deny patterns — common in well-written system prompts + /\bMUST\s+NOT\b/, + /\byou\s+(can\s+)?only\b/i, + /\b(do\s+not|don'?t)\s+(disclose|reveal|share|access|execute|modify)/i, + /\b(outside|beyond)\s+(these|those|the)\s+(boundaries|limits|scope|constraints)/i, + /\bpolitely\s+(decline|refuse|reject)/i, + /\bif\s+asked\s+to\s+(do\s+)?anything\s+(outside|beyond|other)/i, + /\brefuse\s+(any|all)\s+(requests?|instructions?)\s+(that|which|to)/i, ]; return guards.some(g => g.test(prompt)); } diff --git a/src/analyzers/rules/data-leakage.ts b/src/analyzers/rules/data-leakage.ts index 0e40e32..bff871b 100644 --- a/src/analyzers/rules/data-leakage.ts +++ b/src/analyzers/rules/data-leakage.ts @@ -1761,11 +1761,15 @@ export const dataLeakageRules: Rule[] = [ for (const file of [...graph.files.python, ...graph.files.typescript, ...graph.files.javascript]) { let content: string; try { content = fs.readFileSync(file.path, 'utf-8'); } catch { continue; } - const pattern = /(?:ConversationBufferMemory|ConversationSummaryMemory|ChatMessageHistory|InMemoryChatMessageHistory|MemorySaver|memory\s*=\s*\{)/gi; + const memPattern = /(?:ConversationBufferMemory|ConversationSummaryMemory|ChatMessageHistory|InMemoryChatMessageHistory|MemorySaver|memory\s*=\s*\{)/gi; let match: RegExpExecArray | null; - while ((match = pattern.exec(content)) !== null) { + while ((match = memPattern.exec(content)) !== null) { + // Skip import/require statements — only flag actual usage + const lineStart = content.lastIndexOf('\n', match.index) + 1; + const lineText = content.substring(lineStart, content.indexOf('\n', match.index)); + if (/^\s*(from\s+|import\s+|const\s+.*require)/.test(lineText)) continue; const region = content.substring(Math.max(0, match.index - 400), Math.min(content.length, match.index + 400)); - if (!/user[_.]?id|tenant[_.]?id|session[_.]?id|per.?user|isolat|partition|namespace.*user/i.test(region)) { + if (!/user[_.]?id|tenant[_.]?id|session[_.]?id|per.?user|isolat|partition|namespace.*user|thread_id|config.*thread|configurable|memory_key\s*=|chat_history/i.test(region)) { const line = content.substring(0, match.index).split('\n').length; findings.push({ id: `AA-DL-046-${findings.length}`, ruleId: 'AA-DL-046', diff --git a/src/analyzers/rules/human-oversight.ts b/src/analyzers/rules/human-oversight.ts index deedb32..257db50 100644 --- a/src/analyzers/rules/human-oversight.ts +++ b/src/analyzers/rules/human-oversight.ts @@ -126,7 +126,7 @@ export const humanOversightRules: Rule[] = [ /* ---------- Override Mechanisms ---------- */ { id: 'AA-HO-005', name: 'No emergency stop mechanism', domain: 'human-oversight', - severity: 'critical', confidence: 'medium', + severity: 'medium', confidence: 'medium', description: 'Agent system has no emergency stop or kill switch for human operators.', frameworks: ['all'], owaspAgentic: ['ASI09'], standards: STD_EXT, check: (graph: AgentGraph): Finding[] => { @@ -142,8 +142,9 @@ export const humanOversightRules: Rule[] = [ } if (!hasKillSwitch && graph.agents.length > 0) { findings.push({ id: 'AA-HO-005-0', ruleId: 'AA-HO-005', title: 'No emergency stop mechanism', - description: 'No kill switch or emergency stop found in agent system', severity: 'critical', confidence: 'medium', domain: 'human-oversight', + description: 'No kill switch or emergency stop found in agent system', severity: 'medium', confidence: 'medium', domain: 'human-oversight', location: { file: graph.agents[0]?.file ?? 'unknown', line: 1 }, + checkType: 'absence_check', category: 'hardening', remediation: 'Implement an emergency stop mechanism accessible to operators', standards: STD_EXT }); } return findings; diff --git a/src/analyzers/rules/identity-access.ts b/src/analyzers/rules/identity-access.ts index 6b5497a..b3c90e2 100644 --- a/src/analyzers/rules/identity-access.ts +++ b/src/analyzers/rules/identity-access.ts @@ -1010,7 +1010,7 @@ export const identityAccessRules: Rule[] = [ id: 'AA-IA-030', name: 'No RBAC enforcement', domain: 'identity-access', - severity: 'high', + severity: 'medium', confidence: 'medium', description: 'Codebase lacks role-based access control patterns for agent or user actions.', frameworks: ['all'], @@ -1033,8 +1033,9 @@ export const identityAccessRules: Rule[] = [ id: 'AA-IA-030-0', ruleId: 'AA-IA-030', title: 'No RBAC enforcement detected', description: 'No role-based access control patterns found in the codebase. Agent actions may lack authorization checks.', - severity: 'high', confidence: 'medium', domain: 'identity-access', + severity: 'medium', confidence: 'medium', domain: 'identity-access', location: { file: graph.files.all[0]?.relativePath ?? 'project', line: 1 }, + checkType: 'absence_check', category: 'hardening', remediation: 'Implement role-based access control to restrict agent and user actions based on assigned roles.', standards: { owaspAgentic: ['ASI03'], aiuc1: ['B001'], iso42001: ['A.6.2'], nistAiRmf: ['MANAGE-2.1'] }, }); diff --git a/src/analyzers/rules/rogue-agent.ts b/src/analyzers/rules/rogue-agent.ts index 83212aa..a257c69 100644 --- a/src/analyzers/rules/rogue-agent.ts +++ b/src/analyzers/rules/rogue-agent.ts @@ -274,7 +274,7 @@ export const rogueAgentRules: Rule[] = [ /* ---------- Kill Switch ---------- */ { id: 'AA-RA-011', name: 'No kill switch mechanism', domain: 'rogue-agent', - severity: 'critical', confidence: 'medium', + severity: 'medium', confidence: 'medium', description: 'Agent system has no mechanism for immediate shutdown by operators.', frameworks: ['all'], owaspAgentic: ['ASI10'], standards: STD_EXT, check: (graph: AgentGraph): Finding[] => { @@ -290,8 +290,9 @@ export const rogueAgentRules: Rule[] = [ } if (!hasKillSwitch && graph.agents.length > 0) { findings.push({ id: 'AA-RA-011-0', ruleId: 'AA-RA-011', title: 'No kill switch', - description: 'No kill switch or emergency shutdown mechanism found', severity: 'critical', confidence: 'medium', domain: 'rogue-agent', + description: 'No kill switch or emergency shutdown mechanism found', severity: 'medium', confidence: 'medium', domain: 'rogue-agent', location: { file: graph.agents[0]?.file ?? 'unknown', line: 1 }, + checkType: 'absence_check', category: 'hardening', remediation: 'Implement a kill switch accessible to operators', standards: STD_EXT }); } return findings; diff --git a/src/analyzers/rules/tool-safety.ts b/src/analyzers/rules/tool-safety.ts index db9c6dd..c589acf 100644 --- a/src/analyzers/rules/tool-safety.ts +++ b/src/analyzers/rules/tool-safety.ts @@ -801,11 +801,14 @@ export const toolSafetyRules: Rule[] = [ for (const file of [...graph.files.python, ...graph.files.typescript, ...graph.files.javascript]) { let content: string; try { content = fs.readFileSync(file.path, 'utf-8'); } catch { continue; } - const toolContext = /(?:@tool|def\s+\w+_tool|StructuredTool|BaseTool|\.tool\()/; - if (!toolContext.test(content)) continue; + const toolCtx = /(?:@tool|def\s+\w+_tool|StructuredTool|BaseTool|\.tool\()/; + if (!toolCtx.test(content)) continue; const pattern = /(?:requests\.(?:get|post|put|delete)|fetch|urllib|httpx\.(?:get|post)|aiohttp)/g; let match: RegExpExecArray | null; while ((match = pattern.exec(content)) !== null) { + // Narrow: network call must be near a tool definition (within ~2000 chars) + const nearby = content.substring(Math.max(0, match.index - 2000), Math.min(content.length, match.index + 500)); + if (!toolCtx.test(nearby)) continue; const region = content.substring(Math.max(0, match.index - 500), Math.min(content.length, match.index + 500)); if (!/allowlist|whitelist|allowed_url|allowed_domain|url_filter|domain_check/i.test(region)) { const line = content.substring(0, match.index).split('\n').length; diff --git a/src/cli/branding.ts b/src/cli/branding.ts index 8d34a29..ba67e9a 100644 --- a/src/cli/branding.ts +++ b/src/cli/branding.ts @@ -17,15 +17,15 @@ function loadVersion(): string { export function printBanner(): void { const logo = chalk.bold.cyan(` - ██████╗ ██████╗ - ██╔════╝ ██╔═████╗ - ██║ ███╗██║██╔██║ - ██║ ██║████╔╝██║ - ╚██████╔╝╚██████╔╝ - ╚═════╝ ╚═════╝ + ██████╗ ██╗ ██╗ █████╗ ██████╗ ██████╗ ██████╗ + ██╔════╝ ██║ ██║██╔══██╗██╔══██╗██╔══██╗██╔═████╗ + ██║ ███╗██║ ██║███████║██████╔╝██║ ██║██║██╔██║ + ██║ ██║██║ ██║██╔══██║██╔══██╗██║ ██║████╔╝██║ + ╚██████╔╝╚██████╔╝██║ ██║██║ ██║██████╔╝╚██████╔╝ + ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═════╝ `); - const tagline = chalk.dim(' Security Control Layer for AI Agents'); - const version = chalk.dim(` v${loadVersion()} by Guard0`); + const tagline = chalk.dim(' The Control Layer for AI Agents'); + const version = chalk.dim(` v${loadVersion()} — guard0.ai`); console.log(logo + tagline + '\n' + version + '\n'); } diff --git a/src/cli/commands/scan.ts b/src/cli/commands/scan.ts index eb261ce..3103ca2 100644 --- a/src/cli/commands/scan.ts +++ b/src/cli/commands/scan.ts @@ -42,6 +42,7 @@ export const scanCommand = new Command('scan') .option('--fix', 'Auto-fix failed deployment audit checks (use with --openclaw-audit)') .option('--ci', 'CI/CD gate mode — evaluate against .g0-policy.yaml and exit with policy-based exit code') .option('--host-audit', 'Run OS-level host hardening audit (firewall, encryption, SSH, etc.)') + .option('--strict', 'Exit with code 2 if any critical finding exists') .option('--no-banner', 'Suppress the g0 banner') .action(async (targetPath: string, options: { json?: boolean; @@ -67,6 +68,7 @@ export const scanCommand = new Command('scan') fix?: boolean; ci?: boolean; hostAudit?: boolean; + strict?: boolean; banner?: boolean; preset?: string; aiConsensus?: number; @@ -590,6 +592,13 @@ export const scanCommand = new Command('scan') } } } + // --strict: exit with code 2 if any critical finding exists + if (options.strict && result.findings.some(f => f.severity === 'critical')) { + if (!options.quiet) { + console.error(`\n g0 strict mode: ${result.findings.filter(f => f.severity === 'critical').length} critical finding(s) — exiting with code 2`); + } + process.exit(2); + } } catch (error) { spinner?.stop(); const scanMsg = error instanceof Error ? error.message : String(error); diff --git a/src/cli/index.ts b/src/cli/index.ts index e96806d..d68aacd 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -23,7 +23,7 @@ export function createCli(): Command { const opts = actionCommand.opts(); // Suppress banner for machine-readable outputs if (opts.json || opts.sarif || opts.quiet || opts.banner === false) return; - if (opts.markdown) return; + if (opts.markdown || opts.cyclonedx || opts.output) return; printBanner(); }); diff --git a/src/index.ts b/src/index.ts index fa0da27..73e6519 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ export { runScan, runDiscovery, runGraphBuild } from './pipeline.js'; export type { ScanOptions, DiscoveryResult } from './pipeline.js'; export type { ScanResult, ScanScore, DomainScore } from './types/score.js'; -export type { Finding, FindingSummary } from './types/finding.js'; +export type { BaseFinding, Finding, FindingCategory, FindingSummary } from './types/finding.js'; export type { AgentGraph, AgentNode, ToolNode, PromptNode, ModelNode, VectorDBNode, FrameworkInfo } from './types/agent-graph.js'; export type { Severity, Confidence, FrameworkId, Grade, SecurityDomain } from './types/common.js'; export type { Rule } from './types/control.js'; @@ -55,3 +55,28 @@ export type { HeartbeatResponse, PlatformConfig, } from './platform/types.js'; + +// Platform Zod schemas (for guard0-platform to validate uploads) +export { + UploadPayloadSchema, + ScanUploadSchema, + InventoryUploadSchema, + MCPUploadSchema, + TestUploadSchema, + FlowsUploadSchema, + EndpointUploadSchema, + HostHardeningUploadSchema, + OpenClawAuditUploadSchema, + EndpointRegisterSchema, + HeartbeatSchema, + // Shared sub-schemas + FindingSchema, + ScanScoreSchema, + ProjectMetaSchema, + MachineMetaSchema, + CIMetaSchema, + SeveritySchema, + ConfidenceSchema, + GradeSchema, +} from './platform/schemas/upload.js'; +export type { ValidatedUploadPayload } from './platform/schemas/upload.js'; diff --git a/src/pipeline.ts b/src/pipeline.ts index 80ecc2a..a201225 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -214,10 +214,14 @@ export async function runScan(options: ScanOptions): Promise { // Meta-analysis is optional } } else { - console.error(' Warning: --ai flag set but no API key found (ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY)'); + throw new Error( + 'AI analysis requested (--ai) but no provider configured.\n' + + 'Set one of: ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY' + ); } - } catch { - // AI analysis is purely additive; failures don't affect base results + } catch (err) { + if (err instanceof Error && err.message.startsWith('AI analysis requested')) throw err; + // Other AI analysis failures are purely additive; don't affect base results } } diff --git a/src/platform/schemas/upload.ts b/src/platform/schemas/upload.ts new file mode 100644 index 0000000..5cda939 --- /dev/null +++ b/src/platform/schemas/upload.ts @@ -0,0 +1,300 @@ +/** + * Zod schemas for all CLI → Platform upload payloads. + * + * These are the single source of truth for the upload API contract. + * Both the CLI (for construction) and the platform (for validation) + * should use these schemas. + */ +import { z } from 'zod'; + +// ─── Shared Enums ───────────────────────────────────────────────────────────── + +export const SeveritySchema = z.enum(['critical', 'high', 'medium', 'low', 'info']); +export const ConfidenceSchema = z.enum(['high', 'medium', 'low']); +export const GradeSchema = z.enum(['A', 'B', 'C', 'D', 'F']); +export const StatusSchema = z.enum(['healthy', 'degraded', 'error', 'offline']); + +// ─── Metadata ───────────────────────────────────────────────────────────────── + +export const GitMetaSchema = z.object({ + remote: z.string().optional(), + branch: z.string().optional(), + commit: z.string().optional(), + dirty: z.boolean().optional(), +}); + +export const ProjectMetaSchema = z.object({ + name: z.string().min(1), + path: z.string(), + git: GitMetaSchema.optional(), +}); + +export const MachineMetaSchema = z.object({ + machineId: z.string().min(1), + hostname: z.string().optional(), + platform: z.string().optional(), + arch: z.string().optional(), + nodeVersion: z.string().optional(), + g0Version: z.string().optional(), +}); + +export const CIMetaSchema = z.object({ + provider: z.string().min(1), + buildId: z.string().optional(), + buildUrl: z.string().optional(), + pipelineId: z.string().optional(), +}); + +// ─── Finding ────────────────────────────────────────────────────────────────── + +export const FindingLocationSchema = z.object({ + file: z.string(), + line: z.number().int().min(0), + snippet: z.string().optional(), +}); + +export const FindingSchema = z.object({ + id: z.string(), + ruleId: z.string(), + title: z.string(), + severity: SeveritySchema, + confidence: ConfidenceSchema, + domain: z.string(), + location: FindingLocationSchema, + description: z.string().optional(), + remediation: z.string().optional(), + category: z.enum(['vulnerability', 'hardening', 'informational']).optional(), + reachability: z.string().optional(), + exploitability: z.string().optional(), + standards: z.record(z.array(z.string())).optional(), +}); + +// ─── Score ──────────────────────────────────────────────────────────────────── + +export const DomainScoreSchema = z.object({ + domain: z.string(), + label: z.string(), + score: z.number().min(0).max(100), + findings: z.number().int().min(0), + critical: z.number().int().min(0), + high: z.number().int().min(0), + medium: z.number().int().min(0), + low: z.number().int().min(0), +}); + +export const ScanScoreSchema = z.object({ + overall: z.number().min(0).max(100), + grade: GradeSchema, + securityScore: z.number().min(0).max(100).optional(), + hardeningScore: z.number().min(0).max(100).optional(), + domains: z.array(DomainScoreSchema), + correlations: z.unknown().optional(), +}); + +// ─── Upload Payloads ────────────────────────────────────────────────────────── + +export const ScanUploadSchema = z.object({ + type: z.literal('scan'), + project: ProjectMetaSchema, + machine: MachineMetaSchema, + ci: CIMetaSchema.optional(), + result: z.object({ + score: ScanScoreSchema, + findings: z.array(FindingSchema), + duration: z.number(), + timestamp: z.string(), + graph: z.unknown().optional(), + analyzability: z.unknown().optional(), + }).passthrough(), +}); + +export const InventoryUploadSchema = z.object({ + type: z.literal('inventory'), + project: ProjectMetaSchema, + machine: MachineMetaSchema, + ci: CIMetaSchema.optional(), + result: z.object({ + summary: z.object({ + totalModels: z.number().int().min(0), + totalFrameworks: z.number().int().min(0), + totalTools: z.number().int().min(0), + totalAgents: z.number().int().min(0), + totalMCPServers: z.number().int().min(0), + }), + }).passthrough(), +}); + +export const MCPUploadSchema = z.object({ + type: z.literal('mcp'), + project: ProjectMetaSchema.optional(), + machine: MachineMetaSchema, + ci: CIMetaSchema.optional(), + result: z.object({ + servers: z.array(z.object({ name: z.string(), command: z.string(), status: z.string() }).passthrough()), + tools: z.array(z.object({ name: z.string() }).passthrough()), + findings: z.array(z.object({ severity: SeveritySchema }).passthrough()), + summary: z.object({ + totalServers: z.number().int(), + totalTools: z.number().int(), + totalFindings: z.number().int(), + }), + }).passthrough(), +}); + +export const TestUploadSchema = z.object({ + type: z.literal('test'), + project: ProjectMetaSchema, + machine: MachineMetaSchema, + ci: CIMetaSchema.optional(), + result: z.object({ + target: z.object({ type: z.string(), endpoint: z.string() }), + summary: z.object({ + total: z.number().int(), + vulnerable: z.number().int(), + resistant: z.number().int(), + inconclusive: z.number().int(), + overallStatus: z.string(), + }), + }).passthrough(), +}); + +export const FlowsUploadSchema = z.object({ + type: z.literal('flows'), + project: ProjectMetaSchema, + machine: MachineMetaSchema, + ci: CIMetaSchema.optional(), + result: z.object({ + nodes: z.array(z.object({ id: z.string(), label: z.string(), type: z.string() })), + edges: z.array(z.object({ from: z.string(), to: z.string(), label: z.string().optional() })), + paths: z.array(z.object({ nodes: z.array(z.string()), riskScore: z.number(), description: z.string() })), + toxicFlows: z.array(z.object({ + severity: SeveritySchema, + title: z.string(), + description: z.string(), + path: z.array(z.string()), + riskScore: z.number(), + })), + summary: z.object({ + totalNodes: z.number().int(), + totalEdges: z.number().int(), + totalPaths: z.number().int(), + toxicFlowCount: z.number().int(), + maxRiskScore: z.number(), + riskLevel: z.string(), + }), + }).passthrough(), +}); + +export const EndpointUploadSchema = z.object({ + type: z.literal('endpoint'), + machine: MachineMetaSchema, + result: z.object({ + machineId: z.string(), + hostname: z.string(), + timestamp: z.string(), + tools: z.array(z.object({ + name: z.string(), + installed: z.boolean(), + running: z.boolean(), + mcpServerCount: z.number().int(), + }).passthrough()), + summary: z.object({ + totalTools: z.number().int(), + runningTools: z.number().int(), + totalServers: z.number().int(), + totalFindings: z.number().int(), + overallStatus: z.string(), + }).passthrough(), + score: z.object({ + total: z.number().min(0).max(100), + grade: GradeSchema, + }).passthrough(), + duration: z.number(), + }).passthrough(), +}); + +export const HostHardeningUploadSchema = z.object({ + type: z.literal('host-hardening'), + machine: MachineMetaSchema, + result: z.object({ + checks: z.array(z.object({ + id: z.string(), + name: z.string(), + severity: SeveritySchema, + status: z.string(), + detail: z.string(), + })), + platform: z.string(), + summary: z.object({ + total: z.number().int(), + passed: z.number().int(), + failed: z.number().int(), + skipped: z.number().int(), + errors: z.number().int(), + }), + }), +}); + +export const OpenClawAuditUploadSchema = z.object({ + type: z.literal('openclaw-audit'), + machine: MachineMetaSchema, + result: z.object({ + checks: z.array(z.object({ + id: z.string(), + name: z.string(), + severity: SeveritySchema, + status: z.string(), + detail: z.string(), + })), + summary: z.object({ + total: z.number().int(), + pass: z.number().int(), + fail: z.number().int(), + warn: z.number().int(), + skip: z.number().int(), + error: z.number().int(), + }), + timestamp: z.string(), + duration: z.number(), + }), +}); + +// ─── Discriminated Union ────────────────────────────────────────────────────── + +export const UploadPayloadSchema = z.discriminatedUnion('type', [ + ScanUploadSchema, + InventoryUploadSchema, + MCPUploadSchema, + TestUploadSchema, + FlowsUploadSchema, + EndpointUploadSchema, + HostHardeningUploadSchema, + OpenClawAuditUploadSchema, +]); + +export type ValidatedUploadPayload = z.infer; + +// ─── Endpoint API Schemas ───────────────────────────────────────────────────── + +export const EndpointRegisterSchema = z.object({ + machineId: z.string().min(1), + hostname: z.string(), + platform: z.string(), + arch: z.string(), + g0Version: z.string(), + watchPaths: z.array(z.string()), +}); + +export const HeartbeatSchema = z.object({ + endpointId: z.string().min(1), + machineId: z.string().min(1), + timestamp: z.string(), + status: StatusSchema, + lastScanAt: z.string().optional(), + score: z.number().min(0).max(100).optional(), + scoreDelta: z.number().optional(), + issues: z.array(z.string()).optional(), + openclawStatus: z.enum(['secure', 'warn', 'critical']).optional(), + openclawFailedChecks: z.number().int().optional(), + openclawDriftEvents: z.number().int().optional(), +}); diff --git a/src/platform/upload.ts b/src/platform/upload.ts index 290b16d..8ab5c14 100644 --- a/src/platform/upload.ts +++ b/src/platform/upload.ts @@ -3,6 +3,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; import * as childProcess from 'node:child_process'; import { PlatformClient } from './client.js'; +import { logger } from '../utils/logger.js'; import { getMachineId } from './machine-id.js'; import { isAuthenticated, ensureAuthenticated } from './auth.js'; import type { @@ -48,7 +49,7 @@ export async function uploadResults( } catch (err) { // Non-fatal: log warning but don't fail the scan const msg = err instanceof Error ? err.message : String(err); - console.error(` Upload failed: ${msg}`); + logger.error('Upload failed', { error: msg }); return null; } } diff --git a/src/reporters/json.ts b/src/reporters/json.ts index eff0f80..9b33e23 100644 --- a/src/reporters/json.ts +++ b/src/reporters/json.ts @@ -7,9 +7,19 @@ export interface JsonReport { target: string; framework: string; duration: number; + metadata: { + frameworks: string[]; + agentCount: number; + toolCount: number; + promptCount: number; + modelCount: number; + filesScanned: number; + }; score: { overall: number; grade: string; + securityScore?: number; + hardeningScore?: number; domains: Array<{ domain: string; label: string; @@ -37,9 +47,14 @@ export interface JsonReport { severity: string; confidence: string; domain: string; + category?: string; file: string; line: number; + /** Alias for title — backward compat for consumers expecting 'message' */ + message: string; remediation: string; + /** Alias for remediation — backward compat for consumers expecting 'fix' */ + fix: string; standards: { owaspAgentic: string[]; aiuc1?: string[]; iso42001?: string[]; nistAiRmf?: string[] }; snippet?: string; reachability?: string; @@ -50,6 +65,8 @@ export interface JsonReport { tools: number; prompts: number; files: number; + nodes: Array<{ type: string; name: string; file: string; line?: number }>; + edges: Array<{ source: string; target: string; type: string }>; }; analyzability?: { score: number; @@ -67,15 +84,28 @@ export interface JsonReport { } export function reportJson(result: ScanResult, outputPath?: string): string { + const g = result.graph; + const allFrameworks = [g.primaryFramework, ...g.secondaryFrameworks].filter(Boolean); + const report: JsonReport = { version: '1.0.0', timestamp: result.timestamp, - target: result.graph.rootPath, - framework: result.graph.primaryFramework, + target: g.rootPath, + framework: g.primaryFramework, duration: result.duration, + metadata: { + frameworks: allFrameworks, + agentCount: g.agents.length, + toolCount: g.tools.length, + promptCount: g.prompts.length, + modelCount: g.models.length, + filesScanned: g.files.all.length, + }, score: { overall: result.score.overall, grade: result.score.grade, + securityScore: result.score.securityScore, + hardeningScore: result.score.hardeningScore, domains: result.score.domains.map(d => ({ domain: d.domain, label: d.label, @@ -104,19 +134,28 @@ export function reportJson(result: ScanResult, outputPath?: string): string { severity: f.severity, confidence: f.confidence, domain: f.domain, + category: f.category, file: f.location.file, line: f.location.line, + message: f.title, remediation: f.remediation, + fix: f.remediation, snippet: f.location.snippet || undefined, standards: f.standards, reachability: f.reachability, exploitability: f.exploitability, })), graph: { - agents: result.graph.agents.length, - tools: result.graph.tools.length, - prompts: result.graph.prompts.length, - files: result.graph.files.all.length, + agents: g.agents.length, + tools: g.tools.length, + prompts: g.prompts.length, + files: g.files.all.length, + nodes: [ + ...g.agents.map(a => ({ type: 'agent' as const, name: a.name, file: a.file, line: a.line })), + ...g.tools.map(t => ({ type: 'tool' as const, name: t.name, file: t.file, line: t.line })), + ...g.models.map(m => ({ type: 'model' as const, name: m.name, file: m.file, line: m.line })), + ], + edges: g.edges.map(e => ({ source: e.source, target: e.target, type: e.type })), }, ...(result.analyzability && { analyzability: { diff --git a/src/rules/yaml-compiler.ts b/src/rules/yaml-compiler.ts index cffa0ef..e755571 100644 --- a/src/rules/yaml-compiler.ts +++ b/src/rules/yaml-compiler.ts @@ -88,6 +88,9 @@ function buildCheckFunction(yaml: YamlRule): (graph: AgentGraph) => Finding[] { const severity = yaml.info.severity; const confidence = yaml.info.confidence; const standards = mapStandards(yaml.info.standards); + const frameworks = yaml.info.frameworks.includes('all') + ? ['langchain', 'crewai', 'mcp', 'openai', 'vercel-ai', 'bedrock', 'autogen', 'langchain4j', 'spring-ai', 'golang-ai', 'generic'] + : yaml.info.frameworks; switch (check.type) { case 'prompt_contains': @@ -241,6 +244,11 @@ function buildCheckFunction(yaml: YamlRule): (graph: AgentGraph) => Finding[] { case 'agent_property': return (graph) => { const findings: Finding[] = []; + // Skip if framework filter doesn't match the project + if (!frameworks.includes('generic') && !frameworks.includes('all')) { + const projectFrameworks = [graph.primaryFramework, ...graph.secondaryFrameworks]; + if (!frameworks.some(f => projectFrameworks.includes(f as typeof graph.primaryFramework))) return findings; + } // Inter-agent rules only apply when multiple agents exist if (domain === 'inter-agent' && graph.agents.length < 2) return findings; // Cap agent_property findings at 3 per rule per scan to reduce noise. diff --git a/src/scoring/engine.ts b/src/scoring/engine.ts index b15c19b..f40720e 100644 --- a/src/scoring/engine.ts +++ b/src/scoring/engine.ts @@ -31,6 +31,8 @@ const ALL_DOMAINS: SecurityDomain[] = [ const ABSENCE_CHECK_TYPES = new Set([ 'prompt_missing', 'tool_missing_property', + 'project_missing', + 'absence_check', ]); /** @@ -39,12 +41,21 @@ const ABSENCE_CHECK_TYPES = new Set([ * Absence-based: something good is MISSING (no guarding, no validation, no boundary). */ function isAbsenceBased(finding: Finding): boolean { + // Use explicit category if already set + if (finding.category === 'hardening') return true; + if (finding.category === 'vulnerability') return false; + // Fallback to checkType-based heuristic if (finding.checkType && ABSENCE_CHECK_TYPES.has(finding.checkType)) return true; - // agent_property with "missing" in the title/description is absence-based - if (finding.checkType === 'agent_property' && - (finding.title.toLowerCase().includes('missing') || finding.title.toLowerCase().includes('no '))) { - return true; + // agent_property with absence-indicating title + if (finding.checkType === 'agent_property') { + const t = finding.title.toLowerCase(); + if (t.startsWith('no ') || t.includes('missing') || t.includes('lacks') || t.includes('without')) { + return true; + } } + // TS rules that don't set checkType — detect by title pattern + const t = finding.title.toLowerCase(); + if (t.startsWith('no ') || t.startsWith('missing ') || t.startsWith('lacks ')) return true; return false; } @@ -103,10 +114,10 @@ export function calculateScore( let score = computeDomainScore(domainFindings, thresholds); - // Apply attack chain bonus deduction + // Apply attack chain bonus deduction, capped at 50% of remaining score const bonus = chainBonus.get(domain) ?? 0; if (bonus > 0) { - score = Math.max(0, score - bonus); + score = Math.max(0, score - Math.min(bonus, score * 0.5)); } const weight = domainWeightOverrides?.[domain] ?? DOMAIN_WEIGHTS[domain]; @@ -126,7 +137,21 @@ export function calculateScore( const totalWeight = domains.reduce((sum, d) => sum + d.weight, 0); const weightedSum = domains.reduce((sum, d) => sum + d.score * d.weight, 0); - const overall = Math.round(weightedSum / totalWeight); + let overall = Math.round(weightedSum / totalWeight); + + // Critical-floor clamp: presence-of-vulnerability criticals cap the overall grade. + // This prevents a project with shell injection from getting an A/B just because + // 10 other domains are clean. + const criticalVulnCount = findings.filter(f => + f.severity === 'critical' && !isAbsenceBased(f) + ).length; + if (criticalVulnCount >= 3) { + overall = Math.min(overall, 59); // max grade F + } else if (criticalVulnCount >= 2) { + overall = Math.min(overall, 69); // max grade D + } else if (criticalVulnCount >= 1) { + overall = Math.min(overall, 79); // max grade C + } // Split scoring: separate presence-based (security) from absence-based (hardening) const securityFindings = findings.filter(f => !isAbsenceBased(f)); diff --git a/src/scoring/weights.ts b/src/scoring/weights.ts index 9c79efb..c8f47f3 100644 --- a/src/scoring/weights.ts +++ b/src/scoring/weights.ts @@ -30,27 +30,40 @@ export const DOMAIN_LABELS: Record = { 'rogue-agent': 'Rogue Agent', }; +/** + * Base score deductions per finding severity. + * A single agent-reachable critical should drop a domain to ~60 (grade D). + * Two agent-reachable criticals should crater a domain to ~20 (grade F). + */ export const SEVERITY_DEDUCTIONS = { - critical: 20, - high: 10, - medium: 4, - low: 1, + critical: 40, + high: 18, + medium: 6, + low: 2, info: 0, } as const; -/** Reachability multipliers — utility code gets 70% reduction */ +/** + * Reachability multipliers — how close the finding is to agent execution. + * Unknown defaults to 0.85 (assume reachable until proven otherwise). + * Utility code gets heavy reduction since it's not on the agent execution path. + */ export const REACHABILITY_MULTIPLIERS: Record = { 'agent-reachable': 1.0, 'tool-reachable': 1.0, 'endpoint-reachable': 0.8, 'utility-code': 0.3, - 'unknown': 0.6, + 'unknown': 0.85, }; -/** Exploitability multipliers — confirmed issues get amplified */ +/** + * Exploitability multipliers — how likely the finding can be exploited. + * Not-assessed defaults to 0.85 (assume exploitable until proven otherwise). + * Confirmed issues get amplified above 1.0. + */ export const EXPLOITABILITY_MULTIPLIERS: Record = { 'confirmed': 1.2, 'likely': 1.0, 'unlikely': 0.4, - 'not-assessed': 0.7, + 'not-assessed': 0.85, }; diff --git a/src/types/agent-graph.ts b/src/types/agent-graph.ts index 438f145..817fe35 100644 --- a/src/types/agent-graph.ts +++ b/src/types/agent-graph.ts @@ -152,6 +152,9 @@ export interface ModelNode { framework: FrameworkId; file: string; line: number; + maxTokens?: number; + temperature?: number; + topP?: number; } export interface VectorDBNode { diff --git a/src/types/control.ts b/src/types/control.ts index 49f5a37..4d70efb 100644 --- a/src/types/control.ts +++ b/src/types/control.ts @@ -1,6 +1,7 @@ import type { SecurityDomain, Severity, Confidence } from './common.js'; import type { AgentGraph } from './agent-graph.js'; import type { Finding } from './finding.js'; +import type { SecurityControlType } from '../analyzers/control-registry.js'; export interface Rule { id: string; @@ -13,4 +14,8 @@ export interface Rule { owaspAgentic: string[]; standards: import('./finding.js').StandardsMapping; check: (graph: AgentGraph) => Finding[]; + /** Controls that suppress this rule when present */ + suppressedBy?: SecurityControlType[]; + /** Control that must be present for this rule to fire */ + requiresControl?: SecurityControlType; } diff --git a/src/types/finding.ts b/src/types/finding.ts index 55be4cb..56ea502 100644 --- a/src/types/finding.ts +++ b/src/types/finding.ts @@ -3,12 +3,29 @@ import type { Severity, Confidence, SecurityDomain, Location } from './common.js export type Reachability = 'agent-reachable' | 'tool-reachable' | 'endpoint-reachable' | 'utility-code' | 'unknown'; export type FindingExploitability = 'confirmed' | 'likely' | 'unlikely' | 'not-assessed'; -export interface Finding { - id: string; - ruleId: string; +/** + * Finding category distinguishes actual vulnerabilities from missing best practices. + * - vulnerability: dangerous code/config IS present (shell injection, SQL injection, etc.) + * - hardening: a recommended control is MISSING (no kill switch, no RBAC, etc.) + * - informational: low-signal observation, no direct security impact + */ +export type FindingCategory = 'vulnerability' | 'hardening' | 'informational'; + +/** + * Base finding interface shared by all finding types (scan, endpoint, MCP, test). + * Provides the minimal set of fields needed for rendering, sorting, and deduplication. + */ +export interface BaseFinding { + severity: Severity; title: string; description: string; - severity: Severity; + category?: FindingCategory; +} + +/** Scan finding — the primary finding type from static and dynamic analysis. */ +export interface Finding extends BaseFinding { + id: string; + ruleId: string; confidence: Confidence; domain: SecurityDomain; location: Location; diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..820f050 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,43 @@ +/** + * Lightweight structured logger for g0. + * + * - TTY: human-readable colored output + * - Non-TTY (CI/pipes): JSON lines for machine parsing + * - Respects G0_LOG_LEVEL env var (error/warn/info/debug) + */ + +const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 } as const; +type LogLevel = keyof typeof LEVELS; + +const currentLevel: LogLevel = (process.env.G0_LOG_LEVEL as LogLevel) ?? 'warn'; +const isTTY = process.stderr.isTTY ?? false; + +function shouldLog(level: LogLevel): boolean { + return LEVELS[level] <= LEVELS[currentLevel]; +} + +function formatTTY(level: LogLevel, message: string, data?: Record): string { + const prefix = level === 'error' ? '\x1b[31m[ERROR]\x1b[0m' + : level === 'warn' ? '\x1b[33m[WARN]\x1b[0m' + : level === 'info' ? '\x1b[36m[INFO]\x1b[0m' + : '\x1b[90m[DEBUG]\x1b[0m'; + const extra = data ? ` ${JSON.stringify(data)}` : ''; + return `${prefix} ${message}${extra}`; +} + +function formatJSON(level: LogLevel, message: string, data?: Record): string { + return JSON.stringify({ level, message, ...data, ts: new Date().toISOString() }); +} + +function log(level: LogLevel, message: string, data?: Record): void { + if (!shouldLog(level)) return; + const formatted = isTTY ? formatTTY(level, message, data) : formatJSON(level, message, data); + process.stderr.write(formatted + '\n'); +} + +export const logger = { + error: (message: string, data?: Record) => log('error', message, data), + warn: (message: string, data?: Record) => log('warn', message, data), + info: (message: string, data?: Record) => log('info', message, data), + debug: (message: string, data?: Record) => log('debug', message, data), +}; diff --git a/tests/unit/exploitability.test.ts b/tests/unit/exploitability.test.ts index 86b5bae..2c93555 100644 --- a/tests/unit/exploitability.test.ts +++ b/tests/unit/exploitability.test.ts @@ -48,14 +48,14 @@ result = eval(os.getenv("INPUT")) expect(REACHABILITY_MULTIPLIERS['tool-reachable']).toBe(1.0); expect(REACHABILITY_MULTIPLIERS['endpoint-reachable']).toBe(0.8); expect(REACHABILITY_MULTIPLIERS['utility-code']).toBe(0.3); - expect(REACHABILITY_MULTIPLIERS['unknown']).toBe(0.6); + expect(REACHABILITY_MULTIPLIERS['unknown']).toBe(0.85); }); it('exploitability multipliers are defined correctly', () => { expect(EXPLOITABILITY_MULTIPLIERS['confirmed']).toBe(1.2); expect(EXPLOITABILITY_MULTIPLIERS['likely']).toBe(1.0); expect(EXPLOITABILITY_MULTIPLIERS['unlikely']).toBe(0.4); - expect(EXPLOITABILITY_MULTIPLIERS['not-assessed']).toBe(0.7); + expect(EXPLOITABILITY_MULTIPLIERS['not-assessed']).toBe(0.85); }); it('scoring engine applies reachability multipliers', async () => { diff --git a/tsup.config.ts b/tsup.config.ts index a078692..85de395 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,26 +1,34 @@ import { defineConfig } from 'tsup'; -export default defineConfig({ - entry: { - 'bin/g0': 'bin/g0.ts', - 'src/index': 'src/index.ts', - 'src/daemon/runner': 'src/daemon/runner.ts', +export default defineConfig([ + // CLI entry point — needs shebang, no splitting (single executable) + { + entry: { 'bin/g0': 'bin/g0.ts' }, + format: ['esm'], + target: 'node20', + sourcemap: true, + splitting: false, + external: [ + 'tree-sitter', 'tree-sitter-python', 'tree-sitter-typescript', + 'tree-sitter-javascript', 'tree-sitter-java', 'tree-sitter-go', + ], + banner: { js: '#!/usr/bin/env node' }, }, - format: ['esm'], - target: 'node20', - dts: true, - sourcemap: true, - clean: true, - splitting: false, - external: [ - 'tree-sitter', - 'tree-sitter-python', - 'tree-sitter-typescript', - 'tree-sitter-javascript', - 'tree-sitter-java', - 'tree-sitter-go', - ], - banner: { - js: '#!/usr/bin/env node', + // SDK + daemon — code-split for smaller imports + { + entry: { + 'src/index': 'src/index.ts', + 'src/daemon/runner': 'src/daemon/runner.ts', + }, + format: ['esm'], + target: 'node20', + dts: true, + sourcemap: true, + clean: true, + splitting: true, + external: [ + 'tree-sitter', 'tree-sitter-python', 'tree-sitter-typescript', + 'tree-sitter-javascript', 'tree-sitter-java', 'tree-sitter-go', + ], }, -}); +]);