diff --git a/.github/workflows/bindings.yml b/.github/workflows/bindings.yml new file mode 100644 index 00000000..6e9e886f --- /dev/null +++ b/.github/workflows/bindings.yml @@ -0,0 +1,247 @@ +name: Bindings + +on: + push: + branches: [ master ] + paths: + - 'bindings/**' + - 'include/naab/public/**' + - 'src/api/**' + - 'CMakeLists.txt' + pull_request: + branches: [ master ] + paths: + - 'bindings/**' + - 'include/naab/public/**' + - 'src/api/**' + - 'CMakeLists.txt' + workflow_dispatch: + +jobs: + build-shared-lib: + name: Build libnaab-governance + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + cmake ninja-build libsqlite3-dev python3-dev \ + libssl-dev libffi-dev libcurl4-openssl-dev pkg-config + + - name: Build shared library + run: | + cmake -B build -DCMAKE_BUILD_TYPE=Release -G Ninja + ninja -C build naab_governance naab-gov -j$(nproc) + # Verify shared lib was built + ls -la build/libnaab-governance.so* + + - name: Prepare artifacts + run: | + mkdir -p artifacts + # Copy the real .so file (not symlinks — artifacts don't preserve them) + cp build/libnaab-governance.so.*.* artifacts/libnaab-governance.so 2>/dev/null || \ + cp build/libnaab-governance.so artifacts/libnaab-governance.so + cp build/naab-gov artifacts/ + ls -la artifacts/ + + - name: Upload shared library + uses: actions/upload-artifact@v4 + with: + name: libnaab-governance + path: artifacts/ + retention-days: 1 + + test-go: + name: Go Bindings + needs: build-shared-lib + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.21' + cache: false + + - name: Download shared library + uses: actions/download-artifact@v4 + with: + name: libnaab-governance + path: build + + - name: Install shared library + run: | + sudo cp build/libnaab-governance.so /usr/local/lib/ + sudo ldconfig + + - name: Run Go tests + working-directory: bindings/go + run: | + CGO_LDFLAGS="-L/usr/local/lib" \ + go test -v ./naabgov/ + + test-rust: + name: Rust Bindings + needs: build-shared-lib + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - name: Download shared library + uses: actions/download-artifact@v4 + with: + name: libnaab-governance + path: build + + - name: Install shared library + run: | + sudo cp build/libnaab-governance.so /usr/local/lib/ + sudo ldconfig + + - name: Run Rust tests + working-directory: bindings/rust + run: | + NAAB_LIB_DIR=/usr/local/lib cargo test --verbose + + test-java: + name: Java Bindings + needs: build-shared-lib + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Download shared library + uses: actions/download-artifact@v4 + with: + name: libnaab-governance + path: build + + - name: Install shared library + run: | + sudo cp build/libnaab-governance.so /usr/local/lib/ + sudo ldconfig + + - name: Build JNI bridge + working-directory: bindings/java + run: | + # Compile Java source + javac -d classes src/main/java/org/naab/governance/GovernanceEngine.java + + # Build JNI shared library + cc -shared -fPIC -o libnaab-governance-jni.so \ + src/main/c/naab_gov_jni.c \ + -I${JAVA_HOME}/include \ + -I${JAVA_HOME}/include/linux \ + -I../../include/naab/public \ + -L/usr/local/lib \ + -lnaab-governance + + sudo cp libnaab-governance-jni.so /usr/local/lib/ + sudo ldconfig + + - name: Compile tests + working-directory: bindings/java + run: | + javac -d classes -cp classes \ + src/test/java/org/naab/governance/GovernanceEngineTest.java + + - name: Run Java tests + working-directory: bindings/java + run: | + java -cp classes \ + -Djava.library.path=/usr/local/lib \ + org.naab.governance.GovernanceEngineTest + + test-csharp: + name: C# Bindings + needs: build-shared-lib + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0' + + - name: Download shared library + uses: actions/download-artifact@v4 + with: + name: libnaab-governance + path: build + + - name: Install shared library + run: | + sudo cp build/libnaab-governance.so /usr/local/lib/ + sudo ldconfig + + - name: Run C# tests + working-directory: bindings/csharp + run: | + LD_LIBRARY_PATH=/usr/local/lib dotnet test NaabGovernance.Tests/ --verbosity normal + + test-python: + name: Python Bindings (subprocess) + needs: build-shared-lib + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Download shared library + uses: actions/download-artifact@v4 + with: + name: libnaab-governance + path: build + + - name: Install shared library and CLI + run: | + sudo cp build/libnaab-governance.so /usr/local/lib/ + sudo ldconfig + chmod +x build/naab-gov + + - name: Run Python binding smoke test + run: | + python3 -c " + import sys, os + sys.path.insert(0, 'bindings/python') + + # Test 1: ctypes mode (shared lib available) + os.environ['NAAB_GOV_LIB'] = os.path.abspath('build/libnaab-governance.so') + from naab_governance import GovernanceEngine + + eng = GovernanceEngine() + assert not eng._subprocess_mode, 'should be in ctypes mode' + eng.load_config_string('{\"version\":\"5.0\",\"mode\":\"enforce\"}') + result = eng.scan('python', 'x = 42') + assert not result.get('blocked'), 'safe code should not be blocked' + del eng + print('Python ctypes binding: OK') + + # Test 2: subprocess mode (force by pointing to nonexistent lib) + os.environ['NAAB_GOV_LIB'] = '/nonexistent' + os.environ['NAAB_GOV_BIN'] = os.path.abspath('build/naab-gov') + # Re-import to get fresh instance + import importlib, naab_governance + importlib.reload(naab_governance) + eng2 = naab_governance.GovernanceEngine(lib_path='/nonexistent') + assert eng2._subprocess_mode, 'should be in subprocess mode' + eng2.load_config_dict({'version': '5.0', 'mode': 'enforce'}) + result2 = eng2.scan('python', 'x = 42') + assert 'blocked' in result2, 'result should have blocked field' + print('Python subprocess binding: OK') + " diff --git a/CMakeLists.txt b/CMakeLists.txt index 82b649a2..76ad428e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -91,9 +91,11 @@ if(ENABLE_HARDENING AND NOT MSVC) # Stack protection add_compile_options(-fstack-protector-strong) - # Position-independent code (PIE) for ASLR - add_compile_options(-fPIE) - add_link_options(-pie) + # Position-independent code for ASLR + # CMAKE_POSITION_INDEPENDENT_CODE=ON (line 33) already adds -fPIC globally, + # which is a superset of -fPIE. We only need -pie for the linker on executables + # (-pie conflicts with -shared, so we scope it to CMAKE_EXE_LINKER_FLAGS). + set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -pie") # Fortify source (buffer overflow detection) — requires -O1+, inactive in Debug if(NOT CMAKE_BUILD_TYPE STREQUAL "Debug") diff --git a/bindings/csharp/NaabGovernance.Tests/GovernanceEngineTests.cs b/bindings/csharp/NaabGovernance.Tests/GovernanceEngineTests.cs new file mode 100644 index 00000000..32872ad7 --- /dev/null +++ b/bindings/csharp/NaabGovernance.Tests/GovernanceEngineTests.cs @@ -0,0 +1,77 @@ +using Xunit; +using NaabGovernance; + +namespace NaabGovernance.Tests; + +public class GovernanceEngineTests +{ + private const string TestConfig = + @"{""version"":""3.0"",""mode"":""enforce"", + ""restrictions"":{""dangerous_calls"":{""level"":""hard""}, + ""code_injection"":{""level"":""hard""}}, + ""code_quality"":{""no_secrets"":{""level"":""hard""}}}"; + + [Fact] + public void Version_ReturnsNonEmpty() + { + var v = GovernanceEngine.Version; + Assert.False(string.IsNullOrEmpty(v)); + } + + [Fact] + public void Lifecycle_CreateAndDispose() + { + using var engine = new GovernanceEngine(); + Assert.NotNull(engine); + } + + [Fact] + public void Lifecycle_DoubleDispose() + { + var engine = new GovernanceEngine(); + engine.Dispose(); + engine.Dispose(); // should not throw + } + + [Fact] + public void ScanSafeCode_NotBlocked() + { + using var engine = new GovernanceEngine(); + engine.LoadConfigString(TestConfig); + Assert.True(engine.IsActive); + + var result = engine.Scan("python", "x = 42\nprint(x)", "test.py", 1); + Assert.NotNull(result); + Assert.False(engine.WasBlocked); + } + + [Fact] + public void ScanDangerousCode_Blocked() + { + using var engine = new GovernanceEngine(); + engine.LoadConfigString(TestConfig); + + engine.Scan("python", "import os; os.system('rm -rf /')", "test.py", 1); + Assert.True(engine.WasBlocked); + } + + [Fact] + public void Reset_ClearsResults() + { + using var engine = new GovernanceEngine(); + engine.LoadConfigString(TestConfig); + + engine.Scan("python", "eval(input())", "test.py", 1); + Assert.True(engine.ResultCount > 0); + + engine.Reset(); + Assert.Equal(0, engine.ResultCount); + } + + [Fact] + public void IsActive_FalseBeforeConfig() + { + using var engine = new GovernanceEngine(); + Assert.False(engine.IsActive); + } +} diff --git a/bindings/csharp/NaabGovernance.Tests/NaabGovernance.Tests.csproj b/bindings/csharp/NaabGovernance.Tests/NaabGovernance.Tests.csproj new file mode 100644 index 00000000..ff1a6320 --- /dev/null +++ b/bindings/csharp/NaabGovernance.Tests/NaabGovernance.Tests.csproj @@ -0,0 +1,15 @@ + + + net8.0 + false + true + + + + + + + + + + diff --git a/bindings/java/src/main/c/naab_gov_jni.c b/bindings/java/src/main/c/naab_gov_jni.c index 09fa2d56..7e1184b8 100644 --- a/bindings/java/src/main/c/naab_gov_jni.c +++ b/bindings/java/src/main/c/naab_gov_jni.c @@ -8,6 +8,7 @@ */ #include +#include #include #include "naab_governance.h" diff --git a/bindings/java/src/test/java/org/naab/governance/GovernanceEngineTest.java b/bindings/java/src/test/java/org/naab/governance/GovernanceEngineTest.java new file mode 100644 index 00000000..dc3280e0 --- /dev/null +++ b/bindings/java/src/test/java/org/naab/governance/GovernanceEngineTest.java @@ -0,0 +1,118 @@ +package org.naab.governance; + +/** + * Tests for NAAb governance JNI bindings. + * + * Runs standalone (no JUnit dependency) — each test method prints PASS/FAIL. + * Exit code 0 = all pass, 1 = any fail. + */ +public class GovernanceEngineTest { + + private static final String TEST_CONFIG = + "{\"version\":\"3.0\",\"mode\":\"enforce\"," + + "\"restrictions\":{\"dangerous_calls\":{\"level\":\"hard\"}," + + "\"code_injection\":{\"level\":\"hard\"}}," + + "\"code_quality\":{\"no_secrets\":{\"level\":\"hard\"}}}"; + + private static int passed = 0; + private static int failed = 0; + + private static void check(String name, boolean condition) { + if (condition) { + System.out.println(" PASS: " + name); + passed++; + } else { + System.out.println(" FAIL: " + name); + failed++; + } + } + + public static void main(String[] args) { + System.out.println(); + System.out.println("--- Java JNI Binding Tests ---"); + System.out.println(); + + testVersion(); + testLifecycle(); + testScanSafeCode(); + testScanDangerousCode(); + testReset(); + testIsActive(); + testResultCount(); + + System.out.println(); + System.out.println("Results: " + passed + "/" + (passed + failed) + " passed"); + System.out.println(); + System.exit(failed > 0 ? 1 : 0); + } + + private static void testVersion() { + String v = GovernanceEngine.version(); + check("version not null", v != null); + check("version not empty", v != null && !v.isEmpty()); + } + + private static void testLifecycle() { + try (GovernanceEngine engine = new GovernanceEngine()) { + check("create engine", engine != null); + } + // double close via try-with-resources then explicit — should not crash + boolean doubleCloseOk = false; + try { + GovernanceEngine engine = new GovernanceEngine(); + engine.close(); + engine.close(); + doubleCloseOk = true; + } catch (Exception e) { + // native crash that propagates as Java exception + } + check("double close safe", doubleCloseOk); + } + + private static void testScanSafeCode() { + try (GovernanceEngine engine = new GovernanceEngine()) { + engine.loadConfigString(TEST_CONFIG); + check("isActive after config", engine.isActive()); + + String result = engine.scan("python", "x = 42\nprint(x)", "test.py", 1); + check("scan returns JSON", result != null && result.contains("blocked")); + check("safe code not blocked", !engine.wasBlocked()); + } + } + + private static void testScanDangerousCode() { + try (GovernanceEngine engine = new GovernanceEngine()) { + engine.loadConfigString(TEST_CONFIG); + engine.scan("python", "import os; os.system('rm -rf /')", "test.py", 1); + check("dangerous code blocked", engine.wasBlocked()); + } + } + + private static void testReset() { + try (GovernanceEngine engine = new GovernanceEngine()) { + engine.loadConfigString(TEST_CONFIG); + engine.scan("python", "eval(input())", "test.py", 1); + check("has results after scan", engine.resultCount() > 0); + + engine.reset(); + check("no results after reset", engine.resultCount() == 0); + } + } + + private static void testIsActive() { + try (GovernanceEngine engine = new GovernanceEngine()) { + check("not active before config", !engine.isActive()); + engine.loadConfigString(TEST_CONFIG); + check("active after config", engine.isActive()); + } + } + + private static void testResultCount() { + try (GovernanceEngine engine = new GovernanceEngine()) { + engine.loadConfigString(TEST_CONFIG); + check("zero results before scan", engine.resultCount() == 0); + engine.scan("python", "eval(input())", "test.py", 1); + check("has results after scan", engine.resultCount() > 0); + } + } +} diff --git a/bindings/python/naab_governance.py b/bindings/python/naab_governance.py index ab92b422..03884fa4 100644 --- a/bindings/python/naab_governance.py +++ b/bindings/python/naab_governance.py @@ -244,29 +244,16 @@ def _subprocess_scan(self, language: str, code: str, config=None) -> dict: """Run governance scan via naab-gov CLI subprocess.""" cmd = [self._subprocess_bin, "check", "--language", language] if config: - # Write temp config file - import tempfile - tmpdir = os.environ.get("TMPDIR", "/tmp") - cfg_fd = tempfile.NamedTemporaryFile( - mode="w", delete=False, dir=tmpdir, suffix=".json", prefix="naab_gov_" - ) - cfg_path = cfg_fd.name - try: - json.dump(config if isinstance(config, dict) else json.loads(config), cfg_fd) - cfg_fd.close() - cmd.extend(["--config", cfg_path]) - proc = subprocess.run( - cmd, input=code, capture_output=True, text=True, timeout=30 - ) - finally: - try: - os.unlink(cfg_path) - except OSError: - pass - else: - proc = subprocess.run( - cmd, input=code, capture_output=True, text=True, timeout=30 + # Pass config inline via --config-string to avoid signature + # verification issues with temp files (Ed25519 .sig required + # when trusted keys are installed) + cfg_json = json.dumps( + config if isinstance(config, dict) else json.loads(config) ) + cmd.extend(["--config-string", cfg_json]) + proc = subprocess.run( + cmd, input=code, capture_output=True, text=True, timeout=30 + ) if proc.returncode == 1: raise RuntimeError(f"naab-gov check failed: {proc.stderr.strip()}") if proc.returncode == 4: diff --git a/bindings/rust/tests/governance_test.rs b/bindings/rust/tests/governance_test.rs new file mode 100644 index 00000000..62432f4d --- /dev/null +++ b/bindings/rust/tests/governance_test.rs @@ -0,0 +1,76 @@ +use naab_governance::{GovernanceEngine, version}; + +const TEST_CONFIG: &str = r#"{ + "version": "3.0", + "mode": "enforce", + "restrictions": { + "dangerous_calls": {"level": "hard"}, + "code_injection": {"level": "hard"} + }, + "code_quality": { + "no_secrets": {"level": "hard"} + } +}"#; + +#[test] +fn test_version() { + let v = version(); + assert!(!v.is_empty(), "version string should not be empty"); +} + +#[test] +fn test_lifecycle() { + let engine = GovernanceEngine::new().expect("create engine"); + drop(engine); // explicit drop should not panic +} + +#[test] +fn test_scan_safe_code() { + let engine = GovernanceEngine::new().unwrap(); + engine.load_config_string(TEST_CONFIG).unwrap(); + assert!(engine.is_active(), "engine should be active after loading config"); + + let result = engine.scan("python", "x = 42\nprint(x)", "test.py", 1).unwrap(); + assert!(!result.blocked, "safe code should not be blocked"); +} + +#[test] +fn test_scan_dangerous_code() { + let engine = GovernanceEngine::new().unwrap(); + engine.load_config_string(TEST_CONFIG).unwrap(); + + let result = engine.scan("python", "import os; os.system('rm -rf /')", "test.py", 1).unwrap(); + assert!(result.blocked, "dangerous code should be blocked"); +} + +#[test] +fn test_reset() { + let engine = GovernanceEngine::new().unwrap(); + engine.load_config_string(TEST_CONFIG).unwrap(); + + engine.scan("python", "eval(input())", "test.py", 1).unwrap(); + assert!(engine.result_count() > 0, "should have results after scan"); + + engine.reset(); + assert_eq!(engine.result_count(), 0, "should have no results after reset"); +} + +#[test] +fn test_is_active() { + let engine = GovernanceEngine::new().unwrap(); + assert!(!engine.is_active(), "should not be active before loading config"); + + engine.load_config_string(TEST_CONFIG).unwrap(); + assert!(engine.is_active(), "should be active after loading config"); +} + +#[test] +fn test_result_count() { + let engine = GovernanceEngine::new().unwrap(); + engine.load_config_string(TEST_CONFIG).unwrap(); + + assert_eq!(engine.result_count(), 0, "no results before scan"); + + engine.scan("python", "eval(input())", "test.py", 1).unwrap(); + assert!(engine.result_count() > 0, "should have results after scanning dangerous code"); +} diff --git a/src/cli/gov_main.cpp b/src/cli/gov_main.cpp index a62e0560..52e1d94e 100644 --- a/src/cli/gov_main.cpp +++ b/src/cli/gov_main.cpp @@ -72,6 +72,7 @@ static void printHelp() { " --language Target language (required: python, javascript, go, ...)\n" " --file Read code from file instead of stdin\n" " --config Use this govern.json instead of auto-discovery\n" + " --config-string Use inline JSON config string\n" " --sarif Output SARIF instead of JSON\n" " --env Apply named environment overlay from govern.json\n" "\n" @@ -265,6 +266,7 @@ static int cmdCheck(const std::vector& args) { std::string language; std::string file_path; std::string config_path; + std::string config_string; std::string env_name; bool sarif_output = false; @@ -276,6 +278,8 @@ static int cmdCheck(const std::vector& args) { file_path = args[++i]; } else if (args[i] == "--config" && i + 1 < args.size()) { config_path = args[++i]; + } else if (args[i] == "--config-string" && i + 1 < args.size()) { + config_string = args[++i]; } else if (args[i] == "--env" && i + 1 < args.size()) { env_name = args[++i]; } else if (args[i] == "--sarif") { @@ -286,6 +290,11 @@ static int cmdCheck(const std::vector& args) { } } + if (!config_string.empty() && !config_path.empty()) { + std::cerr << "naab-gov check: --config and --config-string are mutually exclusive\n"; + return 4; + } + if (language.empty()) { std::cerr << "naab-gov check: --language is required\n" "Usage: naab-gov check --language [--file ] [--config govern.json]\n"; @@ -322,10 +331,25 @@ static int cmdCheck(const std::vector& args) { } // Load governance config + // + // Threat model for --config-string: naab-gov is a user-space CLI tool, + // never setuid/setgid, never runs with elevated privileges. The caller + // already has full control of the process (can pass --no-governance to + // naab-lang, or simply not invoke governance at all). --config-string + // exists for programmatic callers (Python subprocess binding) that need + // to pass config without writing temp files that require Ed25519 signing. + // Signature verification gates file-based configs; inline configs are + // trusted as caller-provided input, same as any other CLI argument. naab::governance::GovernanceEngine engine; bool loaded = false; - if (!config_path.empty()) { + if (!config_string.empty()) { + loaded = engine.loadFromString(config_string); + if (!loaded) { + std::cerr << "naab-gov check: failed to parse config string\n"; + return 4; + } + } else if (!config_path.empty()) { if (!fs::exists(config_path)) { std::cerr << "naab-gov check: govern.json not found: " << config_path << "\n"; return 4; diff --git a/tests/cli/test_naab_gov.sh b/tests/cli/test_naab_gov.sh index 11ac86d5..f6cedcab 100644 --- a/tests/cli/test_naab_gov.sh +++ b/tests/cli/test_naab_gov.sh @@ -75,6 +75,32 @@ MISSING_EXIT=$? check "lint non-existent file returns non-zero" \ "$([ "${MISSING_EXIT}" -ne 0 ] && echo 0 || echo 1)" +# Test 6: check --config-string passes JSON inline +CONFIG_STR_OUT=$(echo "x = 1" | "${GOV}" check --language python --config-string '{"version":"5.0","mode":"enforce"}' 2>&1) +CONFIG_STR_EXIT=$? +check "--config-string exits 0" "$CONFIG_STR_EXIT" +check "--config-string returns JSON" \ + "$(echo "${CONFIG_STR_OUT}" | grep -q '"blocked"' && echo 0 || echo 1)" + +# Test 7: check --config-string with invalid JSON returns exit 4 +echo "x = 1" | "${GOV}" check --language python --config-string '{bad json' 2>/dev/null +BADJSON_EXIT=$? +check "--config-string bad JSON exits 4" \ + "$([ "${BADJSON_EXIT}" -eq 4 ] && echo 0 || echo 1)" + +# Test 8: check --config-string actually applies config (dangerous code → exit 3) +echo 'import os; os.system("rm -rf /")' | "${GOV}" check --language python \ + --config-string '{"version":"5.0","mode":"enforce","restrictions":{"dangerous_calls":{"level":"hard"}}}' 2>/dev/null +DANGEROUS_EXIT=$? +check "--config-string enforces config (exit 3)" \ + "$([ "${DANGEROUS_EXIT}" -eq 3 ] && echo 0 || echo 1)" + +# Test 9: --config and --config-string are mutually exclusive (exit 4) +echo "x = 1" | "${GOV}" check --language python --config /dev/null --config-string '{}' 2>/dev/null +MUTEX_EXIT=$? +check "--config + --config-string mutually exclusive (exit 4)" \ + "$([ "${MUTEX_EXIT}" -eq 4 ] && echo 0 || echo 1)" + echo "" echo "Results: ${PASS}/${TESTS} passed" echo ""