From 29ea77ec7ad575e9bd875b2387ec655dac437631 Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Thu, 16 Jul 2026 18:38:42 -0400 Subject: [PATCH 1/2] feat(cpp): expand checks across all categories --- docs/checks.md | 93 +++++++-- docs/features.md | 12 +- docs/security.md | 8 +- .../codeguard/checks/ci/ci_target_files.go | 33 +++ .../checks/ci/ci_test_quality_blocks.go | 30 +++ .../checks/ci/ci_test_quality_patterns.go | 19 ++ .../codeguard/checks/contracts/contracts.go | 1 + .../checks/contracts/cpp_breaking.go | 194 ++++++++++++++++++ internal/codeguard/checks/design/design.go | 2 + .../codeguard/checks/design/design_cpp.go | 52 +++++ .../checks/design/design_graph_cpp.go | 24 +++ .../checks/design/design_graph_rules.go | 2 + .../checks/performance/performance_cpp.go | 36 +++- .../performance/performance_cpp_rebuild.go | 48 +++++ .../performance/performance_go_rebuild.go | 51 +---- .../checks/performance/performance_rebuild.go | 50 +++++ .../performance/performance_scan_language.go | 7 +- .../checks/performance/performance_score.go | 1 + .../checks/quality/quality_scan_language.go | 2 +- .../security/security_additional_languages.go | 2 + .../checks/security/security_common.go | 2 + .../codeguard/checks/security/security_cpp.go | 33 +++ .../checks/supplychain/policy_helpers.go | 2 +- .../checks/support/cpp_dependency_graph.go | 98 +++++++++ .../checks/support/parser_clike_imports.go | 9 +- .../checks/support/scan_language_helpers.go | 32 ++- .../codeguard/checks/support/supply_chain.go | 8 + .../checks/support/supply_chain_cpp.go | 178 ++++++++++++++++ .../checks/support/supply_chain_lockfiles.go | 2 + .../supply_chain_lockfiles_versions.go | 4 + .../codeguard/checks/support/treeprovider.go | 5 +- .../typescript_semantic_runner_bootstrap.js | 7 +- .../typescript_semantic_runner_core.js | 1 - .../codeguard/config/defaults_contracts.go | 3 + internal/codeguard/config/example.go | 1 + .../codeguard/core/config_contract_types.go | 1 + internal/codeguard/core/config_rule_types.go | 8 +- internal/codeguard/rules/catalog_contracts.go | 10 + internal/codeguard/rules/catalog_design.go | 20 ++ .../codeguard/rules/catalog_design_graph.go | 12 ++ .../rules/catalog_fix_templates_design.go | 3 + .../rules/catalog_fix_templates_misc.go | 1 + .../catalog_fix_templates_performance.go | 3 + .../catalog_fix_templates_security_lang.go | 5 +- .../codeguard/rules/catalog_performance.go | 30 +++ internal/codeguard/rules/catalog_security.go | 30 +++ .../codeguard/rules/catalog_security_owasp.go | 3 + .../codeguard/rules/catalog_test_quality.go | 7 +- tests/checks/ci_additional_languages_test.go | 1 + tests/checks/ci_test_quality_cpp_test.go | 84 ++++++++ tests/checks/contracts_test.go | 52 +++++ tests/checks/design_cpp_graph_test.go | 98 +++++++++ tests/checks/performance_cpp_rebuild_test.go | 59 ++++++ .../security_additional_languages_test.go | 8 + tests/checks/supplychain_test.go | 72 +++++++ tests/support/clike_parser_test.go | 4 + tests/support/scan_language_helpers_test.go | 59 ++++++ tests/support/treeprovider_test.go | 5 + 58 files changed, 1526 insertions(+), 101 deletions(-) create mode 100644 internal/codeguard/checks/contracts/cpp_breaking.go create mode 100644 internal/codeguard/checks/design/design_cpp.go create mode 100644 internal/codeguard/checks/design/design_graph_cpp.go create mode 100644 internal/codeguard/checks/performance/performance_cpp_rebuild.go create mode 100644 internal/codeguard/checks/performance/performance_rebuild.go create mode 100644 internal/codeguard/checks/security/security_cpp.go create mode 100644 internal/codeguard/checks/support/cpp_dependency_graph.go create mode 100644 internal/codeguard/checks/support/supply_chain_cpp.go create mode 100644 tests/checks/ci_test_quality_cpp_test.go create mode 100644 tests/checks/design_cpp_graph_test.go create mode 100644 tests/checks/performance_cpp_rebuild_test.go create mode 100644 tests/support/scan_language_helpers_test.go diff --git a/docs/checks.md b/docs/checks.md index 71f20b8..4dfb487 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -14,6 +14,7 @@ This file documents the current check categories in `codeguard` and the config k "prompts": true, "ci": true, "supply_chain": false, + "contracts": true, "context": true } } @@ -27,6 +28,8 @@ Each top-level boolean enables or disables an entire check family. `supply_chain` is opt-in and currently covers normalized manifest parsing plus initial policy checks for missing lockfiles, content-based lockfile drift validation, unpinned dependencies, dependency license policy resolved from local manifest and installed metadata where available, and Cargo manifest hygiene for missing package licenses and non-hermetic dependency sources. +`contracts` covers API compatibility against a diff base. When omitted, it is enabled in diff scans and disabled in full scans. It checks exported Go declarations, public C++ headers, OpenAPI documents, protobuf schemas, and destructive migrations. + For ecosystems where local metadata is not present, `supply_chain_rules.license_commands` can provide an opt-in per-ecosystem command that prints JSON license mappings for unresolved dependencies. Each license command receives structured context through environment variables: @@ -225,12 +228,12 @@ Current inference behavior: ## Built-in language coverage snapshot -| Family | Go | Python | TypeScript | Rust | Java | C# | Ruby | -| --- | --- | --- | --- | --- | --- | --- | --- | -| Quality | `gofmt`, parseability, maintainability thresholds | maintainability thresholds | maintainability thresholds, `@ts-ignore`, `@ts-nocheck`, `@ts-expect-error`, `explicit any`, double assertions, non-null assertions, `debugger` statements | maintainability thresholds | maintainability thresholds | maintainability thresholds | maintainability thresholds | -| Design | boundary rules, generic package names, type/interface/file-size heuristics | public-imports-private, public-imports-cli, generic module names | generic module names, max methods per class, max members per interface/object type | - | - | - | - | -| Security | insecure TLS, shell execution review, optional `govulncheck` | insecure TLS, shell execution review, dynamic code | insecure TLS, shell execution review, dynamic code, string timer execution, wildcard `postMessage`, Node `vm` execution, unsafe HTML sinks | insecure TLS, shell execution review | insecure TLS, shell execution review | insecure TLS, shell execution review | insecure TLS, shell execution review, dynamic code | -| Commands | language command mappings via config | language command mappings via config | language command mappings via config | language command mappings via config | language command mappings via config | language command mappings via config | language command mappings via config | +| Family | Go | C++ | Python | TypeScript | Rust | Java | C# | Ruby | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Quality | `gofmt`, parseability, maintainability thresholds | maintainability thresholds across sources, headers, templates, and modules | maintainability thresholds | maintainability thresholds, `@ts-ignore`, `@ts-nocheck`, `@ts-expect-error`, `explicit any`, double assertions, non-null assertions, `debugger` statements | maintainability thresholds | maintainability thresholds | maintainability thresholds | maintainability thresholds | +| Design | boundary rules, generic package names, type/interface/file-size heuristics | include/module cycles, graph impact, generic filenames, qualified method counts | public-imports-private, public-imports-cli, generic module names | generic module names, max methods per class, max members per interface/object type | module cycles and graph impact | import cycles and graph impact | - | - | +| Security | insecure TLS, shell execution review, optional `govulncheck` | insecure TLS, shell execution review, unsafe C string APIs | insecure TLS, shell execution review, dynamic code | insecure TLS, shell execution review, dynamic code, string timer execution, wildcard `postMessage`, Node `vm` execution, unsafe HTML sinks | insecure TLS, shell execution review | insecure TLS, shell execution review | insecure TLS, shell execution review | insecure TLS, shell execution review, dynamic code | +| Commands | language command mappings via config | language command mappings via config | language command mappings via config | language command mappings via config | language command mappings via config | language command mappings via config | language command mappings via config | language command mappings via config | TypeScript semantic runtime: - native TypeScript and JavaScript built-ins use the TypeScript compiler API when `typescript.js` is available @@ -291,7 +294,7 @@ Current behavior: - semantic review request payloads now include lightweight framework metadata plus contract hints for changed Express handlers and middleware, React components, and Next.js route/component files so external semantic runtimes can reason with handler-aware and component-aware context - semantic review request payloads also include a structured `prompt` template with per-rule focus and framework-specific reasoning guidance, so command-backed runtimes do not have to invent their own contract-drift or test-adequacy instructions from scratch - TypeScript and JavaScript quality built-ins use AST-derived function metrics and compiler-parsed syntax when the semantic runtime is available -- includes native maintainability heuristics for Python, TypeScript, JavaScript, Rust, Java, C#, and Ruby targets +- includes native maintainability heuristics for Python, TypeScript, JavaScript, Rust, C++, Java, C#, and Ruby targets - TypeScript and JavaScript targets also warn on `@ts-ignore`, `@ts-nocheck`, `@ts-expect-error`, explicit `any`, double assertions, non-null assertions, and committed `debugger` statements - can run language-specific quality commands based on `targets[].language` @@ -394,15 +397,16 @@ Behavior: Purpose: - N+1 query / remote-fetch patterns inside loops (Go, Python, TypeScript, JavaScript) -- Allocation-heavy loops: string concatenation and `fmt.Sprintf` accumulation (Go, Python, TS/JS), non-preallocated `String` growth (Rust), and (opt-in) append without preallocation (Go) -- Repeated work inside loops: regex compilation (Go, Python, TS/JS, Rust), `defer` accumulation (Go), polling sleeps (Go, Rust) +- Allocation-heavy loops: string concatenation and `fmt.Sprintf` accumulation (Go, Python, TS/JS), non-preallocated string growth (Rust and C++), and (opt-in) append without preallocation (Go) +- Repeated work inside loops: regex compilation (Go, Python, TS/JS, Rust, C++), `defer` accumulation (Go), polling sleeps (Go, Rust, C++) - Blocking I/O in request paths: synchronous file I/O in Go HTTP handlers, `*Sync` calls in TS/JS handlers, blocking calls in Python `async def` bodies -- Unbounded concurrency: goroutines launched from loops (Go), promises created in loops without a limiter (TS/JS), `asyncio` tasks created in loops without a semaphore (Python) +- Unbounded concurrency: goroutines launched from loops (Go), accumulated/detached threads or tasks (C++), promises created in loops without a limiter (TS/JS), `asyncio` tasks created in loops without a semaphore (Python) - Sequential `await` in TS/JS loops that could batch through `Promise.all` - Memory-pressure patterns: `time.After` timers leaked in Go loops, `setInterval` without `clearInterval` and listeners added in TS/JS loops without cleanup, unbounded whole-input reads (`io.ReadAll` in Go handlers/loops, `.read()`/`.readlines()` in Python loops) - Framework-aware smells, gated on file-level framework evidence: Django relation access in queryset loops, Django/SQLAlchemy ORM point queries in loops, expensive per-render work in React components, CPU-heavy synchronous calls in Express middleware - Change intelligence (diff scans): loop-nesting complexity regressions in functions touched by the diff - Measurement gates: artifact size budgets, clang `-ftime-trace` budgets, and `go test -bench` regression detection against a stored baseline +- Dependency-graph rebuild analysis for hot Go packages and C++ headers/modules that amplify recompilation - An opt-in AI-assisted lens for judgment-call concerns (missing caching, algorithmic complexity) when the semantic runtime is configured - A `performance_score` artifact with per-target history so the smell trend is visible across scans @@ -425,7 +429,8 @@ Config keys: "detect_timer_leaks": true, "detect_unbounded_reads": true, "detect_complexity_regression": true, - "detect_framework_patterns": true + "detect_framework_patterns": true, + "detect_rebuild_cascade": true } } } @@ -440,17 +445,20 @@ Rules: | `performance.n-plus-one-query` | Go, Python, TS, JS | `detect_n_plus_one_query` | | `performance.go.alloc-in-loop` | Go | `detect_alloc_in_loop` (+ `detect_prealloc_in_loop`) | | `performance.rust.alloc-in-loop` | Rust | `detect_alloc_in_loop` | +| `performance.cpp.alloc-in-loop` | C++ | `detect_alloc_in_loop` | | `performance.string-concat-in-loop` | Python, TS, JS | `detect_alloc_in_loop` | -| `performance.regex-compile-in-loop` | Go, Python, TS, JS, Rust | `detect_regex_compile_in_loop` | +| `performance.regex-compile-in-loop` | Go, Python, TS, JS, Rust, C++ | `detect_regex_compile_in_loop` | | `performance.go.defer-in-loop` | Go | `detect_defer_in_loop` | | `performance.go.sleep-in-loop` | Go | `detect_sleep_in_loop` | | `performance.rust.sleep-in-loop` | Rust | `detect_sleep_in_loop` | +| `performance.cpp.sleep-in-loop` | C++ | `detect_sleep_in_loop` | | `performance.sync-io-in-request-path` | Go | `detect_sync_io_in_handlers` | | `performance.{typescript,javascript}.sync-io-in-handler` | TS, JS | `detect_sync_io_in_handlers` | | `performance.python.sync-io-in-async` | Python | `detect_sync_io_in_handlers` | | `performance.unbounded-goroutines-in-loop` | Go | `detect_unbounded_concurrency` | | `performance.{typescript,javascript}.unbounded-concurrency` | TS, JS | `detect_unbounded_concurrency` | | `performance.python.unbounded-concurrency` | Python | `detect_unbounded_concurrency` | +| `performance.cpp.unbounded-concurrency` | C++ | `detect_unbounded_concurrency` | | `performance.{typescript,javascript}.await-in-loop` | TS, JS | `detect_await_in_loop` | | `performance.go.timer-leak-in-loop` | Go | `detect_timer_leaks` | | `performance.{typescript,javascript}.timer-listener-leak` | TS, JS | `detect_timer_leaks` | @@ -460,6 +468,8 @@ Rules: | `performance.python.orm-query-in-loop` | Python | `detect_framework_patterns` | | `performance.{typescript,javascript}.react-expensive-render` | TS, JS | `detect_framework_patterns` | | `performance.{typescript,javascript}.express-sync-middleware` | TS, JS | `detect_framework_patterns` | +| `performance.go.{hot-package,rebuild-amplifier}` | Go | `detect_rebuild_cascade` | +| `performance.cpp.{hot-header,rebuild-amplifier}` | C++ | `detect_rebuild_cascade` | Notes on precision: - `unbounded-goroutines-in-loop` recognizes bounded worker-pool construction and stays silent for it: counted loops (`for range n` with no iteration variables, or `for i := 0; i < n; i++` with a literal/identifier bound — `len()`/`cap()` bounds stay data-driven and still fire) and loops whose body acquires a `struct{}` channel semaphore (`sem <- struct{}{}`) before launching. @@ -467,6 +477,7 @@ Notes on precision: - `regex-compile-in-loop` fires only on **literal** patterns: compiling a variable pattern in a loop usually means the pattern differs per iteration (e.g. compiling config-supplied patterns), which is not hoistable. - `rust.alloc-in-loop` is intentionally conservative: it looks only for obvious `String` growth (`+=`, `x = x + ...`, `push_str`) on variables initialized from `String::new`, `String::from`, or `format!`, and stays silent when the variable was initialized with `String::with_capacity(...)`. - `rust.sleep-in-loop` targets `std::thread::sleep` / `thread::sleep`; async-runtime sleeps are out of scope for this version. +- C++ loop checks operate on comment/string-masked source. The concurrency rule intentionally limits itself to accumulated `std::thread`/`std::jthread`/`std::async` work and detached temporary threads; it does not attempt whole-program worker-pool inference. - `defer-in-loop` scopes to the enclosing function: `defer wg.Done()` inside a goroutine launched from a loop runs per goroutine and is not flagged. - `await-in-loop` exempts `for await` streams and any file using a concurrency limiter (`p-limit`/`p-queue`); keep the loop (or disable the toggle) when iterations genuinely depend on each other. - `unbounded-read` does not fire when the reader is already bounded (`io.LimitReader`, `http.MaxBytesReader`, `read(n)`). @@ -503,10 +514,10 @@ When the performance section runs and produces findings, each target publishes a |---|---|---| | Query in loop (N+1) | `n-plus-one-query` | 5 | | Blocking I/O | `sync-io-in-request-path`, `{typescript,javascript}.sync-io-in-handler`, `python.sync-io-in-async` | 4 | -| Unbounded concurrency | `unbounded-goroutines-in-loop`, `{typescript,javascript,python}.unbounded-concurrency` | 4 | +| Unbounded concurrency | `unbounded-goroutines-in-loop`, `cpp.unbounded-concurrency`, `{typescript,javascript,python}.unbounded-concurrency` | 4 | | Memory pressure | `unbounded-read`, `go.timer-leak-in-loop`, `{typescript,javascript}.timer-listener-leak` | 3 | -| Repeated loop work | `regex-compile-in-loop`, `go.defer-in-loop`, `go.sleep-in-loop`, `rust.sleep-in-loop`, `{typescript,javascript}.await-in-loop` | 2 | -| Allocation churn | `go.alloc-in-loop`, `rust.alloc-in-loop`, `string-concat-in-loop` | 1 | +| Repeated loop work | `regex-compile-in-loop`, `go.defer-in-loop`, `{go,rust,cpp}.sleep-in-loop`, `{typescript,javascript}.await-in-loop` | 2 | +| Allocation churn | `{go,rust,cpp}.alloc-in-loop`, `string-concat-in-loop` | 1 | The score trend is persisted per target next to the scan cache (`.perf-history.`) whenever the cache is enabled; subsequent scans annotate the artifact with `previous_score` and `delta`. `performance_rules.score_history: false` disables persistence and `performance_rules.score_history_limit` caps retained entries per target (default 100). Print the recorded trend with: @@ -520,17 +531,23 @@ When a config omits the `performance` key entirely, text-format `scan` output ap **Migration note:** these rules previously ran inside the quality section under `quality.*` ids (`quality.n-plus-one-query`, `quality.go.alloc-in-loop`, `quality.sync-io-in-request-path`, `quality.unbounded-goroutines-in-loop`, the `quality.typescript.*`/`quality.javascript.*` mirrors, and `quality.python.sync-io-in-async`), gated by `quality_rules.detect_*` keys. There is no runtime aliasing: waivers, baselines, and configs that reference the old ids stop matching when you enable `checks.performance`, and `codeguard doctor` flags any waiver still pointing at a retired id with the replacement to use. -### Go rebuild-cascade analysis +### Go and C++ rebuild-cascade analysis The Go performance pass also inspects the in-repo package import graph and emits two graph-backed warnings when `performance_rules.detect_rebuild_cascade` is enabled (default on): - `performance.go.hot-package`: a package exceeds `performance_rules.hot_package_importer_threshold` direct importers (default `8`), making ordinary edits fan out rebuilds broadly. - `performance.go.rebuild-amplifier`: a package exceeds `performance_rules.rebuild_amplifier_threshold` transitive dependents (default `20`), so edits there amplify rebuild cascades across the target. +The C++ pass applies the same thresholds to target-local include and C++20 named-module graphs: + +- `performance.cpp.hot-header`: a header, source, or named module has too many direct target-local dependents. +- `performance.cpp.rebuild-amplifier`: a C++ graph node has too many transitive dependents. + Behavior: - full scans evaluate every in-repo Go package under the target - diff scans only evaluate packages containing changed non-test `.go` files, so unrelated hot spots do not repeat on every PR - package discovery is module-local: imports are resolved through the target's `go.mod`, and only packages present under the target root participate in the graph +- C++ resolution is target-local and conservative: quoted includes resolve relative to the including file or target root, named modules resolve to declarations in the target, and system/compiler include paths are ignored unless their files are already target-local Config example: @@ -680,7 +697,7 @@ Repo-specific performance policies can also be expressed as natural-language cus ## Supply Chain Purpose: -- Manifest normalization across supported ecosystems +- Manifest normalization across Go, npm, Python, Cargo, vcpkg, and Conan ecosystems - Lockfile presence and drift validation - Unpinned dependency detection - Dependency license policy resolved from manifest, lockfile, installed metadata, or configured license commands @@ -701,6 +718,40 @@ Notes on Cargo precision: - `cargo.missing-package-license` looks only at the manifest package metadata (`package.license` in `Cargo.toml`); it does not infer intent from README text or dependency licenses. - `cargo.non-hermetic-source` warns on `path = ...`, `branch = ...`, or `git = ...` without `rev = ...` in dependency specs. A git dependency pinned to an exact `rev` stays silent. +Notes on C++ dependency precision: +- `vcpkg.json` dependencies are treated as pinned when the manifest has a valid 40-character `builtin-baseline` or an exact override for the dependency. +- `conanfile.txt` parses `[requires]` and `[tool_requires]`; exact references are pinned, version ranges are not, and `conan.lock` is required when lockfiles are enforced. +- Conan lockfile drift checks support Conan 2 top-level requirement arrays and Conan 1 `graph_lock.nodes[*].ref` entries. +- Executable `conanfile.py` and dynamic CMake dependency declarations are intentionally not evaluated. + +## API Contracts + +Purpose: +- Detect source-compatible API breaks against the diff base +- Protect exported Go declarations and public C++ headers +- Validate OpenAPI, protobuf, and database migration compatibility + +Config keys: + +```json +{ + "checks": { + "contracts": true, + "contract_rules": { + "go_exported_breaking": true, + "cpp_public_breaking": true, + "openapi_breaking": true, + "proto_breaking": true, + "migration_destructive": true + } + } +} +``` + +`contracts.cpp-public-breaking` compares changed or deleted `.h`, `.hh`, `.hpp`, `.hxx`, and `.h++` files under an `include`, `public`, or `api` directory with the base ref. It conservatively reports removed/renamed types and aliases plus removed or changed function declarations. Private implementation headers outside those public roots are ignored. + +The contracts family needs a base revision, so it runs in diff mode. When `checks.contracts` is omitted it defaults to enabled for diff scans and disabled for full scans. + ## Design Purpose: @@ -742,6 +793,7 @@ Current behavior: - Go targets keep the existing package, import-boundary, declaration-count, type-size, and interface-size heuristics - Python targets fail on public-to-private imports, direct or transitive entrypoint coupling, and internal import cycles, and warn on overly generic module names - TypeScript targets warn on overly generic module names, oversized classes, and oversized interfaces or object types using compiler-parsed AST analysis when the semantic runtime is available +- C++ targets build a target-local include/named-module graph for cycles, god modules, and diff impact; they also warn on generic filenames and excessive qualified out-of-line methods for one type - can run language-specific design commands based on `targets[].language` - language command failures surface as `design.command-check` @@ -880,6 +932,7 @@ Current behavior: - Python targets include insecure TLS review, shell execution review, and dynamic code review markers - TypeScript and JavaScript targets include insecure TLS review, shell execution review, dynamic code review markers, string timer execution review, wildcard `postMessage` review, Node `vm` execution review, and unsafe HTML sink review - Rust, Java, and C# targets include insecure TLS review and shell execution review markers +- C++ targets include insecure TLS review for common libcurl/OpenSSL/Boost.Asio/cpprestsdk settings, shell execution review, and unsafe unbounded C string API warnings - Ruby targets include insecure TLS review, shell execution review, and dynamic code review markers Config keys: @@ -905,7 +958,7 @@ Current behavior: - fails on blocking security findings - warns on reviewable findings - can surface per-vulnerability findings from `govulncheck` -- includes native Python, TypeScript, Rust, Java, C#, and Ruby security heuristics for shell execution and insecure TLS settings +- includes native Python, TypeScript, C++, Rust, Java, C#, and Ruby security heuristics for shell execution and insecure TLS settings - includes dynamic code review heuristics for Python, TypeScript, and Ruby - TypeScript and JavaScript security built-ins resolve imports, aliases, and call sites through compiler-parsed AST analysis when the semantic runtime is available - can run language-specific security commands based on `targets[].language` @@ -1007,11 +1060,11 @@ Config keys: Current behavior: - fails when required workflow, release, or automation files are missing - fails when required workflow content markers are missing -- fails when detected Go, Python, TypeScript, Rust, Java, C#, or Ruby test files live outside the configured test directories +- fails when detected Go, Python, TypeScript, C++, Rust, Java, C#, or Ruby test files live outside the configured test directories ### Test quality -Regex-based assertion checks run against Go, Python, TypeScript, and JavaScript test files. They are enabled by default and can be tuned via `ci_rules.test_quality`: +Regex-based assertion checks run against Go, Python, TypeScript/JavaScript, and C++ test files. C++ recognition covers GoogleTest, Catch2/doctest, Boost.Test, and conventional `assert` calls. The checks are enabled by default and can be tuned via `ci_rules.test_quality`: ```json { diff --git a/docs/features.md b/docs/features.md index 653bee9..030eb38 100644 --- a/docs/features.md +++ b/docs/features.md @@ -14,10 +14,12 @@ This page lists the current `codeguard` feature surface and the main config entr - layering and boundary rules - import cycle and god-module detection - high-impact-change analysis and dependency graph artifacts + - C++ target-local include and named-module graphs, generic filename checks, and qualified method-count limits - `security` - hardcoded secrets and private keys - Go, Python, TypeScript, and JavaScript taint-style flow checks - insecure API heuristics + - C++ insecure TLS, shell execution, and unsafe C string API checks - optional `govulncheck` - `prompts` - prompt-asset governance @@ -25,18 +27,22 @@ This page lists the current `codeguard` feature surface and the main config entr - dangerous instruction and standing-permission detection - `ci` - workflow/release policy - - test-quality heuristics + - test-quality heuristics, including GoogleTest, Catch2/doctest, and Boost.Test - `supply_chain` - - manifest normalization + - manifest normalization, including `vcpkg.json` and declarative `conanfile.txt` - lockfile presence and drift validation - unpinned dependency detection - dependency and manifest license policy - Cargo manifest hygiene for missing package licenses and non-hermetic dependency sources - `performance` - N+1 query patterns, allocation-heavy loops, blocking I/O in request paths, and unbounded concurrency - - Go package rebuild-cascade analysis for rebuild hot spots and amplifiers + - Go package and C++ include/module rebuild-cascade analysis for rebuild hot spots and amplifiers - Rust and C++ loop-smell coverage for regex construction, non-preallocated string growth, and polling sleeps + - C++ loop-driven unbounded thread/task launch detection - build regression, benchmark regression, artifact-size budgets, and clang `-ftime-trace` budgets +- `contracts` + - exported Go and public C++ API compatibility against a diff base + - OpenAPI, protobuf, and destructive migration checks ## Agent-native features diff --git a/docs/security.md b/docs/security.md index 75e40f6..4ac0380 100644 --- a/docs/security.md +++ b/docs/security.md @@ -66,12 +66,12 @@ Example: OWASP Top 10 (2021) coverage: 9/10 categories have rules [ok ] A01:2021-Broken Access Control (2 rules) -[ok ] A02:2021-Cryptographic Failures (11 rules) -[ok ] A03:2021-Injection (24 rules) +[ok ] A02:2021-Cryptographic Failures (12 rules) +[ok ] A03:2021-Injection (26 rules) [gap ] A04:2021-Insecure Design (0 rules) -[ok ] A05:2021-Security Misconfiguration (4 rules) +[ok ] A05:2021-Security Misconfiguration (5 rules) [ok ] A06:2021-Vulnerable and Outdated Components (1 rules) -[ok ] A07:2021-Identification and Authentication Failures (1 rules) +[ok ] A07:2021-Identification and Authentication Failures (3 rules) [ok ] A08:2021-Software and Data Integrity Failures (1 rules) [ok ] A09:2021-Security Logging and Monitoring Failures (2 rules) [ok ] A10:2021-Server-Side Request Forgery (SSRF) (2 rules) diff --git a/internal/codeguard/checks/ci/ci_target_files.go b/internal/codeguard/checks/ci/ci_target_files.go index 4d77de7..94bc6a7 100644 --- a/internal/codeguard/checks/ci/ci_target_files.go +++ b/internal/codeguard/checks/ci/ci_target_files.go @@ -3,6 +3,8 @@ package ci import ( "path/filepath" "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" ) func isTargetTestFile(language, rel string) bool { @@ -21,11 +23,42 @@ func isTargetTestFile(language, rel string) bool { return isCSharpTestFile(rel) case "ruby", "rb": return isRubyTestFile(rel) + case "c++", "cpp", "cxx", "cc": + return isCPPTestFile(rel) default: return false } } +func isCPPTestFile(rel string) bool { + if !support.IsCPPPath(rel, true) { + return false + } + slashPath := strings.ToLower(filepath.ToSlash(rel)) + base := filepath.Base(slashPath) + stem := strings.TrimSuffix(base, filepath.Ext(base)) + originalBase := filepath.Base(rel) + originalStem := strings.TrimSuffix(originalBase, filepath.Ext(originalBase)) + if stem == "test" || stem == "tests" || stem == "unittest" || stem == "unittests" || + strings.HasPrefix(stem, "test_") || strings.HasPrefix(stem, "tests_") { + return true + } + for _, suffix := range []string{"_test", "_tests", "_unittest", "_unittests"} { + if strings.HasSuffix(stem, suffix) { + return true + } + } + if strings.HasSuffix(originalStem, "Test") || strings.HasSuffix(originalStem, "Tests") { + return true + } + for _, directory := range []string{"test", "tests", "unittest", "unittests"} { + if strings.HasPrefix(slashPath, directory+"/") || strings.Contains(slashPath, "/"+directory+"/") { + return true + } + } + return false +} + func normalizedLanguage(language string) string { return strings.ToLower(strings.TrimSpace(language)) } diff --git a/internal/codeguard/checks/ci/ci_test_quality_blocks.go b/internal/codeguard/checks/ci/ci_test_quality_blocks.go index 0b80e37..b0c7203 100644 --- a/internal/codeguard/checks/ci/ci_test_quality_blocks.go +++ b/internal/codeguard/checks/ci/ci_test_quality_blocks.go @@ -19,6 +19,12 @@ var ( goTestDeclPattern = regexp.MustCompile(`^func\s+(Test\w+)\s*\(`) jsTestDeclPattern = regexp.MustCompile(`^\s*(?:it|test)(?:\.\w+)?\s*\(\s*(?:'([^']*)'|"([^"]*)")?`) pythonTestDeclPattern = regexp.MustCompile(`^(\s*)def\s+(test_\w*)\s*\(`) + cppGoogleTestPattern = regexp.MustCompile(`^\s*(?:TEST|TEST_F|TEST_P|TYPED_TEST|TYPED_TEST_P)\s*\(\s*([^,]+)\s*,\s*([^)]+)\)`) + cppGoogleTestStart = regexp.MustCompile(`^\s*((?:TEST|TEST_F|TEST_P|TYPED_TEST|TYPED_TEST_P))\s*\(`) + cppCatchTestPattern = regexp.MustCompile(`^\s*(?:TEST_CASE(?:_METHOD|_FIXTURE|_CLASS|_TEMPLATE(?:_DEFINE)?)?|SCENARIO(?:_METHOD)?|TEMPLATE_(?:PRODUCT_)?TEST_CASE(?:_METHOD)?)\s*\((.*)`) + cppBoostTestPattern = regexp.MustCompile(`^\s*BOOST_(?:AUTO|FIXTURE|DATA)_TEST_CASE(?:_TEMPLATE)?\s*\(\s*([^,\s)]+)`) + cppBoostTestStart = regexp.MustCompile(`^\s*(BOOST_(?:AUTO|FIXTURE|DATA)_TEST_CASE(?:_TEMPLATE)?)\s*\(`) + quotedTestNamePattern = regexp.MustCompile(`["']([^"']+)["']`) braceElsePattern = regexp.MustCompile(`(?:^|\W)else(?:\W|$)`) pythonElsePattern = regexp.MustCompile(`^\s*(?:else\s*:|elif\b)`) envVarGuardPattern = regexp.MustCompile(`if\s+os\.Getenv\(\s*"[^"]*"\s*\)\s*[!=]=`) @@ -94,11 +100,35 @@ func extractTestBlocks(language string, text string) []testBlock { }, '(', ')') case "python", "py": return pythonTestBlocks(lines) + case "c++", "cpp", "cxx", "cc": + return delimitedTestBlocks(lines, cppTestDeclaration, '{', '}') default: return nil } } +func cppTestDeclaration(line string) (string, bool) { + if match := cppGoogleTestPattern.FindStringSubmatch(line); match != nil { + return strings.TrimSpace(match[1]) + "." + strings.TrimSpace(match[2]), true + } + if match := cppGoogleTestStart.FindStringSubmatch(line); match != nil { + return match[1] + " (multiline declaration)", true + } + if match := cppCatchTestPattern.FindStringSubmatch(line); match != nil { + if quoted := quotedTestNamePattern.FindStringSubmatch(match[1]); quoted != nil { + return quoted[1], true + } + return "(unnamed)", true + } + if match := cppBoostTestPattern.FindStringSubmatch(line); match != nil { + return match[1], true + } + if match := cppBoostTestStart.FindStringSubmatch(line); match != nil { + return match[1] + " (multiline declaration)", true + } + return "", false +} + // delimitedTestBlocks collects blocks for brace or parenthesis delimited // languages by balancing the open/close runes from the declaration line on. func delimitedTestBlocks(lines []string, matchDecl func(string) (string, bool), open rune, closing rune) []testBlock { diff --git a/internal/codeguard/checks/ci/ci_test_quality_patterns.go b/internal/codeguard/checks/ci/ci_test_quality_patterns.go index 4becc84..8de05eb 100644 --- a/internal/codeguard/checks/ci/ci_test_quality_patterns.go +++ b/internal/codeguard/checks/ci/ci_test_quality_patterns.go @@ -46,6 +46,23 @@ var ( `|\bexpect\s*\(\s*(?:true|1|` + literalPattern + `\s*===?\s*` + literalPattern + `)\s*\)\s*\.\s*(?:toBeTruthy|toBeDefined|toBeFalsy)\s*\(\s*\)` + `|\bassert\s*\(\s*(?:true|1)\s*\)`) + cppAssertionPattern = regexp.MustCompile( + `\b(?:EXPECT|ASSERT)_[A-Z0-9_]+\s*\(` + + `|\b(?:CHECK|REQUIRE)(?:_[A-Z0-9_]+)?\s*\(` + + `|\bBOOST_(?:CHECK|REQUIRE|WARN)(?:_[A-Z0-9_]+)?\s*\(` + + `|\b(?:ADD_FAILURE|FAIL|FAIL_CHECK|SUCCEED)\s*\(` + + `|\bBOOST_(?:ERROR|FAIL)\s*\(` + + `|\bassert\s*\(`) + cppIdiomaticPattern = regexp.MustCompile(`\b(?:ADD_FAILURE|FAIL|FAIL_CHECK)\s*\(|\bBOOST_(?:ERROR|FAIL)\s*\(`) + cppConstantPattern = regexp.MustCompile( + `\b(?:EXPECT|ASSERT)_TRUE\s*\(\s*(?:true|1)\s*\)` + + `|\b(?:EXPECT|ASSERT)_FALSE\s*\(\s*(?:false|0)\s*\)` + + `|\b(?:CHECK|REQUIRE)(?:_MESSAGE)?\s*\(\s*(?:true|1)\s*[,)]` + + `|\b(?:CHECK|REQUIRE)_FALSE\s*\(\s*(?:false|0)\s*\)` + + `|\bBOOST_(?:CHECK|REQUIRE|WARN)(?:_MESSAGE)?\s*\(\s*(?:true|1)\s*[,)]` + + `|\bassert\s*\(\s*(?:true|1)\s*\)` + + `|\bSUCCEED\s*\(`) + braceConditionalOpener = regexp.MustCompile(`^\s*\}?\s*(?:else\s+)?if\b`) pythonConditionalOpener = regexp.MustCompile(`^\s*if\b.*:`) @@ -64,6 +81,8 @@ func testQualityPatternsFor(language string) (testQualityPatterns, bool) { return testQualityPatterns{assertion: pythonAssertionPattern, idiomatic: pythonIdiomaticPattern, constant: pythonConstantPattern}, true case "typescript", "javascript", "ts", "tsx", "js", "jsx": return testQualityPatterns{assertion: jsAssertionPattern, constant: jsConstantPattern, braceBased: true}, true + case "c++", "cpp", "cxx", "cc": + return testQualityPatterns{assertion: cppAssertionPattern, idiomatic: cppIdiomaticPattern, constant: cppConstantPattern, braceBased: true}, true default: return testQualityPatterns{}, false } diff --git a/internal/codeguard/checks/contracts/contracts.go b/internal/codeguard/checks/contracts/contracts.go index dbb5826..1ef0c22 100644 --- a/internal/codeguard/checks/contracts/contracts.go +++ b/internal/codeguard/checks/contracts/contracts.go @@ -23,6 +23,7 @@ func findingsForTarget(_ context.Context, env support.Context, target core.Targe changed := changedFilesForTarget(env, target) findings := make([]core.Finding, 0) //nolint:prealloc // count not known up front; each rule appends a variable number findings = append(findings, goBreakingFindings(env, target, changed)...) + findings = append(findings, cppBreakingFindings(env, target, changed)...) findings = append(findings, openAPIBreakingFindings(env, target, changed)...) findings = append(findings, protoBreakingFindings(env, target, changed)...) findings = append(findings, migrationFindings(env, target, changed)...) diff --git a/internal/codeguard/checks/contracts/cpp_breaking.go b/internal/codeguard/checks/contracts/cpp_breaking.go new file mode 100644 index 0000000..d780f73 --- /dev/null +++ b/internal/codeguard/checks/contracts/cpp_breaking.go @@ -0,0 +1,194 @@ +package contracts + +import ( + "fmt" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +type cppSymbol struct { + signature string + line int +} + +var ( + cppPublicTypePattern = regexp.MustCompile(`(?m)^[ \t]*(?:template\s*<[^\n]+>\s*)?(?:class|struct|enum(?:\s+class)?)\s+(?:[A-Z_][A-Z0-9_]*\s+)?([A-Za-z_]\w*)\b`) + cppUsingPattern = regexp.MustCompile(`(?m)^[ \t]*using\s+([A-Za-z_]\w*)\s*=`) + cppFunctionPattern = regexp.MustCompile(`([~A-Za-z_]\w*)\s*\(([^()]*)\)\s*((?:const\s*)?(?:noexcept(?:\s*\([^)]*\))?\s*)?(?:override\s*)?(?:final\s*)?(?:=\s*(?:0|default|delete)\s*)?);$`) + cppParamNamePattern = regexp.MustCompile(`^(.+[\s*&])([A-Za-z_]\w*)(\s*\[[^]]*\])?$`) +) + +func cppBreakingFindings(env support.Context, target core.TargetConfig, changed []core.ChangedFile) []core.Finding { + if !enabled(env.Config.Checks.ContractRules.CPPPublicBreaking) { + return nil + } + findings := make([]core.Finding, 0) + for _, file := range changed { + if file.Status == core.ChangedFileAdded || !isCPPPublicHeader(file.Path) { + continue + } + findings = append(findings, cppFileBreakingFindings(env, target, file)...) + } + return findings +} + +func isCPPPublicHeader(rel string) bool { + switch strings.ToLower(filepath.Ext(rel)) { + case ".h", ".hh", ".hpp", ".hxx", ".h++": + default: + return false + } + parts := strings.Split(filepath.ToSlash(rel), "/") + for _, part := range parts[:len(parts)-1] { + switch strings.ToLower(part) { + case "include", "public", "api": + return true + } + } + return false +} + +func cppFileBreakingFindings(env support.Context, target core.TargetConfig, file core.ChangedFile) []core.Finding { + baseSymbols := cppPublicSymbols(readBase(env, target, file.Path)) + if len(baseSymbols) == 0 { + return nil + } + headSymbols := map[string][]cppSymbol{} + if file.Status != core.ChangedFileDeleted { + headSymbols = cppPublicSymbols(readHead(target, file.Path)) + } + + findings := make([]core.Finding, 0) + for _, name := range sortedKeys(baseSymbols) { + base := baseSymbols[name] + head := headSymbols[name] + if len(head) == 0 { + findings = append(findings, newCPPBreakingFinding(env, file.Path, 0, fmt.Sprintf("public C++ %s was removed or renamed against the base ref", name))) + continue + } + if strings.HasPrefix(name, "function ") { + findings = append(findings, cppSignatureFindings(env, file.Path, name, base, head)...) + } + } + return findings +} + +func cppSignatureFindings(env support.Context, path string, name string, base []cppSymbol, head []cppSymbol) []core.Finding { + headSignatures := make(map[string]cppSymbol, len(head)) + for _, symbol := range head { + headSignatures[symbol.signature] = symbol + } + missing := make([]string, 0) + for _, symbol := range base { + if _, ok := headSignatures[symbol.signature]; !ok { + missing = append(missing, symbol.signature) + } + } + if len(missing) == 0 { + return nil + } + sort.Strings(missing) + line := head[0].line + if len(base) == 1 && len(head) == 1 { + return []core.Finding{newCPPBreakingFinding(env, path, line, fmt.Sprintf("public C++ %s changed signature from %s to %s", name, base[0].signature, head[0].signature))} + } + findings := make([]core.Finding, 0, len(missing)) + for _, signature := range missing { + findings = append(findings, newCPPBreakingFinding(env, path, line, fmt.Sprintf("public C++ overload %s %s was removed or changed", name, signature))) + } + return findings +} + +func newCPPBreakingFinding(env support.Context, path string, line int, message string) core.Finding { + return env.NewFinding(support.FindingInput{ + RuleID: "contracts.cpp-public-breaking", + Level: "fail", + Path: path, + Line: line, + Message: message, + }) +} + +func cppPublicSymbols(src []byte) map[string][]cppSymbol { + if len(src) == 0 { + return nil + } + source := strings.ReplaceAll(string(src), "\r\n", "\n") + masked := support.MaskCLikeSource(source, support.CLikeCPP) + symbols := make(map[string][]cppSymbol) + for _, match := range cppPublicTypePattern.FindAllStringSubmatchIndex(masked, -1) { + name := masked[match[2]:match[3]] + appendCPPSymbol(symbols, "type "+name, cppSymbol{line: support.LineNumberForOffset(masked, match[2])}) + } + for _, match := range cppUsingPattern.FindAllStringSubmatchIndex(masked, -1) { + name := masked[match[2]:match[3]] + appendCPPSymbol(symbols, "alias "+name, cppSymbol{line: support.LineNumberForOffset(masked, match[2])}) + } + for start, idx := 0, 0; idx < len(masked); idx++ { + switch masked[idx] { + case '{', '}': + start = idx + 1 + case ';': + collectCPPFunctionSymbol(symbols, masked[start:idx+1], start, masked) + start = idx + 1 + } + } + return symbols +} + +func collectCPPFunctionSymbol(symbols map[string][]cppSymbol, statement string, offset int, source string) { + statement = strings.TrimSpace(statement) + match := cppFunctionPattern.FindStringSubmatchIndex(statement) + if match == nil { + return + } + name := statement[match[2]:match[3]] + switch name { + case "if", "for", "while", "switch", "catch", "static_assert": + return + } + prefix := strings.TrimSpace(statement[:match[2]]) + prefix = strings.TrimSpace(strings.TrimPrefix(prefix, "public:")) + prefix = strings.TrimSpace(strings.TrimPrefix(prefix, "protected:")) + prefix = strings.TrimSpace(strings.TrimPrefix(prefix, "private:")) + params := canonicalCPPParams(statement[match[4]:match[5]]) + suffix := strings.Join(strings.Fields(statement[match[6]:match[7]]), " ") + signature := strings.Join(strings.Fields(prefix), " ") + "(" + params + ")" + if suffix != "" { + signature += " " + suffix + } + lineOffset := offset + strings.Index(source[offset:], name) + appendCPPSymbol(symbols, "function "+name, cppSymbol{signature: strings.TrimSpace(signature), line: support.LineNumberForOffset(source, lineOffset)}) +} + +func canonicalCPPParams(params string) string { + parts := strings.Split(params, ",") + for idx, part := range parts { + part = strings.TrimSpace(part) + if equal := strings.Index(part, "="); equal >= 0 { + part = strings.TrimSpace(part[:equal]) + } + if match := cppParamNamePattern.FindStringSubmatch(part); match != nil { + candidate := strings.TrimSpace(match[1]) + if candidate != "const" && candidate != "volatile" { + part = candidate + strings.TrimSpace(match[3]) + } + } + parts[idx] = strings.Join(strings.Fields(part), " ") + } + return strings.Join(parts, ", ") +} + +func appendCPPSymbol(symbols map[string][]cppSymbol, key string, symbol cppSymbol) { + for _, existing := range symbols[key] { + if existing.signature == symbol.signature { + return + } + } + symbols[key] = append(symbols[key], symbol) +} diff --git a/internal/codeguard/checks/design/design.go b/internal/codeguard/checks/design/design.go index 1aa5575..36930f6 100644 --- a/internal/codeguard/checks/design/design.go +++ b/internal/codeguard/checks/design/design.go @@ -37,6 +37,8 @@ func targetLanguageFindings(ctx context.Context, env support.Context, target cor return nil, buildRustImportGraph(env, target) case "java": return nil, buildJavaImportGraph(env, target) + case "c++", "cpp", "cxx", "cc": + return cppTargetFindings(env, target), buildCPPImportGraph(env, target) default: return nil, nil } diff --git a/internal/codeguard/checks/design/design_cpp.go b/internal/codeguard/checks/design/design_cpp.go new file mode 100644 index 0000000..24df993 --- /dev/null +++ b/internal/codeguard/checks/design/design_cpp.go @@ -0,0 +1,52 @@ +package design + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func cppTargetFindings(env support.Context, target core.TargetConfig) []core.Finding { + return support.ScanCPPFiles(env, target, "design", func(file string, data []byte) []core.Finding { + return cppDesignFindingsForFile(env, file, data) + }) +} + +func cppDesignFindingsForFile(env support.Context, file string, data []byte) []core.Finding { + findings := cppGenericModuleNameFindings(env, file) + parsed := support.ParseCLike(string(data), support.CLikeCPP) + counts := make(map[string]int) + for _, function := range parsed.Functions { + separator := strings.LastIndex(function.Name, "::") + if separator <= 0 { + continue + } + counts[function.Name[:separator]]++ + } + for typeName, count := range counts { + if count <= env.Config.Checks.DesignRules.MaxMethodsPerType { + continue + } + findings = append(findings, env.NewFinding(support.FindingInput{ + RuleID: "design.cpp.max-methods-per-type", Level: "warn", Path: file, Line: 1, Column: 1, + Message: fmt.Sprintf("C++ type %s has %d out-of-line methods in this file; max is %d", typeName, count, env.Config.Checks.DesignRules.MaxMethodsPerType), + })) + } + return findings +} + +func cppGenericModuleNameFindings(env support.Context, file string) []core.Finding { + name := strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)) + for _, forbidden := range env.Config.Checks.DesignRules.ForbiddenPackageNames { + if strings.EqualFold(name, forbidden) { + return []core.Finding{env.NewFinding(support.FindingInput{ + RuleID: "design.cpp.generic-module-name", Level: "warn", Path: file, Line: 1, Column: 1, + Message: fmt.Sprintf("C++ file name %q is too generic", name), + })} + } + } + return nil +} diff --git a/internal/codeguard/checks/design/design_graph_cpp.go b/internal/codeguard/checks/design/design_graph_cpp.go new file mode 100644 index 0000000..773201d --- /dev/null +++ b/internal/codeguard/checks/design/design_graph_cpp.go @@ -0,0 +1,24 @@ +package design + +import ( + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func buildCPPImportGraph(env support.Context, target core.TargetConfig) *moduleGraph { + dependencyGraph := support.BuildCPPDependencyGraph(env, target) + if dependencyGraph == nil { + return nil + } + graph := newModuleGraph("cpp") + for _, id := range dependencyGraph.Graph.Order { + node := dependencyGraph.Graph.Nodes[id] + graph.addModule(id, node.Path) + } + for _, id := range dependencyGraph.Graph.Order { + for _, edge := range dependencyGraph.Graph.Nodes[id].Edges { + graph.addEdge(id, edge.To, edge.Line) + } + } + return graph +} diff --git a/internal/codeguard/checks/design/design_graph_rules.go b/internal/codeguard/checks/design/design_graph_rules.go index 8d11f90..2ddb48a 100644 --- a/internal/codeguard/checks/design/design_graph_rules.go +++ b/internal/codeguard/checks/design/design_graph_rules.go @@ -21,6 +21,8 @@ func graphCycleRuleID(language string, file string) string { return "design.rust.import-cycle" case "java": return "design.java.import-cycle" + case "cpp": + return "design.cpp.import-cycle" default: return "" } diff --git a/internal/codeguard/checks/performance/performance_cpp.go b/internal/codeguard/checks/performance/performance_cpp.go index fd5e156..10e81ff 100644 --- a/internal/codeguard/checks/performance/performance_cpp.go +++ b/internal/codeguard/checks/performance/performance_cpp.go @@ -9,14 +9,17 @@ import ( ) var ( - cppLoopStartPattern = regexp.MustCompile(`(?:^|[^\w])(?:for|while)\b`) - cppRegexDeclPattern = regexp.MustCompile(`\bstd::(?:(?:w|u8|u16|u32)?regex|basic_regex\s*<[^>]+>)\s+[A-Za-z_]\w*\s*[\({]`) - cppRegexLiteralCtor = regexp.MustCompile(`[\({][ \t]*(?:u8|u|U|L)?(?:R"|")`) - cppStringDeclPattern = regexp.MustCompile(`^\s*(?:constexpr\s+|static\s+|inline\s+|const\s+|volatile\s+|mutable\s+)*(?:std::)?(?:basic_string\s*<[^>]+>|string|wstring|u8string|u16string|u32string)\s+([A-Za-z_]\w*)\b`) - cppStringReservePattern = regexp.MustCompile(`\b([A-Za-z_]\w*)\.reserve\s*\(`) - cppStringConcatPattern = regexp.MustCompile(`^\s*([A-Za-z_]\w*)\s*\+=`) - cppStringAppendPattern = regexp.MustCompile(`\b([A-Za-z_]\w*)\.(?:append|push_back)\s*\(`) - cppThreadSleepPattern = regexp.MustCompile(`\bstd::this_thread::sleep_(?:for|until)\s*\(`) + cppLoopStartPattern = regexp.MustCompile(`(?:^|[^\w])(?:for|while)\b`) + cppRegexDeclPattern = regexp.MustCompile(`\bstd::(?:(?:w|u8|u16|u32)?regex|basic_regex\s*<[^>]+>)\s+[A-Za-z_]\w*\s*[\({]`) + cppRegexLiteralCtor = regexp.MustCompile(`[\({][ \t]*(?:u8|u|U|L)?(?:R"|")`) + cppStringDeclPattern = regexp.MustCompile(`^\s*(?:constexpr\s+|static\s+|inline\s+|const\s+|volatile\s+|mutable\s+)*(?:std::)?(?:basic_string\s*<[^>]+>|string|wstring|u8string|u16string|u32string)\s+([A-Za-z_]\w*)\b`) + cppStringReservePattern = regexp.MustCompile(`\b([A-Za-z_]\w*)\.reserve\s*\(`) + cppStringConcatPattern = regexp.MustCompile(`^\s*([A-Za-z_]\w*)\s*\+=`) + cppStringAppendPattern = regexp.MustCompile(`\b([A-Za-z_]\w*)\.(?:append|push_back)\s*\(`) + cppThreadSleepPattern = regexp.MustCompile(`\bstd::this_thread::sleep_(?:for|until)\s*\(`) + cppUnboundedThreadPattern = regexp.MustCompile(`(?:\b[A-Za-z_]\w*\.(?:emplace_back|push_back)\s*\(\s*(?:std::)?(?:jthread|thread|async)\b|\bstd::(?:jthread|thread)\s*\([^;\n]*\)\s*\.detach\s*\()`) + cppThreadVectorDecl = regexp.MustCompile(`\bstd::vector\s*<\s*std::(?:jthread|thread)\s*>\s*([A-Za-z_]\w*)`) + cppContainerAppend = regexp.MustCompile(`\b([A-Za-z_]\w*)\.(?:emplace_back|push_back)\s*\(`) ) type cppStringState struct { @@ -30,6 +33,7 @@ type cppPerformanceScan struct { depth int loops []int stringVar map[string]cppStringState + threadVec map[string]bool findings []core.Finding } @@ -41,6 +45,7 @@ func cppPerformanceFindings(env support.Context, file string, data []byte) []cor file: file, rules: env.Config.Checks.PerformanceRules, stringVar: make(map[string]cppStringState), + threadVec: make(map[string]bool), } rawLines := strings.Split(source, "\n") for idx, line := range strings.Split(masked, "\n") { @@ -59,6 +64,9 @@ func (s *cppPerformanceScan) consumeLine(lineNo int, line string, rawLine string state.reserved = true s.stringVar[m[1]] = state } + if m := cppThreadVectorDecl.FindStringSubmatch(line); m != nil { + s.threadVec[m[1]] = true + } startsLoop := cppLoopStartPattern.MatchString(line) s.checkLine(lineNo, line, rawLine, len(s.loops) > 0 || startsLoop) s.depth, s.loops = consumeBraceLoopLine(s.depth, s.loops, line, startsLoop) @@ -80,6 +88,18 @@ func (s *cppPerformanceScan) checkLine(lineNo int, line string, rawLine string, s.addFinding("performance.cpp.sleep-in-loop", lineNo, "std::this_thread::sleep_* inside a loop usually marks a poll; prefer a condition variable, timer primitive, or bounded backoff helper") } + if toggleEnabled(s.rules.DetectUnboundedConcurrency) && s.isUnboundedThreadLaunch(line) { + s.addFinding("performance.cpp.unbounded-concurrency", lineNo, + "C++ thread/task launch accumulated or detached inside a loop has no visible concurrency bound; use a fixed worker pool, semaphore, or bounded executor") + } +} + +func (s *cppPerformanceScan) isUnboundedThreadLaunch(line string) bool { + if cppUnboundedThreadPattern.MatchString(line) { + return true + } + match := cppContainerAppend.FindStringSubmatch(line) + return match != nil && s.threadVec[match[1]] } func (s *cppPerformanceScan) isStringGrowth(line string) bool { diff --git a/internal/codeguard/checks/performance/performance_cpp_rebuild.go b/internal/codeguard/checks/performance/performance_cpp_rebuild.go new file mode 100644 index 0000000..7ab0c58 --- /dev/null +++ b/internal/codeguard/checks/performance/performance_cpp_rebuild.go @@ -0,0 +1,48 @@ +package performance + +import ( + "fmt" + "path/filepath" + "slices" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func cppRebuildCascadeFindings(env support.Context, target core.TargetConfig) []core.Finding { + if !toggleEnabled(env.Config.Checks.PerformanceRules.DetectRebuildCascade) { + return nil + } + graph := support.BuildCPPDependencyGraph(env, target) + if graph == nil || len(graph.Graph.Nodes) == 0 { + return nil + } + return dependencyRebuildCascadeFindings(env, graph.Graph, cppRebuildCandidates(env, graph), rebuildCascadeSpec{ + hotRuleID: "performance.cpp.hot-header", + amplifierRuleID: "performance.cpp.rebuild-amplifier", + hotMessage: func(module string, count int, threshold int, sample string) string { + return fmt.Sprintf("C++ file %q is included or imported by %d target-local files; max is %d, so edits fan out recompilation broadly%s", module, count, threshold, sample) + }, + amplifierMessage: func(module string, count int, threshold int, sample string) string { + return fmt.Sprintf("C++ file %q has %d transitive target-local dependents; max is %d, so changes amplify rebuild cascades%s", module, count, threshold, sample) + }, + }) +} + +func cppRebuildCandidates(env support.Context, graph *support.CPPDependencyGraph) []string { + if env.Mode != core.ScanModeDiff { + return append([]string(nil), graph.Graph.Order...) + } + seen := make(map[string]bool) + modules := make([]string, 0) + for _, changed := range env.ChangedFiles { + module, ok := graph.FileToModule[filepath.ToSlash(changed)] + if !ok || seen[module] { + continue + } + seen[module] = true + modules = append(modules, module) + } + slices.Sort(modules) + return modules +} diff --git a/internal/codeguard/checks/performance/performance_go_rebuild.go b/internal/codeguard/checks/performance/performance_go_rebuild.go index a6e5b1c..c08848d 100644 --- a/internal/codeguard/checks/performance/performance_go_rebuild.go +++ b/internal/codeguard/checks/performance/performance_go_rebuild.go @@ -24,48 +24,17 @@ func goRebuildCascadeFindings(env support.Context, target core.TargetConfig) []c if graph == nil || len(graph.Graph.Nodes) == 0 { return nil } - hotThreshold := env.Config.Checks.PerformanceRules.HotPackageImporterThreshold - if hotThreshold <= 0 { - hotThreshold = defaultHotPackageImporterThreshold - } - amplifierThreshold := env.Config.Checks.PerformanceRules.RebuildAmplifierThreshold - if amplifierThreshold <= 0 { - amplifierThreshold = defaultRebuildAmplifierThreshold - } - - reverse := support.ReverseDependencyMap(graph.Graph) candidates := rebuildCascadeCandidatePackages(env, graph) - findings := make([]core.Finding, 0) - for _, pkg := range candidates { - node, ok := graph.Graph.Nodes[pkg] - if !ok { - continue - } - importers := append([]string(nil), reverse[pkg]...) - slices.Sort(importers) - if len(importers) > hotThreshold { - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "performance.go.hot-package", - Level: "warn", - Path: node.Path, - Line: 0, - Column: 1, - Message: fmt.Sprintf("Go package %q is imported by %d packages; max is %d, so edits here fan out rebuilds broadly%s", pkg, len(importers), hotThreshold, rebuildCascadeSample(importers)), - })) - } - dependents := support.TransitiveDependents(reverse, pkg) - if len(dependents) > amplifierThreshold { - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "performance.go.rebuild-amplifier", - Level: "warn", - Path: node.Path, - Line: 0, - Column: 1, - Message: fmt.Sprintf("Go package %q has %d transitive dependents; max is %d, so changes here amplify rebuild cascades%s", pkg, len(dependents), amplifierThreshold, rebuildCascadeSample(dependents)), - })) - } - } - return findings + return dependencyRebuildCascadeFindings(env, graph.Graph, candidates, rebuildCascadeSpec{ + hotRuleID: "performance.go.hot-package", + amplifierRuleID: "performance.go.rebuild-amplifier", + hotMessage: func(pkg string, count int, threshold int, sample string) string { + return fmt.Sprintf("Go package %q is imported by %d packages; max is %d, so edits here fan out rebuilds broadly%s", pkg, count, threshold, sample) + }, + amplifierMessage: func(pkg string, count int, threshold int, sample string) string { + return fmt.Sprintf("Go package %q has %d transitive dependents; max is %d, so changes here amplify rebuild cascades%s", pkg, count, threshold, sample) + }, + }) } func rebuildCascadeCandidatePackages(env support.Context, graph *support.GoPackageImportGraph) []string { diff --git a/internal/codeguard/checks/performance/performance_rebuild.go b/internal/codeguard/checks/performance/performance_rebuild.go new file mode 100644 index 0000000..a732a59 --- /dev/null +++ b/internal/codeguard/checks/performance/performance_rebuild.go @@ -0,0 +1,50 @@ +package performance + +import ( + "slices" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +type rebuildCascadeSpec struct { + hotRuleID string + amplifierRuleID string + hotMessage func(id string, count int, threshold int, sample string) string + amplifierMessage func(id string, count int, threshold int, sample string) string +} + +func dependencyRebuildCascadeFindings(env support.Context, graph support.DependencyGraph, candidates []string, spec rebuildCascadeSpec) []core.Finding { + hotThreshold := env.Config.Checks.PerformanceRules.HotPackageImporterThreshold + if hotThreshold <= 0 { + hotThreshold = defaultHotPackageImporterThreshold + } + amplifierThreshold := env.Config.Checks.PerformanceRules.RebuildAmplifierThreshold + if amplifierThreshold <= 0 { + amplifierThreshold = defaultRebuildAmplifierThreshold + } + reverse := support.ReverseDependencyMap(graph) + findings := make([]core.Finding, 0) + for _, id := range candidates { + node, ok := graph.Nodes[id] + if !ok { + continue + } + importers := append([]string(nil), reverse[id]...) + slices.Sort(importers) + if len(importers) > hotThreshold { + findings = append(findings, env.NewFinding(support.FindingInput{ + RuleID: spec.hotRuleID, Level: "warn", Path: node.Path, Line: 0, Column: 1, + Message: spec.hotMessage(id, len(importers), hotThreshold, rebuildCascadeSample(importers)), + })) + } + dependents := support.TransitiveDependents(reverse, id) + if len(dependents) > amplifierThreshold { + findings = append(findings, env.NewFinding(support.FindingInput{ + RuleID: spec.amplifierRuleID, Level: "warn", Path: node.Path, Line: 0, Column: 1, + Message: spec.amplifierMessage(id, len(dependents), amplifierThreshold, rebuildCascadeSample(dependents)), + })) + } + } + return findings +} diff --git a/internal/codeguard/checks/performance/performance_scan_language.go b/internal/codeguard/checks/performance/performance_scan_language.go index d936b74..0ba371a 100644 --- a/internal/codeguard/checks/performance/performance_scan_language.go +++ b/internal/codeguard/checks/performance/performance_scan_language.go @@ -33,11 +33,12 @@ func scanLanguagePerformanceFindings(env support.Context, target core.TargetConf }, }, support.LanguageDispatch{ - Aliases: []string{"c++", "cpp", "cxx"}, + Aliases: []string{"c++", "cpp", "cxx", "cc"}, Run: func() []core.Finding { - return support.ScanCPPFiles(env, target, "performance", func(file string, data []byte) []core.Finding { + findings := cppRebuildCascadeFindings(env, target) + return append(findings, support.ScanCPPFiles(env, target, "performance", func(file string, data []byte) []core.Finding { return cppPerformanceFindings(env, file, data) - }) + })...) }, }, support.LanguageDispatch{ diff --git a/internal/codeguard/checks/performance/performance_score.go b/internal/codeguard/checks/performance/performance_score.go index 9844397..b880c36 100644 --- a/internal/codeguard/checks/performance/performance_score.go +++ b/internal/codeguard/checks/performance/performance_score.go @@ -25,6 +25,7 @@ var performanceScoreWeights = map[string]int{ "performance.python.sync-io-in-async": 4, "performance.unbounded-goroutines-in-loop": 4, + "performance.cpp.unbounded-concurrency": 4, "performance.typescript.unbounded-concurrency": 4, "performance.javascript.unbounded-concurrency": 4, "performance.python.unbounded-concurrency": 4, diff --git a/internal/codeguard/checks/quality/quality_scan_language.go b/internal/codeguard/checks/quality/quality_scan_language.go index 11326de..48ff6d4 100644 --- a/internal/codeguard/checks/quality/quality_scan_language.go +++ b/internal/codeguard/checks/quality/quality_scan_language.go @@ -40,7 +40,7 @@ func languageQualityFindings(ctx context.Context, env support.Context, target co }, }, support.LanguageDispatch{ - Aliases: []string{"c++", "cpp", "cxx"}, + Aliases: []string{"c++", "cpp", "cxx", "cc"}, Run: func() []core.Finding { return support.ScanCPPFiles(env, target, "quality", func(file string, data []byte) []core.Finding { return cppFindingsForFile(env, file, data) diff --git a/internal/codeguard/checks/security/security_additional_languages.go b/internal/codeguard/checks/security/security_additional_languages.go index 6851d26..e1ec631 100644 --- a/internal/codeguard/checks/security/security_additional_languages.go +++ b/internal/codeguard/checks/security/security_additional_languages.go @@ -26,6 +26,8 @@ func appendAdditionalLanguageLineFindings(env support.Context, file string, line switch { case isRustFile(file): return appendRustLineFindings(env, file, lineNo, raw, masked) + case isCPPFile(file): + return appendCPPLineFindings(env, file, lineNo, masked) case isJavaFile(file): return appendJavaLineFindings(env, file, lineNo, masked) case isCSharpFile(file): diff --git a/internal/codeguard/checks/security/security_common.go b/internal/codeguard/checks/security/security_common.go index 4bc7dff..8c50f84 100644 --- a/internal/codeguard/checks/security/security_common.go +++ b/internal/codeguard/checks/security/security_common.go @@ -67,6 +67,8 @@ func maskedSourceForFile(file string, source string) string { return support.MaskPythonSource(source) case isRustFile(file): return support.MaskCLikeSource(source, support.CLikeRust) + case isCPPFile(file): + return support.MaskCLikeSource(source, support.CLikeCPP) case isJavaFile(file), isCSharpFile(file): return support.MaskCLikeSource(source, support.CLikeJava) default: diff --git a/internal/codeguard/checks/security/security_cpp.go b/internal/codeguard/checks/security/security_cpp.go new file mode 100644 index 0000000..75e6320 --- /dev/null +++ b/internal/codeguard/checks/security/security_cpp.go @@ -0,0 +1,33 @@ +package security + +import ( + "regexp" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +var ( + cppShellPattern = regexp.MustCompile(`\b(?:(?:std\s*::\s*)?system|popen|_popen)\s*\(`) + cppCurlTLSPattern = regexp.MustCompile(`\bcurl_easy_setopt\s*\([^;]*(?:CURLOPT_SSL_VERIFYPEER|CURLOPT_SSL_VERIFYHOST)\s*,\s*(?:0(?:[uUlL]*)?|false)\b`) + cppOpenSSLTLSPattern = regexp.MustCompile(`\bSSL_CTX_set_verify\s*\([^;]*\bSSL_VERIFY_NONE\b`) + cppVerifyNonePattern = regexp.MustCompile(`\b(?:set_verify_mode\s*\([^;]*\bverify_none\b|set_validate_certificates\s*\(\s*false\b)`) + cppUnsafeCAPIPattern = regexp.MustCompile(`\b(?:gets|strcpy|strcat|sprintf|vsprintf)\s*\(`) +) + +func appendCPPLineFindings(env support.Context, file string, lineNo int, line string) []core.Finding { + switch { + case cppCurlTLSPattern.MatchString(line), cppOpenSSLTLSPattern.MatchString(line), cppVerifyNonePattern.MatchString(line): + return []core.Finding{env.NewFinding(support.FindingInput{RuleID: "security.cpp.insecure-tls", Level: "fail", Path: file, Line: lineNo, Column: 1, Message: "C++ TLS certificate or hostname verification is disabled"})} + case cppShellPattern.MatchString(line): + return []core.Finding{env.NewFinding(support.FindingInput{RuleID: "security.cpp.shell-execution", Level: "warn", Path: file, Line: lineNo, Column: 1, Message: "C++ shell execution primitive should be reviewed"})} + case cppUnsafeCAPIPattern.MatchString(line): + return []core.Finding{env.NewFinding(support.FindingInput{RuleID: "security.cpp.unsafe-c-api", Level: "warn", Path: file, Line: lineNo, Column: 1, Message: "unbounded C string API should be replaced with a bounds-aware operation"})} + default: + return nil + } +} + +func isCPPFile(path string) bool { + return support.IsCPPPath(path, true) +} diff --git a/internal/codeguard/checks/supplychain/policy_helpers.go b/internal/codeguard/checks/supplychain/policy_helpers.go index ef79ea8..4ccde3f 100644 --- a/internal/codeguard/checks/supplychain/policy_helpers.go +++ b/internal/codeguard/checks/supplychain/policy_helpers.go @@ -22,7 +22,7 @@ func changedFilesSet(paths []string) map[string]struct{} { func manifestExpectsLockfile(manifest core.SupplyChainManifest) bool { switch manifest.Ecosystem { - case "go", "npm", "cargo": + case "go", "npm", "cargo", "conan": return true case "python": return manifest.PackageManager == "poetry" || manifest.PackageManager == "uv" diff --git a/internal/codeguard/checks/support/cpp_dependency_graph.go b/internal/codeguard/checks/support/cpp_dependency_graph.go new file mode 100644 index 0000000..12ef56f --- /dev/null +++ b/internal/codeguard/checks/support/cpp_dependency_graph.go @@ -0,0 +1,98 @@ +package support + +import ( + "path" + "path/filepath" + "regexp" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +var ( + cppModuleDeclarationPattern = regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?module[ \t]+([A-Za-z_]\w*(?::[A-Za-z_]\w*)*)[ \t]*;`) + cppModuleImportPattern = regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?import[ \t]+([A-Za-z_]\w*(?::[A-Za-z_]\w*)*)[ \t]*;`) +) + +// CPPDependencyGraph captures target-local #include and C++20 named-module +// dependencies. Nodes are files because headers, unlike Go packages, are the +// unit whose edits fan out compilation work. +type CPPDependencyGraph struct { + Graph DependencyGraph + FileToModule map[string]string +} + +type pendingCPPDependency struct { + from string + target string + line int + named bool +} + +// BuildCPPDependencyGraph resolves only includes and module imports that map +// unambiguously to files inside the target. Compiler include paths and system +// headers are intentionally ignored because they cannot be inferred safely. +func BuildCPPDependencyGraph(env Context, target core.TargetConfig) *CPPDependencyGraph { + nodes := make(map[string]DependencyNode) + fileToModule := make(map[string]string) + moduleFiles := make(map[string]string) + pending := make([]pendingCPPDependency, 0) + env.VisitTargetFiles(target, func(rel string) bool { return IsCPPPath(rel, true) }, func(rel string, data []byte) { + rel = filepath.ToSlash(rel) + nodes[rel] = DependencyNode{ID: rel, Path: rel} + fileToModule[rel] = rel + source := string(data) + parsed := ParseCLike(source, CLikeCPP) + for _, imported := range parsed.Imports { + pending = append(pending, pendingCPPDependency{from: rel, target: imported.Module, line: imported.Line}) + } + if match := cppModuleDeclarationPattern.FindStringSubmatch(parsed.Masked); match != nil { + moduleFiles[match[1]] = rel + } + for _, match := range cppModuleImportPattern.FindAllStringSubmatchIndex(parsed.Masked, -1) { + pending = append(pending, pendingCPPDependency{ + from: rel, target: parsed.Masked[match[2]:match[3]], + line: LineNumberForOffset(parsed.Masked, match[0]), named: true, + }) + } + }) + if len(nodes) == 0 { + return nil + } + seen := make(map[string]map[string]bool, len(nodes)) + for _, dependency := range pending { + to := "" + if dependency.named { + to = moduleFiles[dependency.target] + } else { + to = resolveCPPInclude(nodes, dependency.from, dependency.target) + } + if to == "" || to == dependency.from { + continue + } + if seen[dependency.from] == nil { + seen[dependency.from] = make(map[string]bool) + } + if seen[dependency.from][to] { + continue + } + seen[dependency.from][to] = true + node := nodes[dependency.from] + node.Edges = append(node.Edges, DependencyEdge{To: to, Line: dependency.line}) + nodes[dependency.from] = node + } + return &CPPDependencyGraph{Graph: NewDependencyGraph(nodes), FileToModule: fileToModule} +} + +func resolveCPPInclude(nodes map[string]DependencyNode, from string, imported string) string { + imported = filepath.ToSlash(imported) + candidates := []string{ + path.Clean(path.Join(path.Dir(from), imported)), + path.Clean(imported), + } + for _, candidate := range candidates { + if _, ok := nodes[candidate]; ok { + return candidate + } + } + return "" +} diff --git a/internal/codeguard/checks/support/parser_clike_imports.go b/internal/codeguard/checks/support/parser_clike_imports.go index 80e1394..589a987 100644 --- a/internal/codeguard/checks/support/parser_clike_imports.go +++ b/internal/codeguard/checks/support/parser_clike_imports.go @@ -22,7 +22,7 @@ func clikeImports(source string, masked string, lang CLikeLanguage) []ParsedImpo case CLikeJava: return javaImports(masked) case CLikeCPP: - return cppImports(source) + return cppImports(source, masked) case CLikeRust: return rustImports(masked) default: @@ -128,9 +128,14 @@ func rustUseImport(path string, line int) ParsedImport { return ParsedImport{Module: path, Alias: alias, Line: line} } -func cppImports(source string) []ParsedImport { +func cppImports(source string, masked string) []ParsedImport { imports := make([]ParsedImport, 0, 4) for _, match := range cppIncludePattern.FindAllStringSubmatchIndex(source, -1) { + // The path itself is masked as a string literal, but the directive must + // remain visible. This rejects #include text inside comments/raw strings. + if !strings.Contains(masked[match[0]:match[2]], "#include") { + continue + } path := source[match[2]:match[3]] path = strings.Trim(path, `<> "`) alias := path diff --git a/internal/codeguard/checks/support/scan_language_helpers.go b/internal/codeguard/checks/support/scan_language_helpers.go index bc4de02..b5736fe 100644 --- a/internal/codeguard/checks/support/scan_language_helpers.go +++ b/internal/codeguard/checks/support/scan_language_helpers.go @@ -27,11 +27,31 @@ func ScanRustFiles(env Context, target core.TargetConfig, section string, scan f func ScanCPPFiles(env Context, target core.TargetConfig, section string, scan func(file string, data []byte) []core.Finding) []core.Finding { return env.ScanTargetFiles(target, section, func(rel string) bool { - switch strings.ToLower(filepath.Ext(rel)) { - case ".cc", ".cp", ".cpp", ".cxx", ".c++", ".hh", ".hpp", ".hxx", ".h++", ".ipp", ".tpp": - return true - default: - return false - } + // A target that explicitly declares C++ resolves the otherwise ambiguous + // .h and inline/template header suffixes as C++. Standalone grammar + // discovery remains conservative for .h; see ScriptLanguageForPath. + return IsCPPPath(rel, true) }, scan) } + +// IsCPPPath reports whether path uses a conventional C++ source, header, or +// C++20 module-interface suffix. includeAmbiguousHeaders is reserved for an +// explicitly C++ target, where .h/.inc cannot be mistaken for C or another +// language. +func IsCPPPath(path string, includeAmbiguousHeaders bool) bool { + rawExt := filepath.Ext(path) + if rawExt == ".C" { // conventional case-sensitive Unix C++ source suffix + return true + } + ext := strings.ToLower(rawExt) + switch ext { + case ".cc", ".cp", ".cpp", ".cxx", ".c++", + ".hh", ".hpp", ".hxx", ".h++", ".ipp", ".tpp", ".inl", ".txx", + ".ixx", ".cppm", ".cxxm", ".ccm", ".c++m", ".mpp", ".mxx", ".ii": + return true + case ".h", ".inc": + return includeAmbiguousHeaders + default: + return false + } +} diff --git a/internal/codeguard/checks/support/supply_chain.go b/internal/codeguard/checks/support/supply_chain.go index f748161..15c896f 100644 --- a/internal/codeguard/checks/support/supply_chain.go +++ b/internal/codeguard/checks/support/supply_chain.go @@ -34,6 +34,10 @@ func IsSupplyChainManifest(rel string) bool { return true case base == "cargo.toml": return true + case base == "vcpkg.json": + return true + case base == "conanfile.txt": + return true case strings.HasPrefix(base, "requirements") && strings.HasSuffix(base, ".txt"): return true default: @@ -81,6 +85,10 @@ func parseSupplyChainManifest(root string, rel string, data []byte) (core.Supply return parsePyprojectManifest(root, rel, data), true case "cargo.toml": return parseCargoManifest(root, rel, data), true + case "vcpkg.json": + return parseVCPKGManifest(root, rel, data) + case "conanfile.txt": + return parseConanTextManifest(root, rel, data), true default: if strings.HasPrefix(strings.ToLower(path.Base(rel)), "requirements") && strings.HasSuffix(strings.ToLower(path.Base(rel)), ".txt") { return parseRequirementsManifest(root, rel, data), true diff --git a/internal/codeguard/checks/support/supply_chain_cpp.go b/internal/codeguard/checks/support/supply_chain_cpp.go new file mode 100644 index 0000000..d3610f7 --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_cpp.go @@ -0,0 +1,178 @@ +package support + +import ( + "encoding/json" + "regexp" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +var vcpkgBaselinePattern = regexp.MustCompile(`^[0-9a-fA-F]{40}$`) + +func parseVCPKGManifest(root string, rel string, data []byte) (core.SupplyChainManifest, bool) { + var raw struct { + Name string `json:"name"` + BuiltinBaseline string `json:"builtin-baseline"` + Dependencies []json.RawMessage `json:"dependencies"` + Overrides []struct { + Name string `json:"name"` + Version string `json:"version"` + } `json:"overrides"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return core.SupplyChainManifest{}, false + } + overrides := make(map[string]string, len(raw.Overrides)) + for _, override := range raw.Overrides { + if name, version := strings.TrimSpace(override.Name), strings.TrimSpace(override.Version); name != "" && version != "" { + overrides[name] = version + } + } + baselinePinned := vcpkgBaselinePattern.MatchString(strings.TrimSpace(raw.BuiltinBaseline)) + manifest := core.SupplyChainManifest{ + Ecosystem: "vcpkg", + PackageManager: "vcpkg", + Path: rel, + Name: strings.TrimSpace(raw.Name), + Lockfiles: presentLockfiles(root, rel, []string{"vcpkg-lock.json"}), + } + for _, entry := range raw.Dependencies { + name, constraint, scope, ok := parseVCPKGDependency(entry) + if !ok { + continue + } + version := strings.TrimSpace(overrides[name]) + requirement := constraint + pinned := version != "" || baselinePinned + if version != "" { + requirement = version + } else if baselinePinned && requirement == "" { + requirement = "builtin-baseline@" + strings.TrimSpace(raw.BuiltinBaseline) + } + manifest.Dependencies = append(manifest.Dependencies, core.SupplyChainDependency{ + Name: name, + Requirement: requirement, + Version: version, + Scope: scope, + Pinned: pinned, + Line: findJSONKeyLine(data, name), + }) + } + sortDependencies(manifest.Dependencies) + return manifest, true +} + +func parseVCPKGDependency(data json.RawMessage) (name string, constraint string, scope string, ok bool) { + var simple string + if err := json.Unmarshal(data, &simple); err == nil { + name = strings.TrimSpace(simple) + return name, "", "runtime", name != "" + } + var object struct { + Name string `json:"name"` + VersionMin string `json:"version>="` + Host bool `json:"host"` + } + if err := json.Unmarshal(data, &object); err != nil { + return "", "", "", false + } + scope = "runtime" + if object.Host { + scope = "build" + } + name = strings.TrimSpace(object.Name) + return name, strings.TrimSpace(object.VersionMin), scope, name != "" +} + +func parseConanTextManifest(root string, rel string, data []byte) core.SupplyChainManifest { + manifest := core.SupplyChainManifest{ + Ecosystem: "conan", + PackageManager: "conan", + Path: rel, + Lockfiles: presentLockfiles(root, rel, []string{"conan.lock"}), + } + section := "" + for idx, rawLine := range strings.Split(string(data), "\n") { + line := strings.TrimSpace(rawLine) + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + section = strings.ToLower(strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "["), "]"))) + continue + } + if section != "requires" && section != "tool_requires" { + continue + } + if strings.HasPrefix(line, "#") { + continue + } + if comment := strings.Index(line, ";"); comment >= 0 { + line = strings.TrimSpace(line[:comment]) + } + if dep, ok := parseConanReference(line, section, idx+1); ok { + manifest.Dependencies = append(manifest.Dependencies, dep) + } + } + sortDependencies(manifest.Dependencies) + return manifest +} + +func parseConanReference(reference string, section string, line int) (core.SupplyChainDependency, bool) { + reference = strings.TrimSpace(reference) + if reference == "" { + return core.SupplyChainDependency{}, false + } + name, remainder, ok := strings.Cut(reference, "/") + if !ok || strings.TrimSpace(name) == "" || strings.TrimSpace(remainder) == "" { + return core.SupplyChainDependency{}, false + } + version := remainder + if idx := strings.IndexAny(version, "@#"); idx >= 0 { + version = version[:idx] + } + version = strings.TrimSpace(version) + scope := "runtime" + if section == "tool_requires" { + scope = "build" + } + lowered := strings.ToLower(version) + pinned := version != "" && !strings.ContainsAny(version, "[]<>=~^*,|") && lowered != "latest" + return core.SupplyChainDependency{ + Name: strings.TrimSpace(name), + Requirement: reference, + Version: version, + Scope: scope, + Pinned: pinned, + Line: line, + }, true +} + +func parseConanLockState(data []byte) (lockfileState, bool) { + var lock struct { + Requires []string `json:"requires"` + BuildRequires []string `json:"build_requires"` + PythonRequires []string `json:"python_requires"` + GraphLock struct { + Nodes map[string]struct { + Ref string `json:"ref"` + } `json:"nodes"` + } `json:"graph_lock"` + } + if err := json.Unmarshal(data, &lock); err != nil { + return lockfileState{}, false + } + state := newLockfileState() + refs := append([]string(nil), lock.Requires...) + refs = append(refs, lock.BuildRequires...) + refs = append(refs, lock.PythonRequires...) + for _, node := range lock.GraphLock.Nodes { + refs = append(refs, node.Ref) + } + for _, ref := range refs { + dep, ok := parseConanReference(ref, "requires", 0) + if !ok { + continue + } + addLockfilePackage(state, dep.Name, dep.Version) + } + return state, len(state.packages) > 0 +} diff --git a/internal/codeguard/checks/support/supply_chain_lockfiles.go b/internal/codeguard/checks/support/supply_chain_lockfiles.go index 05ec3dc..6d86325 100644 --- a/internal/codeguard/checks/support/supply_chain_lockfiles.go +++ b/internal/codeguard/checks/support/supply_chain_lockfiles.go @@ -59,6 +59,8 @@ func parseLockfileState(path string, data []byte) (lockfileState, bool) { return parseBunLockState(data), true case "cargo.lock", "poetry.lock", "uv.lock": return parsePackageBlockLockState(data), true + case "conan.lock": + return parseConanLockState(data) default: return lockfileState{}, false } diff --git a/internal/codeguard/checks/support/supply_chain_lockfiles_versions.go b/internal/codeguard/checks/support/supply_chain_lockfiles_versions.go index 79c32ea..fcb3b9c 100644 --- a/internal/codeguard/checks/support/supply_chain_lockfiles_versions.go +++ b/internal/codeguard/checks/support/supply_chain_lockfiles_versions.go @@ -18,6 +18,10 @@ func exactLockedVersion(manifest core.SupplyChainManifest, dep core.SupplyChainD if dep.Pinned { return strings.TrimSpace(dep.Version) } + case "conan": + if dep.Pinned { + return strings.TrimSpace(dep.Version) + } case "python": if strings.HasPrefix(strings.TrimSpace(dep.Requirement), "==") || strings.Contains(strings.TrimSpace(dep.Requirement), "==") { return trimPythonVersion(dep.Version) diff --git a/internal/codeguard/checks/support/treeprovider.go b/internal/codeguard/checks/support/treeprovider.go index f1e08b0..6616260 100644 --- a/internal/codeguard/checks/support/treeprovider.go +++ b/internal/codeguard/checks/support/treeprovider.go @@ -29,6 +29,9 @@ const ( // Python grammar, and common C++ source/header suffixes the C++ grammar. It // returns "" for unsupported files. func ScriptLanguageForPath(path string) ScriptLanguage { + if IsCPPPath(path, false) { + return ScriptLangCPP + } switch strings.ToLower(filepath.Ext(path)) { case ".ts", ".mts", ".cts": return ScriptLangTypeScript @@ -38,8 +41,6 @@ func ScriptLanguageForPath(path string) ScriptLanguage { return ScriptLangJavaScript case ".py": return ScriptLangPython - case ".cc", ".cp", ".cpp", ".cxx", ".c++", ".hh", ".hpp", ".hxx", ".h++", ".ipp", ".tpp": - return ScriptLangCPP default: return "" } diff --git a/internal/codeguard/checks/support/typescript_semantic_runner_bootstrap.js b/internal/codeguard/checks/support/typescript_semantic_runner_bootstrap.js index 5f4d06f..0abaca9 100644 --- a/internal/codeguard/checks/support/typescript_semantic_runner_bootstrap.js +++ b/internal/codeguard/checks/support/typescript_semantic_runner_bootstrap.js @@ -1,2 +1,5 @@ -// Intentionally empty: the combined runner enters from core so each fragment -// owns the symbols it defines for static-analysis tooling. +// Enter only after every fragment has initialized its top-level constants. +// Function declarations are hoisted across the combined script, but const +// bindings in the security and taint fragments remain in the temporal dead +// zone until evaluation reaches them. +main(); diff --git a/internal/codeguard/checks/support/typescript_semantic_runner_core.js b/internal/codeguard/checks/support/typescript_semantic_runner_core.js index 9109946..93eb857 100644 --- a/internal/codeguard/checks/support/typescript_semantic_runner_core.js +++ b/internal/codeguard/checks/support/typescript_semantic_runner_core.js @@ -196,7 +196,6 @@ function normalizedModuleName(relPath) { return lower.slice(0, -path.extname(lower).length); } -main(); function analyzeDirectives(sourceFile, relPath, flavor) { const lines = sourceFile.text.replace(/\r\n/g, "\n").split("\n"); diff --git a/internal/codeguard/config/defaults_contracts.go b/internal/codeguard/config/defaults_contracts.go index 275b059..d8f3c19 100644 --- a/internal/codeguard/config/defaults_contracts.go +++ b/internal/codeguard/config/defaults_contracts.go @@ -6,6 +6,9 @@ func applyContractDefaults(dst *core.ContractRulesConfig, def core.ContractRules if dst.GoExportedBreaking == nil { dst.GoExportedBreaking = boolPtr(true) } + if dst.CPPPublicBreaking == nil { + dst.CPPPublicBreaking = boolPtr(true) + } if dst.OpenAPIBreaking == nil { dst.OpenAPIBreaking = boolPtr(true) } diff --git a/internal/codeguard/config/example.go b/internal/codeguard/config/example.go index e4ba3bd..2f95d5e 100644 --- a/internal/codeguard/config/example.go +++ b/internal/codeguard/config/example.go @@ -75,6 +75,7 @@ func exampleSupplyChainRules() core.SupplyChainRulesConfig { func exampleContractRules() core.ContractRulesConfig { return core.ContractRulesConfig{ GoExportedBreaking: boolPtr(true), + CPPPublicBreaking: boolPtr(true), OpenAPIBreaking: boolPtr(true), ProtoBreaking: boolPtr(true), MigrationDestructive: boolPtr(true), diff --git a/internal/codeguard/core/config_contract_types.go b/internal/codeguard/core/config_contract_types.go index e863b3e..ea83f17 100644 --- a/internal/codeguard/core/config_contract_types.go +++ b/internal/codeguard/core/config_contract_types.go @@ -2,6 +2,7 @@ package core type ContractRulesConfig struct { GoExportedBreaking *bool `json:"go_exported_breaking,omitempty" yaml:"go_exported_breaking,omitempty"` + CPPPublicBreaking *bool `json:"cpp_public_breaking,omitempty" yaml:"cpp_public_breaking,omitempty"` OpenAPIBreaking *bool `json:"openapi_breaking,omitempty" yaml:"openapi_breaking,omitempty"` ProtoBreaking *bool `json:"proto_breaking,omitempty" yaml:"proto_breaking,omitempty"` MigrationDestructive *bool `json:"migration_destructive,omitempty" yaml:"migration_destructive,omitempty"` diff --git a/internal/codeguard/core/config_rule_types.go b/internal/codeguard/core/config_rule_types.go index 806a88e..df169c1 100644 --- a/internal/codeguard/core/config_rule_types.go +++ b/internal/codeguard/core/config_rule_types.go @@ -60,14 +60,14 @@ type PerformanceRulesConfig struct { // framework evidence (imports or obvious idioms), so non-framework code // never matches. DetectFrameworkPatterns *bool `json:"detect_framework_patterns,omitempty" yaml:"detect_framework_patterns,omitempty"` - // DetectRebuildCascade flags Go packages whose import graph position makes - // them rebuild hot spots or rebuild-cascade amplifiers. + // DetectRebuildCascade flags Go packages and C++ headers/modules whose + // dependency-graph position makes them rebuild hot spots or amplifiers. DetectRebuildCascade *bool `json:"detect_rebuild_cascade,omitempty" yaml:"detect_rebuild_cascade,omitempty"` // HotPackageImporterThreshold is the direct importer count above which - // performance.go.hot-package fires. Zero means use the default threshold. + // the language-specific hot package/header rule fires. Zero uses the default. HotPackageImporterThreshold int `json:"hot_package_importer_threshold,omitempty" yaml:"hot_package_importer_threshold,omitempty"` // RebuildAmplifierThreshold is the transitive dependent count above which - // performance.go.rebuild-amplifier fires. Zero means use the default threshold. + // a language-specific rebuild-amplifier rule fires. Zero uses the default. RebuildAmplifierThreshold int `json:"rebuild_amplifier_threshold,omitempty" yaml:"rebuild_amplifier_threshold,omitempty"` // Budgets lists measured size gates over build artifacts (see // PerformanceBudgetConfig); findings report as performance.budget. diff --git a/internal/codeguard/rules/catalog_contracts.go b/internal/codeguard/rules/catalog_contracts.go index 1dcf68f..85a9cf8 100644 --- a/internal/codeguard/rules/catalog_contracts.go +++ b/internal/codeguard/rules/catalog_contracts.go @@ -13,6 +13,16 @@ var contractsCatalog = map[string]core.RuleMetadata{ Description: "Fails in diff mode when exported Go functions, methods, types, or consts are removed or renamed, or when an exported function signature changes against the base ref.", HowToFix: "Restore the exported declaration or signature, or ship the break deliberately with a deprecation path and a major version bump.", }, + "contracts.cpp-public-breaking": { + ID: "contracts.cpp-public-breaking", + Section: "API Contracts", + DefaultLevel: "fail", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageCPP), + Title: "C++ public-header breaking change", + Description: "Fails in diff mode when a declaration in an include, public, or api header is removed, renamed, or changes signature against the base ref.", + HowToFix: "Keep the old declaration available with a compatibility implementation, or deliberately version the public C++ API and ABI.", + }, "contracts.openapi-breaking": { ID: "contracts.openapi-breaking", Section: "API Contracts", diff --git a/internal/codeguard/rules/catalog_design.go b/internal/codeguard/rules/catalog_design.go index 2d18f77..332b8c8 100644 --- a/internal/codeguard/rules/catalog_design.go +++ b/internal/codeguard/rules/catalog_design.go @@ -93,6 +93,26 @@ var designCatalog = map[string]core.RuleMetadata{ Description: "Warns when a TypeScript or JavaScript module name is too generic to communicate ownership or responsibility.", HowToFix: "Rename the module to something specific to its responsibility.", }, + "design.cpp.generic-module-name": { + ID: "design.cpp.generic-module-name", + Section: "Design Patterns", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageCPP), + Title: "C++ generic file name", + Description: "Warns when a C++ source, header, or module file name is too generic to communicate responsibility.", + HowToFix: "Rename the file after the component or responsibility it owns.", + }, + "design.cpp.max-methods-per-type": { + ID: "design.cpp.max-methods-per-type", + Section: "Design Patterns", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageCPP), + Title: "C++ methods per type", + Description: "Warns when one C++ file defines too many qualified methods for the same type.", + HowToFix: "Split the type's responsibilities across smaller types or extracted collaborators.", + }, "design.typescript.max-methods-per-type": { ID: "design.typescript.max-methods-per-type", Section: "Design Patterns", diff --git a/internal/codeguard/rules/catalog_design_graph.go b/internal/codeguard/rules/catalog_design_graph.go index 73da943..0552d44 100644 --- a/internal/codeguard/rules/catalog_design_graph.go +++ b/internal/codeguard/rules/catalog_design_graph.go @@ -39,6 +39,16 @@ var designGraphCatalog = map[string]core.RuleMetadata{ Description: "Fails when Java classes form an internal import cycle.", HowToFix: "Break the cycle by introducing an interface or moving shared types into a lower-level package.", }, + "design.cpp.import-cycle": { + ID: "design.cpp.import-cycle", + Section: "Design Patterns", + DefaultLevel: "fail", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageCPP), + Title: "C++ include or module cycle", + Description: "Fails when target-local C++ headers, source files, or named modules form a dependency cycle.", + HowToFix: "Break the cycle by moving shared declarations into a lower-level header/module or depending on an abstract interface.", + }, "design.god-module": { ID: "design.god-module", Section: "Design Patterns", @@ -51,6 +61,7 @@ var designGraphCatalog = map[string]core.RuleMetadata{ core.RuleLanguageJavaScript, core.RuleLanguageRust, core.RuleLanguageJava, + core.RuleLanguageCPP, ), Title: "God module", Description: "Warns when a module's combined fan-in and fan-out exceeds the configured threshold, indicating it concentrates too much of the dependency graph.", @@ -68,6 +79,7 @@ var designGraphCatalog = map[string]core.RuleMetadata{ core.RuleLanguageJavaScript, core.RuleLanguageRust, core.RuleLanguageJava, + core.RuleLanguageCPP, ), Title: "High impact change", Description: "Warns in diff mode when a changed module has more transitive dependents than the configured threshold.", diff --git a/internal/codeguard/rules/catalog_fix_templates_design.go b/internal/codeguard/rules/catalog_fix_templates_design.go index 2bdf222..3ab5bca 100644 --- a/internal/codeguard/rules/catalog_fix_templates_design.go +++ b/internal/codeguard/rules/catalog_fix_templates_design.go @@ -27,6 +27,9 @@ var designFixTemplates = map[string]core.FixTemplate{ "design.javascript.import-cycle": {Kind: guided, Text: "Break the cycle by extracting the shared symbol into a lower-level module.\n\nBefore:\n// order.js requires user.js; user.js requires order.js\n\nAfter:\n// both require the shared helpers from ids.js\n// order.js -> ids.js <- user.js"}, "design.rust.import-cycle": {Kind: guided, Text: "Break the cycle by moving the shared items into a lower-level module.\n\nBefore:\n// orders.rs uses crate::users; users.rs uses crate::orders\n\nAfter:\n// move the shared struct into models.rs\n// orders.rs -> models.rs <- users.rs"}, "design.java.import-cycle": {Kind: guided, Text: "Break the cycle with an interface owned by the lower-level package.\n\nBefore:\n// billing.Invoice imports shipping.RateTable; shipping.RateTable imports billing.Invoice\n\nAfter:\n// billing defines interface RateSource; shipping implements it\n// billing no longer imports shipping, and shipping depends only on the interface"}, + "design.cpp.import-cycle": {Kind: guided, Text: "Break the include/module cycle by moving shared declarations into a lower-level header or depending on an abstract interface.\n\nBefore:\n// order.hpp includes user.hpp; user.hpp includes order.hpp\n\nAfter:\n// use forward declarations where possible, or move shared declarations to model.hpp"}, + "design.cpp.generic-module-name": {Kind: guided, Text: "Rename the C++ file after the component it owns.\n\nBefore:\n// src/utils.cpp\n\nAfter:\n// src/date_format.cpp"}, + "design.cpp.max-methods-per-type": {Kind: guided, Text: "Split the C++ type's responsibilities across smaller collaborators.\n\nBefore:\nclass Service { /* methods for auth, billing, and reporting */ };\n\nAfter:\nclass AuthService;\nclass BillingService;\nclass ReportService;"}, "design.god-module": {Kind: guided, Text: "Split the module into focused pieces and route consumers through narrower interfaces.\n\nBefore:\n// core.ts: imported by 40 modules and importing 25 others\n\nAfter:\n// split into config.ts, events.ts, and store.ts\n// consumers import only the piece they actually use"}, "design.high-impact-change": {Kind: guided, Text: "Reduce the blast radius before merging a change to a widely depended-on module.\n\nBefore:\n// one commit rewrites shared/http.ts, which 60 modules transitively depend on\n\nAfter:\n// land a backwards-compatible refactor first, then migrate dependents in batches\n// add tests for the highest-traffic dependents before the breaking step"}, } diff --git a/internal/codeguard/rules/catalog_fix_templates_misc.go b/internal/codeguard/rules/catalog_fix_templates_misc.go index f3b7ad5..90a222c 100644 --- a/internal/codeguard/rules/catalog_fix_templates_misc.go +++ b/internal/codeguard/rules/catalog_fix_templates_misc.go @@ -6,6 +6,7 @@ import "github.com/devr-tools/codeguard/internal/codeguard/core" // families. var miscFixTemplates = map[string]core.FixTemplate{ "contracts.go-exported-breaking": {Kind: guided, Text: "Keep the exported declaration working and add the new shape alongside it.\n\nBefore:\n// signature changed in place, breaking every caller\nfunc Fetch(ctx context.Context, url string) (*Result, error)\n\nAfter:\n// keep the old signature delegating to the new one\nfunc Fetch(url string) (*Result, error) {\n\treturn FetchContext(context.Background(), url)\n}\n\nfunc FetchContext(ctx context.Context, url string) (*Result, error)\n// or ship the break deliberately with a deprecation note and a major version bump"}, + "contracts.cpp-public-breaking": {Kind: guided, Text: "Keep the old public declaration and add the new API alongside it.\n\nBefore:\nResult fetch(std::string_view url, Options options);\n\nAfter:\nResult fetch(std::string_view url); // compatibility overload\nResult fetch(std::string_view url, Options options);\n// remove the compatibility overload only in a deliberate major API/ABI release"}, "contracts.openapi-breaking": {Kind: guided, Text: "Make the change additive so existing clients keep a working contract.\n\nBefore:\n# /v1/orders deleted; request field \"region\" made required\n\nAfter:\npaths:\n /v1/orders: {} # keep the old path, mark it deprecated: true\n /v2/orders: {} # additive replacement\n# keep new request fields optional with a server-side default"}, "contracts.proto-breaking": {Kind: guided, Text: "Reserve removed fields and deprecate instead of deleting so old clients keep decoding.\n\nBefore:\nmessage User {\n string name = 1;\n // removed: string email = 2;\n}\n\nAfter:\nmessage User {\n string name = 1;\n reserved 2;\n reserved \"email\";\n}\n// deprecate rpcs and messages rather than deleting them"}, "contracts.migration-destructive": {Kind: guided, Text: "Replace the one-shot destructive migration with an expand-migrate-contract sequence.\n\nBefore:\nALTER TABLE users DROP COLUMN legacy_email;\n\nAfter:\n-- 1. expand: add the replacement column and dual-write\n-- 2. migrate: backfill data and switch readers\n-- 3. contract: drop legacy_email in a later release, after a verified backup"}, diff --git a/internal/codeguard/rules/catalog_fix_templates_performance.go b/internal/codeguard/rules/catalog_fix_templates_performance.go index 99ff4a5..bf897fc 100644 --- a/internal/codeguard/rules/catalog_fix_templates_performance.go +++ b/internal/codeguard/rules/catalog_fix_templates_performance.go @@ -20,10 +20,13 @@ var performanceFixTemplates = map[string]core.FixTemplate{ "performance.go.sleep-in-loop": {Kind: guided, Text: "Replace the polling sleep with a ticker, a channel signal, or backoff.\n\nBefore:\nfor {\n\tif ready() {\n\t\tbreak\n\t}\n\ttime.Sleep(100 * time.Millisecond)\n}\n\nAfter:\nticker := time.NewTicker(100 * time.Millisecond)\ndefer ticker.Stop()\nfor range ticker.C {\n\tif ready() {\n\t\tbreak\n\t}\n}\n// or better: have the producer signal readiness on a channel"}, "performance.rust.sleep-in-loop": {Kind: guided, Text: "Replace the polling sleep with a proper synchronization primitive or bounded backoff.\n\nBefore:\nloop {\n if ready() { break; }\n std::thread::sleep(Duration::from_millis(100));\n}\n\nAfter:\nwhile !ready() {\n receiver.recv_timeout(Duration::from_millis(100)).ok();\n}\n// or use a Condvar / watch channel so readiness is signaled instead of polled"}, "performance.cpp.sleep-in-loop": {Kind: guided, Text: "Replace the polling sleep with a condition variable, timer primitive, or bounded backoff helper.\n\nBefore:\nwhile (!ready()) {\n std::this_thread::sleep_for(std::chrono::milliseconds(100));\n}\n\nAfter:\nstd::unique_lock lock(mu);\ncv.wait_for(lock, std::chrono::milliseconds(100), [&] { return ready(); });\n// or use an event/timer abstraction instead of polling"}, + "performance.cpp.unbounded-concurrency": {Kind: guided, Text: "Bound loop-driven thread/task creation with a fixed worker pool, semaphore, or bounded executor.\n\nBefore:\nfor (const auto& job : jobs) {\n threads.emplace_back(run, job);\n}\n\nAfter:\nThreadPool pool(8);\nfor (const auto& job : jobs) {\n pool.submit(run, job);\n}\npool.wait();"}, "performance.go.timer-leak-in-loop": {Kind: deterministic, Text: "Reuse one timer instead of allocating a new one per iteration with time.After.\n\nBefore:\nfor {\n\tselect {\n\tcase msg := <-inbox:\n\t\thandle(msg)\n\tcase <-time.After(time.Second): // leaks a timer per iteration\n\t\treturn\n\t}\n}\n\nAfter:\ntimer := time.NewTimer(time.Second)\ndefer timer.Stop()\nfor {\n\tselect {\n\tcase msg := <-inbox:\n\t\thandle(msg)\n\t\ttimer.Reset(time.Second)\n\tcase <-timer.C:\n\t\treturn\n\t}\n}"}, "performance.unbounded-read": {Kind: guided, Text: "Bound the read or process the input as a stream.\n\nBefore:\nfunc handler(w http.ResponseWriter, r *http.Request) {\n\tbody, _ := io.ReadAll(r.Body) // one oversized request = whole payload in memory\n}\n\nAfter:\nfunc handler(w http.ResponseWriter, r *http.Request) {\n\tbody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MiB cap\n}\n// Python: read(65536) in a loop, or iterate the file object line by line"}, "performance.go.hot-package": {Kind: guided, Text: "Reduce direct fan-in to the package so ordinary edits do not force broad recompiles.\n\nBefore:\ninternal/platform/config // imported directly by many packages\n\nAfter:\ninternal/platform/config/schema // stable leaf types\ninternal/platform/config/load // volatile implementation\n// move frequently changing code behind smaller leaf packages or interfaces"}, "performance.go.rebuild-amplifier": {Kind: guided, Text: "Break the package apart so changes stop cascading through the transitive import graph.\n\nBefore:\ninternal/core // depends on many details and is imported nearly everywhere\n\nAfter:\ninternal/core/contracts // stable shared interfaces\ninternal/core/runtime // volatile implementation details\n// pull fast-changing code into narrower leaves and keep shared contracts small"}, + "performance.cpp.hot-header": {Kind: guided, Text: "Reduce direct include fan-in so ordinary header edits do not force broad recompilation.\n\nBefore:\n// common.hpp includes unrelated declarations and is included everywhere\n\nAfter:\n// split focused declarations into narrow headers and use forward declarations where possible"}, + "performance.cpp.rebuild-amplifier": {Kind: guided, Text: "Break the transitive include cascade around the header/module.\n\nBefore:\n// core.hpp includes volatile implementation headers and is imported broadly\n\nAfter:\n// keep stable declarations in core.hpp; move implementation dependencies into .cpp files or private modules"}, "performance.string-concat-in-loop": {Kind: deterministic, Text: "Collect the parts and join once after the loop.\n\nBefore:\nout = \"\"\nfor row in rows:\n out += render(row)\n\nAfter:\nparts = [render(row) for row in rows]\nout = \"\".join(parts)\n// TS/JS: const parts: string[] = []; parts.push(render(row)); const out = parts.join(\"\")"}, "performance.python.unbounded-concurrency": {Kind: guided, Text: "Bound loop-driven task creation with a semaphore or task group.\n\nBefore:\nfor url in urls:\n asyncio.create_task(fetch(url))\n\nAfter:\nsem = asyncio.Semaphore(8)\n\nasync def bounded_fetch(url):\n async with sem:\n return await fetch(url)\n\nasync with asyncio.TaskGroup() as group:\n for url in urls:\n group.create_task(bounded_fetch(url))"}, "performance.typescript.await-in-loop": {Kind: guided, Text: "Run independent iterations concurrently with Promise.all.\n\nBefore:\nfor (const id of ids) {\n const user = await fetchUser(id); // serial round-trips\n users.push(user);\n}\n\nAfter:\nconst users = await Promise.all(ids.map((id) => fetchUser(id)));\n// keep the loop only when each iteration depends on the previous one"}, diff --git a/internal/codeguard/rules/catalog_fix_templates_security_lang.go b/internal/codeguard/rules/catalog_fix_templates_security_lang.go index ca28724..bbe6709 100644 --- a/internal/codeguard/rules/catalog_fix_templates_security_lang.go +++ b/internal/codeguard/rules/catalog_fix_templates_security_lang.go @@ -3,7 +3,7 @@ package rules import "github.com/devr-tools/codeguard/internal/codeguard/core" // securityLanguageFixTemplates covers the per-language security mirror rules -// (TypeScript, JavaScript, Python, Rust, Java, C#, Ruby) with snippets in the +// (TypeScript, JavaScript, Python, C++, Rust, Java, C#, Ruby) with snippets in the // idiom of each language. var securityLanguageFixTemplates = map[string]core.FixTemplate{ "security.typescript.insecure-tls": {Kind: deterministic, Text: "Remove the verification opt-out so TLS certificates are checked.\n\nBefore:\nconst agent = new https.Agent({ rejectUnauthorized: false });\n\nAfter:\nconst agent = new https.Agent(); // the default agent verifies certificates"}, @@ -27,6 +27,9 @@ var securityLanguageFixTemplates = map[string]core.FixTemplate{ "security.python.insecure-tls": {Kind: deterministic, Text: "Remove verify=False so TLS certificates are checked.\n\nBefore:\nresp = requests.get(url, verify=False)\n\nAfter:\nresp = requests.get(url) # requests verifies certificates by default\n# for a private CA: requests.get(url, verify=\"/etc/ssl/private-ca.pem\")"}, "security.python.shell-execution": {Kind: guided, Text: "Pass an argv list instead of a shell string.\n\nBefore:\nsubprocess.run(\"convert \" + user_file, shell=True)\n\nAfter:\nsubprocess.run([\"convert\", user_file], check=True) # fixed binary, no shell"}, "security.python.dynamic-code": {Kind: guided, Text: "Replace eval or exec with a safe parser or a dispatch dict.\n\nBefore:\nresult = eval(user_expression)\n\nAfter:\nresult = ast.literal_eval(user_expression) # literals only\n# or dispatch by name: handlers = {\"sum\": sum_values}; handlers[name](values)"}, + "security.cpp.insecure-tls": {Kind: deterministic, Text: "Keep certificate and hostname verification enabled.\n\nBefore:\ncurl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);\n\nAfter:\ncurl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);\ncurl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);"}, + "security.cpp.shell-execution": {Kind: guided, Text: "Replace shell parsing with a process API that accepts a fixed executable and argument vector.\n\nBefore:\nstd::system(command.c_str());\n\nAfter:\n// use the platform process API or a reviewed process library\nprocess::run(\"convert\", {user_file});"}, + "security.cpp.unsafe-c-api": {Kind: guided, Text: "Use a bounds-aware string operation.\n\nBefore:\nstrcpy(destination, source);\n\nAfter:\nstd::string destination{source};\n// for a fixed buffer, use a checked copy that always preserves termination"}, "security.rust.insecure-tls": {Kind: deterministic, Text: "Remove the certificate acceptance opt-out so TLS verification stays on.\n\nBefore:\nlet client = reqwest::Client::builder()\n .danger_accept_invalid_certs(true)\n .build()?;\n\nAfter:\nlet client = reqwest::Client::new(); // the default client verifies certificates"}, "security.rust.shell-execution": {Kind: guided, Text: "Spawn the target binary directly with typed arguments instead of a shell command line.\n\nBefore:\nCommand::new(\"sh\").arg(\"-c\").arg(format!(\"convert {user_file}\")).status()?;\n\nAfter:\nCommand::new(\"convert\").arg(&user_file).status()?; // fixed binary, no shell parsing"}, "security.java.insecure-tls": {Kind: deterministic, Text: "Delete the permissive verifier override so hostname and certificate checks stay on.\n\nBefore:\nHttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true);\n\nAfter:\n// no override: the default verifier validates hostnames and certificates\nHttpsURLConnection conn = (HttpsURLConnection) url.openConnection();"}, diff --git a/internal/codeguard/rules/catalog_performance.go b/internal/codeguard/rules/catalog_performance.go index b66b170..c2da2e4 100644 --- a/internal/codeguard/rules/catalog_performance.go +++ b/internal/codeguard/rules/catalog_performance.go @@ -168,6 +168,16 @@ var performanceCatalog = map[string]core.RuleMetadata{ Description: "Warns when std::this_thread::sleep_for or sleep_until runs inside a C++ loop body, which usually marks a poll that wants a condition variable, timer primitive, or bounded backoff helper (performance_rules.detect_sleep_in_loop).", HowToFix: "Replace the polling sleep with a condition variable, timer primitive, or bounded backoff helper.", }, + "performance.cpp.unbounded-concurrency": { + ID: "performance.cpp.unbounded-concurrency", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageCPP), + Title: "C++ unbounded concurrency", + Description: "Warns when threads or async tasks are accumulated or detached inside a loop without a visible concurrency bound (performance_rules.detect_unbounded_concurrency).", + HowToFix: "Use a fixed-size worker pool, semaphore, bounded executor, or process work in bounded batches.", + }, "performance.go.timer-leak-in-loop": { ID: "performance.go.timer-leak-in-loop", Section: "Performance", @@ -208,6 +218,26 @@ var performanceCatalog = map[string]core.RuleMetadata{ Description: "Warns when a Go package has too many transitive dependents in the in-repo package graph, so changes in that package amplify rebuild cascades across the target (performance_rules.detect_rebuild_cascade, rebuild_amplifier_threshold).", HowToFix: "Reduce upward coupling around the package, extract volatile code into leaf packages, and separate stable contracts from frequently changing implementation details.", }, + "performance.cpp.hot-header": { + ID: "performance.cpp.hot-header", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageCPP), + Title: "C++ rebuild hot header/module", + Description: "Warns when a target-local C++ file has too many direct include/module importers, making edits fan out recompilation broadly (performance_rules.detect_rebuild_cascade, hot_package_importer_threshold).", + HowToFix: "Split the header/module, reduce exposed dependencies, use forward declarations, or move volatile implementation behind a stable interface.", + }, + "performance.cpp.rebuild-amplifier": { + ID: "performance.cpp.rebuild-amplifier", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageCPP), + Title: "C++ rebuild cascade amplifier", + Description: "Warns when a target-local C++ file has too many transitive include/module dependents, amplifying rebuild cascades (performance_rules.detect_rebuild_cascade, rebuild_amplifier_threshold).", + HowToFix: "Reduce transitive includes, prefer forward declarations, and separate stable declarations from volatile implementation details.", + }, "performance.string-concat-in-loop": { ID: "performance.string-concat-in-loop", Section: "Performance", diff --git a/internal/codeguard/rules/catalog_security.go b/internal/codeguard/rules/catalog_security.go index b91251e..f4384c2 100644 --- a/internal/codeguard/rules/catalog_security.go +++ b/internal/codeguard/rules/catalog_security.go @@ -273,6 +273,36 @@ var securityCatalog = map[string]core.RuleMetadata{ Description: "Warns when Python code evaluates or executes dynamic source text.", HowToFix: "Remove eval or exec usage, or strictly constrain and validate the executed content.", }, + "security.cpp.insecure-tls": { + ID: "security.cpp.insecure-tls", + Section: "Security", + DefaultLevel: "fail", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageCPP), + Title: "C++ insecure TLS", + Description: "Fails when C++ code disables certificate or hostname verification in common libcurl, OpenSSL, Boost.Asio, or cpprestsdk APIs.", + HowToFix: "Enable certificate and hostname verification and use a reviewed trust store.", + }, + "security.cpp.shell-execution": { + ID: "security.cpp.shell-execution", + Section: "Security", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageCPP), + Title: "C++ shell execution review", + Description: "Warns when C++ code invokes system, popen, or _popen.", + HowToFix: "Prefer a direct library call or argument-vector process API, and strictly constrain any command input.", + }, + "security.cpp.unsafe-c-api": { + ID: "security.cpp.unsafe-c-api", + Section: "Security", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageCPP), + Title: "C++ unbounded C string API", + Description: "Warns when C++ code calls gets, strcpy, strcat, sprintf, or vsprintf, whose destination bounds are not part of the API.", + HowToFix: "Use std::string, std::format, snprintf, or another operation that carries and enforces destination bounds.", + }, "security.rust.insecure-tls": { ID: "security.rust.insecure-tls", Section: "Security", diff --git a/internal/codeguard/rules/catalog_security_owasp.go b/internal/codeguard/rules/catalog_security_owasp.go index 193c992..6c60044 100644 --- a/internal/codeguard/rules/catalog_security_owasp.go +++ b/internal/codeguard/rules/catalog_security_owasp.go @@ -19,6 +19,7 @@ var securityRuleOWASP = map[string]core.OWASPCategory{ "security.typescript.insecure-tls": core.OWASPA02CryptographicFailures, "security.javascript.insecure-tls": core.OWASPA02CryptographicFailures, "security.python.insecure-tls": core.OWASPA02CryptographicFailures, + "security.cpp.insecure-tls": core.OWASPA02CryptographicFailures, "security.rust.insecure-tls": core.OWASPA02CryptographicFailures, "security.java.insecure-tls": core.OWASPA02CryptographicFailures, "security.csharp.insecure-tls": core.OWASPA02CryptographicFailures, @@ -29,6 +30,8 @@ var securityRuleOWASP = map[string]core.OWASPCategory{ "security.typescript.shell-execution": core.OWASPA03Injection, "security.javascript.shell-execution": core.OWASPA03Injection, "security.python.shell-execution": core.OWASPA03Injection, + "security.cpp.shell-execution": core.OWASPA03Injection, + "security.cpp.unsafe-c-api": core.OWASPA03Injection, "security.rust.shell-execution": core.OWASPA03Injection, "security.java.shell-execution": core.OWASPA03Injection, "security.csharp.shell-execution": core.OWASPA03Injection, diff --git a/internal/codeguard/rules/catalog_test_quality.go b/internal/codeguard/rules/catalog_test_quality.go index 44b5262..4064e1e 100644 --- a/internal/codeguard/rules/catalog_test_quality.go +++ b/internal/codeguard/rules/catalog_test_quality.go @@ -13,6 +13,7 @@ var testQualityCatalog = map[string]core.RuleMetadata{ core.RuleLanguagePython, core.RuleLanguageTypeScript, core.RuleLanguageJavaScript, + core.RuleLanguageCPP, ), Title: "Test without assertion", Description: "Warns when a test function contains no recognizable assertion. Custom assertion helper names can be registered via ci_rules.test_quality.assertion_helpers.", @@ -28,9 +29,10 @@ var testQualityCatalog = map[string]core.RuleMetadata{ core.RuleLanguagePython, core.RuleLanguageTypeScript, core.RuleLanguageJavaScript, + core.RuleLanguageCPP, ), Title: "Always-true test assertion", - Description: "Warns when every assertion in a test only compares constants (for example expect(true).toBe(true) or assert 1 == 1), so the test can never fail.", + Description: "Warns when every assertion in a test only checks constants (for example EXPECT_TRUE(true), expect(true).toBe(true), or assert 1 == 1), so the test can never fail.", HowToFix: "Replace constant assertions with assertions on values produced by the code under test.", }, "ci.conditional-assertion": { @@ -43,9 +45,10 @@ var testQualityCatalog = map[string]core.RuleMetadata{ core.RuleLanguagePython, core.RuleLanguageTypeScript, core.RuleLanguageJavaScript, + core.RuleLanguageCPP, ), Title: "Conditionally executed assertions", - Description: "Warns when every assertion in a test sits inside a conditional without an else branch, so the assertions may silently never run. Idiomatic Go failure checks (t.Error/t.Fatal inside if) are not flagged.", + Description: "Warns when every assertion in a test sits inside a conditional without an else branch, so the assertions may silently never run. Explicit failure calls such as Go t.Fatal and C++ ADD_FAILURE/FAIL are not flagged.", HowToFix: "Move assertions out of the conditional, or fail the test explicitly in the branch where the assertions are skipped.", }, } diff --git a/tests/checks/ci_additional_languages_test.go b/tests/checks/ci_additional_languages_test.go index 2728b49..d8a51e6 100644 --- a/tests/checks/ci_additional_languages_test.go +++ b/tests/checks/ci_additional_languages_test.go @@ -39,6 +39,7 @@ func ciAdditionalLanguageCases() []ciAdditionalLanguageCase { {name: "java", language: "java", failPath: "src/test/java/SampleTest.java", failAllowed: []string{"tests/**"}, passPath: "tests/java/SampleTest.java", passAllowed: []string{"tests/**"}}, {name: "csharp", language: "csharp", failPath: "src/WidgetTests.cs", failAllowed: []string{"tests/**"}, passPath: "tests/WidgetTests.cs", passAllowed: []string{"tests/**"}}, {name: "ruby", language: "ruby", failPath: "spec/sample_spec.rb", failAllowed: []string{"tests/**"}, passPath: "tests/sample_test.rb", passAllowed: []string{"tests/**"}}, + {name: "cpp", language: "c++", failPath: "src/sample_test.cpp", failAllowed: []string{"tests/**"}, passPath: "tests/sample_test.ixx", passAllowed: []string{"tests/**"}}, } } diff --git a/tests/checks/ci_test_quality_cpp_test.go b/tests/checks/ci_test_quality_cpp_test.go new file mode 100644 index 0000000..8989892 --- /dev/null +++ b/tests/checks/ci_test_quality_cpp_test.go @@ -0,0 +1,84 @@ +package checks_test + +import ( + "path/filepath" + "testing" +) + +func TestCPPGoogleTestQualityRules(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "tests", "widget_test.cpp"), `#include + +TEST(WidgetTest, NoAssertion) { + auto value = compute(); + (void)value; +} + +TEST_F(WidgetTest, AlwaysTrue) { + EXPECT_TRUE(true); +} + +TEST_P(WidgetTest, ConditionalAssertion) { + auto value = compute(); + if (value > 0) { + ASSERT_EQ(value, 5); + } +} + +TYPED_TEST(WidgetTypedTest, ProperAssertion) { + EXPECT_EQ(compute(), 5); +} + +TEST( + WidgetTest, + MultilineDeclaration +) { + EXPECT_NE(compute(), 0); +} + +TEST(WidgetTest, ExplicitFailureBranch) { + if (compute() != 5) { + ADD_FAILURE() << "unexpected value"; + } +} +`) + + report := runScan(t, testQualityConfig(t, dir, "c++")) + + assertRuleCount(t, report, "ci.test-without-assertion", 1) + assertRuleCount(t, report, "ci.always-true-test-assertion", 1) + assertRuleCount(t, report, "ci.conditional-assertion", 1) +} + +func TestCPPCatchDoctestAndBoostAssertions(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "tests", "frameworks_test.cpp"), `#include +#include + +TEST_CASE("Catch2 assertion", "[widget]") { + CHECK(compute() == 5); +} + +TEST_CASE_FIXTURE(WidgetFixture, "doctest fixture assertion") { + REQUIRE_FALSE(compute() == 0); +} + +TEMPLATE_TEST_CASE("Catch2 template assertion", "[widget]", int, long) { + CHECK_THAT(compute(), MatchesWidget()); +} + +BOOST_AUTO_TEST_CASE(boost_assertion) { + BOOST_CHECK_EQUAL(compute(), 5); +} + +BOOST_FIXTURE_TEST_CASE(boost_fixture_assertion, WidgetFixture) { + BOOST_REQUIRE(compute() == 5); +} +`) + + report := runScan(t, testQualityConfig(t, dir, "cpp")) + + assertRuleCount(t, report, "ci.test-without-assertion", 0) + assertRuleCount(t, report, "ci.always-true-test-assertion", 0) + assertRuleCount(t, report, "ci.conditional-assertion", 0) +} diff --git a/tests/checks/contracts_test.go b/tests/checks/contracts_test.go index 77fa5da..b32bcbf 100644 --- a/tests/checks/contracts_test.go +++ b/tests/checks/contracts_test.go @@ -65,6 +65,58 @@ func TestContractsGoExportedBreakingOnDeletedFile(t *testing.T) { assertMessageContaining(t, messages, "func Gone was removed") } +func TestContractsCPPPublicHeaderBreaking(t *testing.T) { + dir := initContractsRepo(t) + writeFile(t, filepath.Join(dir, "include", "demo", "client.hpp"), strings.Join([]string{ + "#pragma once", + "namespace demo {", + "class Legacy {};", + "class Client {};", + "Client Fetch(const char* url, int timeout = 0);", + "void Keep(int value);", + "}", + "", + }, "\n")) + commitAll(t, dir, "base") + + writeFile(t, filepath.Join(dir, "include", "demo", "client.hpp"), strings.Join([]string{ + "#pragma once", + "namespace demo {", + "class Client {};", + "Client Fetch(const char* url);", + "void Keep(int renamed);", + "}", + "", + }, "\n")) + + cfg := contractsTestConfig(dir) + cfg.Targets[0].Language = "cpp" + report := runContractsDiff(t, cfg) + assertSectionStatus(t, report, "API Contracts", "fail") + messages := contractsRuleMessages(report, "contracts.cpp-public-breaking") + assertMessageContaining(t, messages, "type Legacy was removed") + assertMessageContaining(t, messages, "function Fetch changed signature") + for _, message := range messages { + if strings.Contains(message, "function Keep") { + t.Fatalf("parameter rename must not be treated as a breaking change: %v", messages) + } + } +} + +func TestContractsCPPIgnoresPrivateSourceHeaders(t *testing.T) { + dir := initContractsRepo(t) + writeFile(t, filepath.Join(dir, "src", "detail.hpp"), "class Internal {};\n") + commitAll(t, dir, "base") + writeFile(t, filepath.Join(dir, "src", "detail.hpp"), "class Replacement {};\n") + + cfg := contractsTestConfig(dir) + cfg.Targets[0].Language = "cpp" + report := runContractsDiff(t, cfg) + if messages := contractsRuleMessages(report, "contracts.cpp-public-breaking"); len(messages) != 0 { + t.Fatalf("private source header produced contract findings: %v", messages) + } +} + func TestContractsOpenAPIBreaking(t *testing.T) { dir := initContractsRepo(t) writeFile(t, filepath.Join(dir, "openapi.yaml"), strings.Join([]string{ diff --git a/tests/checks/design_cpp_graph_test.go b/tests/checks/design_cpp_graph_test.go new file mode 100644 index 0000000..1776a70 --- /dev/null +++ b/tests/checks/design_cpp_graph_test.go @@ -0,0 +1,98 @@ +package checks_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func TestDesignCheckFailsForCPPIncludeCycle(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "include", "alpha.h"), "#pragma once\n#include \"beta.h\"\nstruct Alpha {};\n") + writeFile(t, filepath.Join(dir, "include", "beta.h"), "#pragma once\n#include \"alpha.h\"\nstruct Beta {};\n") + + report, err := codeguard.Run(context.Background(), graphTestConfig("design-cpp-cycle", dir, "cpp")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRulePresent(t, report, "Design Patterns", "design.cpp.import-cycle") +} + +func TestDesignCheckFailsForCPPNamedModuleCycle(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "alpha.cppm"), "export module alpha;\nimport beta;\nexport int alpha_value();\n") + writeFile(t, filepath.Join(dir, "src", "beta.cppm"), "export module beta;\nimport alpha;\nexport int beta_value();\n") + + report, err := codeguard.Run(context.Background(), graphTestConfig("design-cpp-module-cycle", dir, "cpp")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRulePresent(t, report, "Design Patterns", "design.cpp.import-cycle") +} + +func TestDesignCheckUsesCPPGraphForGodModules(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "include", "common.hpp"), "#pragma once\n") + writeFile(t, filepath.Join(dir, "src", "alpha.cpp"), "#include \"../include/common.hpp\"\n") + writeFile(t, filepath.Join(dir, "src", "beta.cpp"), "#include \"../include/common.hpp\"\n") + writeFile(t, filepath.Join(dir, "src", "gamma.cpp"), "#include \"../include/common.hpp\"\n") + + cfg := graphTestConfig("design-cpp-god-module", dir, "c++") + cfg.Checks.DesignRules.GodModuleThreshold = 2 + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRulePresent(t, report, "Design Patterns", "design.god-module") +} + +func TestDesignCheckWarnsForCPPGenericNameAndQualifiedMethodCount(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "utils.cpp"), "struct Worker { void one(); void two(); void three(); };\nvoid Worker::one() {}\nvoid Worker::two() {}\nvoid Worker::three() {}\n") + + cfg := graphTestConfig("design-cpp-heuristics", dir, "cpp") + cfg.Checks.DesignRules.MaxMethodsPerType = 2 + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRulePresent(t, report, "Design Patterns", "design.cpp.generic-module-name") + assertFindingRulePresent(t, report, "Design Patterns", "design.cpp.max-methods-per-type") +} + +func TestDiffModeUsesCPPGraphForHighImpactChanges(t *testing.T) { + dir := t.TempDir() + runGit(t, dir, "init", "-b", "main") + runGit(t, dir, "config", "user.email", "test@example.com") + runGit(t, dir, "config", "user.name", "CodeGuard Test") + writeFile(t, filepath.Join(dir, "include", "base.hpp"), "#pragma once\nconstexpr int value = 1;\n") + writeFile(t, filepath.Join(dir, "include", "mid.hpp"), "#pragma once\n#include \"base.hpp\"\n") + writeFile(t, filepath.Join(dir, "src", "top.cpp"), "#include \"../include/mid.hpp\"\n") + runGit(t, dir, "add", ".") + runGit(t, dir, "commit", "-m", "base") + writeFile(t, filepath.Join(dir, "include", "base.hpp"), "#pragma once\nconstexpr int value = 2;\n") + + cfg := graphTestConfig("design-cpp-change-impact", dir, "cpp") + cfg.Checks.DesignRules.HighImpactChangeThreshold = 1 + report, err := codeguard.RunWithOptions(context.Background(), cfg, codeguard.ScanOptions{ + Mode: codeguard.ScanModeDiff, BaseRef: "main", + }) + if err != nil { + t.Fatalf("run diff: %v", err) + } + + assertFindingRulePresent(t, report, "Design Patterns", "design.high-impact-change") + artifact := changeImpactArtifact(t, report) + for _, entry := range artifact.Entries { + if entry.File == "include/base.hpp" && entry.Language == "cpp" && entry.TransitiveDependents == 2 { + return + } + } + t.Fatalf("artifact missing C++ impact entry: %+v", artifact.Entries) +} diff --git a/tests/checks/performance_cpp_rebuild_test.go b/tests/checks/performance_cpp_rebuild_test.go new file mode 100644 index 0000000..9e6f4fb --- /dev/null +++ b/tests/checks/performance_cpp_rebuild_test.go @@ -0,0 +1,59 @@ +package checks_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func writeCPPRebuildCascadeFixture(t *testing.T, dir string) { + t.Helper() + writeFile(t, filepath.Join(dir, "include", "common.hpp"), "#pragma once\n") + writeFile(t, filepath.Join(dir, "include", "mid.hpp"), "#pragma once\n#include \"common.hpp\"\n") + writeFile(t, filepath.Join(dir, "src", "alpha.cpp"), "#include \"../include/common.hpp\"\n") + writeFile(t, filepath.Join(dir, "src", "beta.cpp"), "#include \"../include/common.hpp\"\n") + writeFile(t, filepath.Join(dir, "src", "gamma.cpp"), "#include \"../include/mid.hpp\"\n") +} + +func TestPerformanceCheckWarnsForCPPRebuildHotHeaderAndAmplifier(t *testing.T) { + dir := t.TempDir() + writeCPPRebuildCascadeFixture(t, dir) + cfg := performanceConfig("performance-cpp-rebuild", dir, "cpp") + cfg.Checks.PerformanceRules.HotPackageImporterThreshold = 2 + cfg.Checks.PerformanceRules.RebuildAmplifierThreshold = 3 + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRulePresent(t, report, "Performance", "performance.cpp.hot-header") + assertFindingRulePresent(t, report, "Performance", "performance.cpp.rebuild-amplifier") +} + +func TestPerformanceCheckWarnsForCPPUnboundedThreadLaunchInLoop(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "workers.cpp"), "#include \n#include \nvoid run_all(const std::vector& jobs) {\n std::vector threads;\n for (int job : jobs) {\n threads.emplace_back(run, job);\n }\n}\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-cpp-threads", dir, "cpp")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRulePresent(t, report, "Performance", "performance.cpp.unbounded-concurrency") +} + +func TestPerformanceCheckSkipsCPPRebuildCascadeBelowThreshold(t *testing.T) { + dir := t.TempDir() + writeCPPRebuildCascadeFixture(t, dir) + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-cpp-rebuild-neg", dir, "cpp")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRuleAbsent(t, report, "Performance", "performance.cpp.hot-header") + assertFindingRuleAbsent(t, report, "Performance", "performance.cpp.rebuild-amplifier") +} diff --git a/tests/checks/security_additional_languages_test.go b/tests/checks/security_additional_languages_test.go index 0cb398d..371cd16 100644 --- a/tests/checks/security_additional_languages_test.go +++ b/tests/checks/security_additional_languages_test.go @@ -19,6 +19,14 @@ func TestSecurityCheckFindsAdditionalLanguagePatterns(t *testing.T) { status string ruleIDs []string }{ + { + name: "cpp", + language: "cpp", + path: "src/sample.cpp", + source: "void run(CURL* curl, char* dst, const char* src) {\n curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);\n std::system(\"echo ok\");\n strcpy(dst, src);\n // system(\"ignored\");\n const char* ignored = R\"(SSL_CTX_set_verify(ctx, SSL_VERIFY_NONE, nullptr))\";\n}\n", + status: "fail", + ruleIDs: []string{"security.cpp.insecure-tls", "security.cpp.shell-execution", "security.cpp.unsafe-c-api"}, + }, { name: "rust", language: "rust", diff --git a/tests/checks/supplychain_test.go b/tests/checks/supplychain_test.go index d2cfb77..7127f9b 100644 --- a/tests/checks/supplychain_test.go +++ b/tests/checks/supplychain_test.go @@ -162,3 +162,75 @@ func TestSupplyChainWarnsForNonHermeticCargoSources(t *testing.T) { t.Fatalf("expected 2 non-hermetic Cargo findings, got %d: %v", len(messages), messages) } } + +func TestSupplyChainParsesVCPKGBaselineAndOverrides(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "vcpkg.json"), `{ + "name": "native-app", + "builtin-baseline": "0123456789abcdef0123456789abcdef01234567", + "dependencies": ["fmt", {"name": "cmake", "host": true}], + "overrides": [{"name": "fmt", "version": "10.2.1"}] +}`) + + report, err := codeguard.Run(context.Background(), supplyChainTestConfig(dir, "vcpkg-pinned")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertSectionStatus(t, report, "Supply Chain", "pass") +} + +func TestSupplyChainWarnsForVCPKGWithoutBaseline(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "vcpkg.json"), `{ + "name": "native-app", + "dependencies": ["fmt", {"name": "openssl", "version>=": "3.0.0"}] +}`) + + report, err := codeguard.Run(context.Background(), supplyChainTestConfig(dir, "vcpkg-unpinned")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertSectionStatus(t, report, "Supply Chain", "warn") + messages := supplyChainRuleMessages(report, "supply_chain.unpinned-dependency") + if len(messages) != 2 { + t.Fatalf("unpinned vcpkg findings = %d, want 2: %v", len(messages), messages) + } +} + +func TestSupplyChainConanRequiresLockfileAndDetectsRanges(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "conanfile.txt"), "[requires]\nfmt/10.2.1\nopenssl/[>=3.0 <4]\n\n[tool_requires]\ncmake/3.29.0\n") + + report, err := codeguard.Run(context.Background(), supplyChainTestConfig(dir, "conan-policy")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertSectionStatus(t, report, "Supply Chain", "fail") + assertFindingRulePresent(t, report, "Supply Chain", "supply_chain.missing-lockfile") + messages := supplyChainRuleMessages(report, "supply_chain.unpinned-dependency") + if len(messages) != 1 || !strings.Contains(messages[0], "openssl") { + t.Fatalf("unexpected Conan unpinned findings: %v", messages) + } +} + +func TestSupplyChainDetectsConanLockfileDrift(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "conanfile.txt"), "[requires]\nfmt/10.2.1\nopenssl/3.2.1\n") + writeFile(t, filepath.Join(dir, "conan.lock"), `{ + "version": "0.5", + "requires": [ + "fmt/10.2.1#recipe-revision%1700000000", + "openssl/3.1.4#recipe-revision%1700000000" + ] +}`) + + report, err := codeguard.Run(context.Background(), supplyChainTestConfig(dir, "conan-lock-drift")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertSectionStatus(t, report, "Supply Chain", "fail") + messages := supplyChainRuleMessages(report, "supply_chain.lockfile-drift") + if len(messages) != 1 || !strings.Contains(messages[0], "openssl") { + t.Fatalf("unexpected Conan lockfile drift findings: %v", messages) + } +} diff --git a/tests/support/clike_parser_test.go b/tests/support/clike_parser_test.go index c87ec8f..b220a1a 100644 --- a/tests/support/clike_parser_test.go +++ b/tests/support/clike_parser_test.go @@ -194,6 +194,7 @@ const trickyCPP = `#include #include "widget.hpp" // int commented(int x) { return x; } +// #include "commented.hpp" std::string Demo::render(const std::vector& rows, int count) { const char* raw = R"(int fake_inner() { return 1; })"; std::string out = ""; @@ -225,6 +226,9 @@ func TestParseCPPStructure(t *testing.T) { if !hasImport(file.Imports, "regex", "regex") || !hasImport(file.Imports, "widget.hpp", "widget.hpp") { t.Fatalf("cpp includes missing: %+v", file.Imports) } + if hasImport(file.Imports, "commented.hpp", "commented.hpp") { + t.Fatalf("commented include must not parse: %+v", file.Imports) + } } func functionNames(file *support.ParsedFile) []string { diff --git a/tests/support/scan_language_helpers_test.go b/tests/support/scan_language_helpers_test.go new file mode 100644 index 0000000..9f2a7ef --- /dev/null +++ b/tests/support/scan_language_helpers_test.go @@ -0,0 +1,59 @@ +package support_test + +import ( + "slices" + "testing" + + checksupport "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func TestScanCPPFilesIncludesExplicitTargetHeadersAndModules(t *testing.T) { + candidates := []string{ + "include/widget.h", + "include/widget.inc", + "include/widget.inl", + "include/widget.tpp", + "src/widget.cpp", + "src/widget.ixx", + "src/widget.cppm", + "src/widget.cxxm", + "src/widget.ccm", + "src/widget.c++m", + "src/widget.mpp", + "src/widget.mxx", + "src/widget.ii", + "src/widget.C", + "src/widget.c", + "README.md", + } + included := make([]string, 0) + env := checksupport.Context{ + ScanTargetFiles: func(_ core.TargetConfig, _ string, include func(string) bool, _ func(string, []byte) []core.Finding) []core.Finding { + for _, candidate := range candidates { + if include(candidate) { + included = append(included, candidate) + } + } + return nil + }, + } + + checksupport.ScanCPPFiles(env, core.TargetConfig{Language: "c++"}, "quality", func(string, []byte) []core.Finding { return nil }) + + want := candidates[:len(candidates)-2] + if !slices.Equal(included, want) { + t.Fatalf("included C++ paths = %v, want %v", included, want) + } +} + +func TestCPPPathRequiresExplicitTargetForAmbiguousHeaders(t *testing.T) { + for _, path := range []string{"widget.h", "widget.inc"} { + if checksupport.IsCPPPath(path, false) { + t.Errorf("IsCPPPath(%q, false) = true, want false", path) + } + if !checksupport.IsCPPPath(path, true) { + t.Errorf("IsCPPPath(%q, true) = false, want true", path) + } + } +} diff --git a/tests/support/treeprovider_test.go b/tests/support/treeprovider_test.go index da7675e..031c301 100644 --- a/tests/support/treeprovider_test.go +++ b/tests/support/treeprovider_test.go @@ -19,6 +19,11 @@ func TestScriptLanguageForPath(t *testing.T) { "src/app.cjs": checksupport.ScriptLangJavaScript, "src/main.cpp": checksupport.ScriptLangCPP, "include/app.hpp": checksupport.ScriptLangCPP, + "src/widget.ixx": checksupport.ScriptLangCPP, + "src/widget.cppm": checksupport.ScriptLangCPP, + "src/widget.c++m": checksupport.ScriptLangCPP, + "include/app.inl": checksupport.ScriptLangCPP, + "include/app.h": "", "src/main.go": "", "src/app.ts.bak": "", } From b93d6819a936b11b18fdca5493bb181ef2e6edc1 Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Thu, 16 Jul 2026 19:46:22 -0400 Subject: [PATCH 2/2] feat(cpp): add tooling and security analysis --- docs/checks.md | 44 ++++- docs/features.md | 4 +- docs/security.md | 6 +- examples/codeguard.json | 6 + internal/codeguard/checks/quality/quality.go | 1 + .../checks/quality/quality_cpp_tooling.go | 80 ++++++++ .../checks/security/security_taint.go | 4 +- .../checks/security/security_taint_cpp.go | 137 +++++++++++++ .../security/security_taint_cpp_expr.go | 114 +++++++++++ .../security/security_taint_cpp_sinks.go | 111 +++++++++++ .../security/security_taint_cpp_ssrf.go | 40 ++++ .../security/security_taint_cpp_types.go | 41 ++++ .../security/security_taint_python_expr.go | 13 +- .../security/security_taint_sanitizers.go | 21 ++ internal/codeguard/checks/support/context.go | 13 ++ .../checks/support/cpp_dependency_graph.go | 51 ++++- .../checks/support/parser_clike_scope.go | 16 +- .../codeguard/checks/support/supply_chain.go | 52 ++--- .../checks/support/supply_chain_artifacts.go | 18 +- .../checks/support/supply_chain_cmake.go | 127 ++++++++++++ .../supply_chain_cmake_dependencies.go | 135 +++++++++++++ .../support/supply_chain_cmake_lexer.go | 97 +++++++++ .../support/supply_chain_cmake_scanner.go | 111 +++++++++++ .../support/supply_chain_cmake_versions.go | 82 ++++++++ .../support/supply_chain_conan_python.go | 127 ++++++++++++ .../supply_chain_conan_python_expression.go | 143 ++++++++++++++ .../supply_chain_conan_python_scanner.go | 94 +++++++++ .../supply_chain_conan_python_strings.go | 94 +++++++++ internal/codeguard/config/defaults_rules.go | 37 +++- internal/codeguard/config/example_rules.go | 6 + internal/codeguard/config/validate.go | 20 ++ internal/codeguard/core/config_cpp_tooling.go | 20 ++ internal/codeguard/core/config_rule_types.go | 2 + .../codeguard/core/report_artifact_types.go | 3 + internal/codeguard/cpp/compdb/arguments.go | 79 ++++++++ internal/codeguard/cpp/compdb/compdb.go | 33 ++++ internal/codeguard/cpp/compdb/discovery.go | 67 +++++++ internal/codeguard/cpp/compdb/entry.go | 75 +++++++ internal/codeguard/cpp/compdb/load.go | 71 +++++++ internal/codeguard/cpp/compdb/metadata.go | 94 +++++++++ internal/codeguard/cpp/compdb/paths.go | 43 ++++ .../rules/catalog_fix_templates_quality.go | 2 + .../rules/catalog_fix_templates_security.go | 2 + internal/codeguard/rules/catalog_quality.go | 20 ++ .../codeguard/rules/catalog_security_owasp.go | 2 + .../codeguard/rules/catalog_security_taint.go | 20 ++ internal/codeguard/runner/checks/checks.go | 7 +- .../codeguard/runner/checks/cpp_tooling.go | 31 +++ .../runner/checks/govulncheck_callback.go | 15 ++ .../codeguard/runner/cpptooling/cpptooling.go | 156 +++++++++++++++ pkg/codeguard/sdk_types_config_checks.go | 8 + tests/checks/cpp_tooling_test.go | 84 ++++++++ tests/checks/security_taint_cpp_test.go | 158 +++++++++++++++ .../checks/supplychain_cpp_manifests_test.go | 185 ++++++++++++++++++ tests/support/compdb_test.go | 96 +++++++++ tests/support/cpp_dependency_graph_test.go | 52 +++++ tests/support/cpptooling_runner_test.go | 119 +++++++++++ 57 files changed, 3220 insertions(+), 69 deletions(-) create mode 100644 internal/codeguard/checks/quality/quality_cpp_tooling.go create mode 100644 internal/codeguard/checks/security/security_taint_cpp.go create mode 100644 internal/codeguard/checks/security/security_taint_cpp_expr.go create mode 100644 internal/codeguard/checks/security/security_taint_cpp_sinks.go create mode 100644 internal/codeguard/checks/security/security_taint_cpp_ssrf.go create mode 100644 internal/codeguard/checks/security/security_taint_cpp_types.go create mode 100644 internal/codeguard/checks/security/security_taint_sanitizers.go create mode 100644 internal/codeguard/checks/support/supply_chain_cmake.go create mode 100644 internal/codeguard/checks/support/supply_chain_cmake_dependencies.go create mode 100644 internal/codeguard/checks/support/supply_chain_cmake_lexer.go create mode 100644 internal/codeguard/checks/support/supply_chain_cmake_scanner.go create mode 100644 internal/codeguard/checks/support/supply_chain_cmake_versions.go create mode 100644 internal/codeguard/checks/support/supply_chain_conan_python.go create mode 100644 internal/codeguard/checks/support/supply_chain_conan_python_expression.go create mode 100644 internal/codeguard/checks/support/supply_chain_conan_python_scanner.go create mode 100644 internal/codeguard/checks/support/supply_chain_conan_python_strings.go create mode 100644 internal/codeguard/core/config_cpp_tooling.go create mode 100644 internal/codeguard/cpp/compdb/arguments.go create mode 100644 internal/codeguard/cpp/compdb/compdb.go create mode 100644 internal/codeguard/cpp/compdb/discovery.go create mode 100644 internal/codeguard/cpp/compdb/entry.go create mode 100644 internal/codeguard/cpp/compdb/load.go create mode 100644 internal/codeguard/cpp/compdb/metadata.go create mode 100644 internal/codeguard/cpp/compdb/paths.go create mode 100644 internal/codeguard/runner/checks/cpp_tooling.go create mode 100644 internal/codeguard/runner/checks/govulncheck_callback.go create mode 100644 internal/codeguard/runner/cpptooling/cpptooling.go create mode 100644 tests/checks/cpp_tooling_test.go create mode 100644 tests/checks/security_taint_cpp_test.go create mode 100644 tests/checks/supplychain_cpp_manifests_test.go create mode 100644 tests/support/compdb_test.go create mode 100644 tests/support/cpp_dependency_graph_test.go create mode 100644 tests/support/cpptooling_runner_test.go diff --git a/docs/checks.md b/docs/checks.md index 4dfb487..fdc8348 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -230,9 +230,9 @@ Current inference behavior: | Family | Go | C++ | Python | TypeScript | Rust | Java | C# | Ruby | | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| Quality | `gofmt`, parseability, maintainability thresholds | maintainability thresholds across sources, headers, templates, and modules | maintainability thresholds | maintainability thresholds, `@ts-ignore`, `@ts-nocheck`, `@ts-expect-error`, `explicit any`, double assertions, non-null assertions, `debugger` statements | maintainability thresholds | maintainability thresholds | maintainability thresholds | maintainability thresholds | +| Quality | `gofmt`, parseability, maintainability thresholds | maintainability thresholds across sources, headers, templates, and modules; optional `clang-format` and sanitized `clang++` validation | maintainability thresholds | maintainability thresholds, `@ts-ignore`, `@ts-nocheck`, `@ts-expect-error`, `explicit any`, double assertions, non-null assertions, `debugger` statements | maintainability thresholds | maintainability thresholds | maintainability thresholds | maintainability thresholds | | Design | boundary rules, generic package names, type/interface/file-size heuristics | include/module cycles, graph impact, generic filenames, qualified method counts | public-imports-private, public-imports-cli, generic module names | generic module names, max methods per class, max members per interface/object type | module cycles and graph impact | import cycles and graph impact | - | - | -| Security | insecure TLS, shell execution review, optional `govulncheck` | insecure TLS, shell execution review, unsafe C string APIs | insecure TLS, shell execution review, dynamic code | insecure TLS, shell execution review, dynamic code, string timer execution, wildcard `postMessage`, Node `vm` execution, unsafe HTML sinks | insecure TLS, shell execution review | insecure TLS, shell execution review | insecure TLS, shell execution review | insecure TLS, shell execution review, dynamic code | +| Security | insecure TLS, shell execution review, optional `govulncheck` | insecure TLS, shell execution review, unsafe C string APIs, taint flow, SSRF | insecure TLS, shell execution review, dynamic code | insecure TLS, shell execution review, dynamic code, string timer execution, wildcard `postMessage`, Node `vm` execution, unsafe HTML sinks | insecure TLS, shell execution review | insecure TLS, shell execution review | insecure TLS, shell execution review | insecure TLS, shell execution review, dynamic code | | Commands | language command mappings via config | language command mappings via config | language command mappings via config | language command mappings via config | language command mappings via config | language command mappings via config | language command mappings via config | language command mappings via config | TypeScript semantic runtime: @@ -295,6 +295,7 @@ Current behavior: - semantic review request payloads also include a structured `prompt` template with per-rule focus and framework-specific reasoning guidance, so command-backed runtimes do not have to invent their own contract-drift or test-adequacy instructions from scratch - TypeScript and JavaScript quality built-ins use AST-derived function metrics and compiler-parsed syntax when the semantic runtime is available - includes native maintainability heuristics for Python, TypeScript, JavaScript, Rust, C++, Java, C#, and Ruby targets +- explicit C++ targets can opt into `clang-format` and sanitized `clang++ -fsyntax-only` validation through `quality_rules.cpp_tooling` - TypeScript and JavaScript targets also warn on `@ts-ignore`, `@ts-nocheck`, `@ts-expect-error`, explicit `any`, double assertions, non-null assertions, and committed `debugger` statements - can run language-specific quality commands based on `targets[].language` @@ -359,6 +360,35 @@ Language command example: } ``` +### C++ Clang tooling + +Explicit C++ targets can enable built-in formatter and compiler validation without placing executable command lines in `compile_commands.json`: + +```json +{ + "checks": { + "quality": true, + "quality_rules": { + "cpp_tooling": { + "clang_format_mode": "auto", + "compiler_mode": "auto", + "compile_commands": "build/compile_commands.json" + } + } + } +} +``` + +Both modes accept `off`, `auto`, or `required` and default to `off`: + +- `off` never invokes the tool. +- `auto` runs when the binary and, for compiler validation, a compilation database are available; absence is skipped. +- `required` reports an actionable failure when the tool or database is unavailable. + +`compile_commands.json` is searched at the target root, `build/`, `cmake-build-debug/`, and `cmake-build-release/` unless a target-relative path is configured. The database is treated as untrusted metadata: CodeGuard never runs its compiler, wrapper, response files, plugins, or arbitrary flags. It rebuilds a fixed `clang++ -fsyntax-only` invocation from the target-local source plus an allowlist of `-std`, target-contained include directories, and `-D`/`-U` metadata. Configured command overrides still require the normal config-command trust opt-in. + +Target-contained include roots from the database also improve C++ include-graph resolution. Ambiguous or external include paths are ignored. + ### Coverage delta (diff mode) `quality.coverage-delta` gates the test coverage of changed lines during `scan -diff`. It is **opt-in and disabled by default** because it runs the target's test suite as part of the scan, which can be expensive. It only activates in diff mode. @@ -721,8 +751,10 @@ Notes on Cargo precision: Notes on C++ dependency precision: - `vcpkg.json` dependencies are treated as pinned when the manifest has a valid 40-character `builtin-baseline` or an exact override for the dependency. - `conanfile.txt` parses `[requires]` and `[tool_requires]`; exact references are pinned, version ranges are not, and `conan.lock` is required when lockfiles are enforced. +- `conanfile.py` is statically analyzed for literal/list/tuple constants, class-level `requires`/`tool_requires`, and `self.requires()`/`self.tool_requires()` calls. The recipe is never imported or executed. +- `CMakeLists.txt` and dependency-bearing `*.cmake` files are statically analyzed for `find_package`, `FetchContent_Declare`, `ExternalProject_Add`, `CPMAddPackage`, and same-file `set()` indirection. Exact revisions, hashes, and versioned URLs count as pinned. - Conan lockfile drift checks support Conan 2 top-level requirement arrays and Conan 1 `graph_lock.nodes[*].ref` entries. -- Executable `conanfile.py` and dynamic CMake dependency declarations are intentionally not evaluated. +- Dynamic CMake/Python expressions, custom wrappers, cross-file variable propagation, and imported helpers are not evaluated. Unresolved declarations are exposed in the supply-chain artifact as `analysis_limitations`; repository code is never executed to resolve them. ## API Contracts @@ -932,7 +964,7 @@ Current behavior: - Python targets include insecure TLS review, shell execution review, and dynamic code review markers - TypeScript and JavaScript targets include insecure TLS review, shell execution review, dynamic code review markers, string timer execution review, wildcard `postMessage` review, Node `vm` execution review, and unsafe HTML sink review - Rust, Java, and C# targets include insecure TLS review and shell execution review markers -- C++ targets include insecure TLS review for common libcurl/OpenSSL/Boost.Asio/cpprestsdk settings, shell execution review, and unsafe unbounded C string API warnings +- C++ targets include insecure TLS review for common libcurl/OpenSSL/Boost.Asio/cpprestsdk settings, shell execution review, unsafe unbounded C string API warnings, and bounded same-file taint/SSRF analysis - Ruby targets include insecure TLS review, shell execution review, and dynamic code review markers Config keys: @@ -943,7 +975,8 @@ Config keys: "security": true, "security_rules": { "govulncheck_mode": "auto", - "govulncheck_command": "govulncheck" + "govulncheck_command": "govulncheck", + "taint_cpp": true } } } @@ -960,6 +993,7 @@ Current behavior: - can surface per-vulnerability findings from `govulncheck` - includes native Python, TypeScript, C++, Rust, Java, C#, and Ruby security heuristics for shell execution and insecure TLS settings - includes dynamic code review heuristics for Python, TypeScript, and Ruby +- C++ taint analysis follows obvious environment, process-argument, standard-input, and recognized request values through local assignments and same-file function summaries into process or outbound-network sinks; it intentionally does not claim pointer/field aliasing, macro expansion, or a cross-file call graph - TypeScript and JavaScript security built-ins resolve imports, aliases, and call sites through compiler-parsed AST analysis when the semantic runtime is available - can run language-specific security commands based on `targets[].language` - only runs `govulncheck` for Go targets diff --git a/docs/features.md b/docs/features.md index 030eb38..6d817bf 100644 --- a/docs/features.md +++ b/docs/features.md @@ -10,6 +10,7 @@ This page lists the current `codeguard` feature surface and the main config entr - language-native quality heuristics for Go, Python, TypeScript, JavaScript, Rust, Java, C++, C#, and Ruby - AI-quality heuristics such as swallowed errors, narrative comments, hallucinated imports, dead code, over-mocked tests, idiom drift, semantic review, provenance policy, and change-risk rollups - changed-line coverage gating in diff mode + - opt-in `clang-format` and sanitized `clang++ -fsyntax-only` validation backed by safe `compile_commands.json` metadata - `design` - layering and boundary rules - import cycle and god-module detection @@ -20,6 +21,7 @@ This page lists the current `codeguard` feature surface and the main config entr - Go, Python, TypeScript, and JavaScript taint-style flow checks - insecure API heuristics - C++ insecure TLS, shell execution, and unsafe C string API checks + - C++ same-file taint-flow and SSRF analysis for common process and networking APIs - optional `govulncheck` - `prompts` - prompt-asset governance @@ -29,7 +31,7 @@ This page lists the current `codeguard` feature surface and the main config entr - workflow/release policy - test-quality heuristics, including GoogleTest, Catch2/doctest, and Boost.Test - `supply_chain` - - manifest normalization, including `vcpkg.json` and declarative `conanfile.txt` + - manifest normalization, including `vcpkg.json`, `conanfile.txt`, statically analyzed `conanfile.py`, and CMake dependency declarations - lockfile presence and drift validation - unpinned dependency detection - dependency and manifest license policy diff --git a/docs/security.md b/docs/security.md index 4ac0380..64729fb 100644 --- a/docs/security.md +++ b/docs/security.md @@ -67,14 +67,14 @@ OWASP Top 10 (2021) coverage: 9/10 categories have rules [ok ] A01:2021-Broken Access Control (2 rules) [ok ] A02:2021-Cryptographic Failures (12 rules) -[ok ] A03:2021-Injection (26 rules) +[ok ] A03:2021-Injection (27 rules) [gap ] A04:2021-Insecure Design (0 rules) [ok ] A05:2021-Security Misconfiguration (5 rules) [ok ] A06:2021-Vulnerable and Outdated Components (1 rules) [ok ] A07:2021-Identification and Authentication Failures (3 rules) [ok ] A08:2021-Software and Data Integrity Failures (1 rules) [ok ] A09:2021-Security Logging and Monitoring Failures (2 rules) -[ok ] A10:2021-Server-Side Request Forgery (SSRF) (2 rules) +[ok ] A10:2021-Server-Side Request Forgery (SSRF) (3 rules) ``` `A04` (Insecure Design) is left as an explicit gap: it is a design-level risk @@ -103,6 +103,8 @@ taint engine and default to `fail`. | `security.log-secret-exposure` | A09 | secret-named identifiers (password, token, api_key, …) inside the argument list of a Go/Python/TS/JS logging call, secret-named structured-log keys, and secret-labeled string literals concatenated or format-directed into log output | | `security.unsanitized-error-response` | A09 | raw error values written directly into HTTP responses: Go `http.Error(w, err.Error(), …)` / `fmt.Fprintf(w, …, err)`, TS/JS `res.send(err)` / `res.json(err)` / `res.status(…).send(err.stack \|\| err.message)`, Python `return str(e)` / `HttpResponse(str(e))` inside `except` blocks | | `security.ssrf.go` / `security.ssrf.python` | A10 | untrusted input flowing into an outbound HTTP request URL | +| `security.taint.cpp` | A03 | environment, argv/stdin, or recognized request input flowing into process execution through bounded same-file summaries | +| `security.ssrf.cpp` | A10 | untrusted input flowing into libcurl, cpr, Boost resolver, cpprestsdk, or Poco outbound destinations | ## Release integrity (supply chain) diff --git a/examples/codeguard.json b/examples/codeguard.json index 7299195..a5a9557 100644 --- a/examples/codeguard.json +++ b/examples/codeguard.json @@ -23,6 +23,12 @@ "max_function_lines": 80, "max_parameters": 5, "max_cyclomatic_complexity": 10, + "cpp_tooling": { + "clang_format_mode": "off", + "clang_format_command": "clang-format", + "compiler_mode": "off", + "compiler_command": "clang++" + }, "language_commands": { "typescript": [ { diff --git a/internal/codeguard/checks/quality/quality.go b/internal/codeguard/checks/quality/quality.go index 3d0d2d1..a14d960 100644 --- a/internal/codeguard/checks/quality/quality.go +++ b/internal/codeguard/checks/quality/quality.go @@ -19,6 +19,7 @@ func runQualitySection(ctx context.Context, env support.Context) core.SectionRes func qualityTargetFindings(ctx context.Context, env support.Context, target core.TargetConfig) []core.Finding { findings := languageQualityFindings(ctx, env, target) + findings = append(findings, cppToolingFindings(ctx, env, target)...) findings = append(findings, cloneFindingsForTarget(env, target)...) findings = append(findings, aiTargetFindings(env, target)...) findings = append(findings, semanticFindings(ctx, env, target)...) diff --git a/internal/codeguard/checks/quality/quality_cpp_tooling.go b/internal/codeguard/checks/quality/quality_cpp_tooling.go new file mode 100644 index 0000000..718649a --- /dev/null +++ b/internal/codeguard/checks/quality/quality_cpp_tooling.go @@ -0,0 +1,80 @@ +package quality + +import ( + "context" + "fmt" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func cppToolingFindings(ctx context.Context, env support.Context, target core.TargetConfig) []core.Finding { + if !isExplicitCPPTarget(target.Language) { + return nil + } + cfg := env.Config.Checks.QualityRules.CPPTooling + if !modeEnabled(cfg.ClangFormatMode) && !modeEnabled(cfg.CompilerMode) { + return nil + } + files := make([]string, 0) + env.VisitTargetFiles(target, func(rel string) bool { return support.IsCPPPath(rel, true) }, func(rel string, _ []byte) { + files = append(files, rel) + }) + if len(files) == 0 { + return nil + } + findings := make([]core.Finding, 0) + if modeEnabled(cfg.ClangFormatMode) { + result := support.CPPToolResult{Unavailable: true, Err: fmt.Errorf("clang-format runner is unavailable")} + if env.RunCPPFormat != nil { + result = env.RunCPPFormat(ctx, target.Path, cfg, files) + } + findings = append(findings, cppToolFindings(env, "quality.cpp.clang-format", cfg.ClangFormatMode, result)...) + } + if modeEnabled(cfg.CompilerMode) { + result := support.CPPToolResult{Unavailable: true, Err: fmt.Errorf("c++ compiler runner is unavailable")} + if env.RunCPPSyntax != nil { + result = env.RunCPPSyntax(ctx, target.Path, cfg) + } + findings = append(findings, cppToolFindings(env, "quality.cpp.compiler-parse", cfg.CompilerMode, result)...) + } + return findings +} + +func cppToolFindings(env support.Context, ruleID, mode string, result support.CPPToolResult) []core.Finding { + findings := make([]core.Finding, 0, len(result.Issues)+1) + for _, issue := range result.Issues { + findings = append(findings, env.NewFinding(support.FindingInput{ + RuleID: ruleID, Level: "fail", Path: issue.Path, Line: 1, Column: 1, Message: issue.Message, + })) + } + if result.Err == nil || (result.Unavailable && normalizedToolMode(mode) == core.ExternalToolModeAuto) { + return findings + } + level := "warn" + if normalizedToolMode(mode) == core.ExternalToolModeRequired { + level = "fail" + } + return append(findings, env.NewFinding(support.FindingInput{ + RuleID: ruleID, Level: level, Message: result.Err.Error(), + })) +} + +func isExplicitCPPTarget(language string) bool { + switch support.NormalizedLanguage(language) { + case "c++", "cpp", "cxx", "cc": + return true + default: + return false + } +} + +func modeEnabled(mode string) bool { + mode = normalizedToolMode(mode) + return mode == core.ExternalToolModeAuto || mode == core.ExternalToolModeRequired +} + +func normalizedToolMode(mode string) string { + return strings.ToLower(strings.TrimSpace(mode)) +} diff --git a/internal/codeguard/checks/security/security_taint.go b/internal/codeguard/checks/security/security_taint.go index 5892a5d..0565346 100644 --- a/internal/codeguard/checks/security/security_taint.go +++ b/internal/codeguard/checks/security/security_taint.go @@ -18,6 +18,8 @@ func taintFindingsForFile(env support.Context, file string, source string) []cor return goTaintFindings(env, file, source) case isPythonFile(file) && taintToggleEnabled(rules.TaintPython): return pythonTaintFindings(env, file, source) + case isCPPFile(file) && taintToggleEnabled(rules.TaintCPP): + return cppTaintFindings(env, file, source) default: return nil } @@ -56,7 +58,7 @@ func appendTaintFinding(env support.Context, file string, seen map[string]struct return findings } seen[key] = struct{}{} - // Taint findings come from AST-based source-to-sink analysis (including the + // Taint findings come from structured source-to-sink analysis (including // SSRF sinks), so they carry high confidence compared to regex line scans. return append(findings, env.NewFinding(support.FindingInput{ RuleID: input.ruleID, diff --git a/internal/codeguard/checks/security/security_taint_cpp.go b/internal/codeguard/checks/security/security_taint_cpp.go new file mode 100644 index 0000000..67bd3bc --- /dev/null +++ b/internal/codeguard/checks/security/security_taint_cpp.go @@ -0,0 +1,137 @@ +package security + +import ( + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +// cppTaintAnalyzer uses the lightweight, comment/string-masked C-like parser. +// Two summary passes and a reporting pass resolve bounded same-file call paths. +type cppTaintAnalyzer struct { + env support.Context + file string + parsed *support.ParsedFile + summaries map[string]*cppSummary + findings []core.Finding + seen map[string]struct{} +} + +func cppTaintFindings(env support.Context, file string, source string) []core.Finding { + analyzer := &cppTaintAnalyzer{ + env: env, + file: file, + parsed: support.ParseCLike(source, support.CLikeCPP), + summaries: map[string]*cppSummary{}, + seen: map[string]struct{}{}, + } + analyzer.runPasses() + return analyzer.findings +} + +func (a *cppTaintAnalyzer) runPasses() { + for pass := 0; pass < 3; pass++ { + emit := pass == 2 + next := map[string]*cppSummary{} + for _, fn := range a.parsed.AllFunctions() { + summary := a.analyzeScope(fn, emit) + next[fn.Name] = summary + if base := cppCalleeBase(fn.Name); base != fn.Name { + if _, exists := next[base]; !exists { + next[base] = summary + } + } + } + a.summaries = next + } +} + +func (a *cppTaintAnalyzer) analyzeScope(fn *support.ParsedFunction, emit bool) *cppSummary { + scope := &cppScope{ + analyzer: a, + fn: fn, + emit: emit, + vars: map[string]*cppTaint{}, + summary: &cppSummary{paramsToReturn: map[int]bool{}}, + } + scope.bindParams() + for _, statement := range fn.Statements { + scope.processStatement(statement) + } + return scope.summary +} + +type cppScope struct { + analyzer *cppTaintAnalyzer + fn *support.ParsedFunction + emit bool + vars map[string]*cppTaint + summary *cppSummary +} + +func (s *cppScope) bindParams() { + for index, param := range s.fn.Params { + taint := &cppTaint{ + source: "parameter " + param.Name, + sourceLine: s.fn.StartLine, + chain: []string{param.Name}, + paramIndex: index, + } + if cppCalleeBase(s.fn.Name) == "main" && (param.Name == "argv" || param.Name == "envp") { + taint.source = "main parameter " + param.Name + taint.paramIndex = -1 + } + s.vars[param.Name] = taint + } +} + +func (s *cppScope) processStatement(statement support.ParsedStatement) { + s.bindInputWrites(statement) + s.checkStatementSinks(statement) + s.applyAssignments(statement) + trimmed := strings.TrimSpace(statement.Text) + if rest, isReturn := strings.CutPrefix(trimmed, "return "); isReturn { + s.recordReturn(strings.TrimSuffix(strings.TrimSpace(rest), ";"), statement.Line) + } +} + +func (s *cppScope) applyAssignments(statement support.ParsedStatement) { + for _, assignment := range s.fn.Assignments { + if assignment.Line == statement.Line { + s.updateAssignedValue(assignment) + } + } +} + +func (s *cppScope) updateAssignedValue(assignment support.ParsedAssignment) { + if taint := s.evalExpr(assignment.Expr, assignment.Line); taint != nil { + s.vars[assignment.Name] = taint.extended(assignment.Name) + return + } + // Appending a constant does not make an existing tainted value safe. + if !assignment.Augmented { + delete(s.vars, assignment.Name) + } +} + +func (s *cppScope) recordReturn(expr string, line int) { + taint := s.evalExpr(expr, line) + switch { + case taint == nil: + // No summary effect for a return value proven untainted. + case taint.paramIndex >= 0: + s.summary.paramsToReturn[taint.paramIndex] = true + case s.summary.returnTaint == nil: + s.summary.returnTaint = taint + } +} + +func cppCalleeBase(callee string) string { + for _, separator := range []string{"->", "::", "."} { + if cut := strings.LastIndex(callee, separator); cut >= 0 { + callee = callee[cut+len(separator):] + } + } + return callee +} diff --git a/internal/codeguard/checks/security/security_taint_cpp_expr.go b/internal/codeguard/checks/security/security_taint_cpp_expr.go new file mode 100644 index 0000000..e7a0e28 --- /dev/null +++ b/internal/codeguard/checks/security/security_taint_cpp_expr.go @@ -0,0 +1,114 @@ +package security + +import ( + "regexp" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" +) + +var ( + cppSanitizerCallPattern = regexp.MustCompile(`(?:^|[^\w:])((?:(?:std::)?(?:stoi|stol|stoll|stoul|stoull)|escape_shell_arg|shell_escape|allowlisted_url|validated_url)\s*\()`) + cppEnvSourcePattern = regexp.MustCompile(`(?:^|[^\w:])((?:std::)?getenv)\s*\(`) + cppIdentScanPattern = regexp.MustCompile(`[A-Za-z_]\w*`) + cppInputExtractPattern = regexp.MustCompile(`\b(?:std\s*::\s*)?cin\s*>>\s*([A-Za-z_]\w*)`) + cppRequestTypePattern = regexp.MustCompile(`(?i)\b(?:basic_)?(?:http_?request|request)\b`) +) + +func stripCPPSanitizers(text string) string { + return stripTaintSanitizerCalls(text, cppSanitizerCallPattern) +} + +func (s *cppScope) evalExpr(expr string, line int) *cppTaint { + stripped := stripCPPSanitizers(expr) + if taint := s.directSourceTaint(stripped, line); taint != nil { + return taint + } + taint := s.localCallTaint(stripped, line) + return preferCPPTaint(taint, s.taintedIdentifier(stripped)) +} + +func (s *cppScope) directSourceTaint(expr string, line int) *cppTaint { + if match := cppEnvSourcePattern.FindStringSubmatch(expr); match != nil { + return &cppTaint{source: match[1] + "()", sourceLine: line, chain: []string{match[1] + "()"}, paramIndex: -1} + } + for _, param := range s.fn.Params { + if !cppRequestTypePattern.MatchString(param.Type) || !cppRequestAccessor(expr, param.Name) { + continue + } + source := param.Name + " request data" + return &cppTaint{source: source, sourceLine: line, chain: []string{source}, paramIndex: -1} + } + return nil +} + +func cppRequestAccessor(expr string, param string) bool { + receiver := regexp.QuoteMeta(param) + `\s*(?:\.|->)\s*` + accessor := `(?:getParameter|getQueryParameter|getHeader|query|param|url_params\s*\.\s*get)\b` + return regexp.MustCompile(`\b` + receiver + accessor).MatchString(expr) +} + +func (s *cppScope) localCallTaint(expr string, line int) *cppTaint { + for _, call := range support.ExtractCLikeCalls(expr, line) { + summary := s.lookupSummary(call.Callee) + if summary == nil { + continue + } + if summary.returnTaint != nil { + inner := summary.returnTaint + return &cppTaint{ + source: inner.source, + sourceLine: inner.sourceLine, + chain: append(append([]string{}, inner.chain...), call.Callee+"()"), + paramIndex: -1, + } + } + for index, arg := range call.Args { + if !summary.paramsToReturn[index] { + continue + } + if taint := s.evalExpr(arg, line); taint != nil { + return taint.extended(call.Callee + "()") + } + } + } + return nil +} + +func (s *cppScope) taintedIdentifier(expr string) *cppTaint { + var found *cppTaint + for _, name := range cppIdentScanPattern.FindAllString(expr, -1) { + if taint, tracked := s.vars[name]; tracked { + found = preferCPPTaint(found, taint) + } + } + return found +} + +func (s *cppScope) bindInputWrites(statement support.ParsedStatement) { + for _, match := range cppInputExtractPattern.FindAllStringSubmatch(statement.Text, -1) { + s.bindConcreteSource(match[1], "std::cin", statement.Line) + } + for _, call := range support.ExtractCLikeCalls(statement.Text, statement.Line) { + if cppCalleeBase(call.Callee) != "getline" || len(call.Args) < 2 { + continue + } + input := strings.ReplaceAll(call.Args[0], " ", "") + name := strings.TrimSpace(call.Args[1]) + identifier := cppIdentScanPattern.FindString(name) + if (input == "std::cin" || input == "cin") && identifier == name { + s.bindConcreteSource(name, "std::getline(std::cin)", call.Line) + } + } +} + +func (s *cppScope) bindConcreteSource(name string, source string, line int) { + s.vars[name] = &cppTaint{source: source, sourceLine: line, chain: []string{source, name}, paramIndex: -1} +} + +func (s *cppScope) lookupSummary(callee string) *cppSummary { + if summary := s.analyzer.summaries[callee]; summary != nil { + return summary + } + return s.analyzer.summaries[cppCalleeBase(callee)] +} diff --git a/internal/codeguard/checks/security/security_taint_cpp_sinks.go b/internal/codeguard/checks/security/security_taint_cpp_sinks.go new file mode 100644 index 0000000..3dbb630 --- /dev/null +++ b/internal/codeguard/checks/security/security_taint_cpp_sinks.go @@ -0,0 +1,111 @@ +package security + +import ( + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" +) + +var cppSimpleProcessSinks = map[string]bool{ + "system": true, "_wsystem": true, "popen": true, "_popen": true, "_wpopen": true, + "execl": true, "execle": true, "execlp": true, "execv": true, "execve": true, + "execvp": true, "execvpe": true, "WinExec": true, +} + +func cppTaintRuleID(sink string) string { + if isCPPSSRFSink(sink) { + return "security.ssrf.cpp" + } + return "security.taint.cpp" +} + +func (a *cppTaintAnalyzer) emitFinding(taint *cppTaint, sink string, sinkLine int) { + a.findings = appendTaintFinding(a.env, a.file, a.seen, a.findings, taintSinkInput{ + ruleID: cppTaintRuleID(sink), + source: taint.source, + sourceLine: taint.sourceLine, + chain: taint.chain, + sink: sink, + sinkLine: sinkLine, + }) +} + +func (s *cppScope) reportSink(taint *cppTaint, sink string, line int) { + if taint == nil { + return + } + if taint.paramIndex >= 0 { + s.summary.paramsToSink = append(s.summary.paramsToSink, cppParamSink{ + paramIndex: taint.paramIndex, + sink: sink, + line: line, + }) + return + } + if s.emit { + s.analyzer.emitFinding(taint, sink, line) + } +} + +func (s *cppScope) checkStatementSinks(statement support.ParsedStatement) { + for _, call := range support.ExtractCLikeCalls(statement.Text, statement.Line) { + s.checkCallSink(call) + s.applyLocalParamSinks(call) + } +} + +func (s *cppScope) checkCallSink(call support.ParsedCall) { + base := cppCalleeBase(call.Callee) + switch { + case cppSimpleProcessSinks[base]: + s.reportSink(s.argTaint(call, 0), call.Callee, call.Line) + case base == "CreateProcess" || base == "CreateProcessA" || base == "CreateProcessW": + s.reportFirstTainted(call, call.Callee, 0, 1) + case base == "ShellExecute" || base == "ShellExecuteA" || base == "ShellExecuteW": + s.reportFirstTainted(call, call.Callee, 2, 3) + case call.Callee == "boost::process::system" || call.Callee == "boost::process::child": + s.reportSink(s.argTaint(call, 0), call.Callee, call.Line) + case base == "curl_easy_setopt": + s.checkCurlURLSink(call) + case isCPRRequestCall(call.Callee): + s.reportSink(s.argTaint(call, 0), call.Callee, call.Line) + case base == "SetUrl": + s.reportSink(s.argTaint(call, 0), call.Callee, call.Line) + case isBoostResolverCall(call.Callee): + s.reportSink(s.argTaint(call, 0), call.Callee, call.Line) + case strings.Contains(call.Callee, "web::http::client::http_client") || strings.Contains(call.Callee, "Poco::Net::HTTPClientSession"): + s.reportSink(s.argTaint(call, 0), call.Callee, call.Line) + } +} + +func (s *cppScope) argTaint(call support.ParsedCall, index int) *cppTaint { + if index < 0 || index >= len(call.Args) { + return nil + } + return s.evalExpr(call.Args[index], call.Line) +} + +func (s *cppScope) reportFirstTainted(call support.ParsedCall, sink string, indexes ...int) { + for _, index := range indexes { + if taint := s.argTaint(call, index); taint != nil { + s.reportSink(taint, sink, call.Line) + return + } + } +} + +func (s *cppScope) applyLocalParamSinks(call support.ParsedCall) { + summary := s.lookupSummary(call.Callee) + if summary == nil { + return + } + for _, paramSink := range summary.paramsToSink { + if paramSink.paramIndex >= len(call.Args) { + continue + } + taint := s.evalExpr(call.Args[paramSink.paramIndex], call.Line) + if taint != nil { + s.reportSink(taint.extended(call.Callee+"()"), paramSink.sink, paramSink.line) + } + } +} diff --git a/internal/codeguard/checks/security/security_taint_cpp_ssrf.go b/internal/codeguard/checks/security/security_taint_cpp_ssrf.go new file mode 100644 index 0000000..bf17596 --- /dev/null +++ b/internal/codeguard/checks/security/security_taint_cpp_ssrf.go @@ -0,0 +1,40 @@ +package security + +import ( + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" +) + +func isCPPSSRFSink(sink string) bool { + return strings.HasPrefix(sink, "curl_easy_setopt[") || strings.HasPrefix(sink, "cpr::") || + strings.HasSuffix(sink, "SetUrl") || strings.HasSuffix(sink, ".resolve") || + strings.HasSuffix(sink, "->resolve") || strings.Contains(sink, "http_client") || + strings.Contains(sink, "HTTPClientSession") +} + +func (s *cppScope) checkCurlURLSink(call support.ParsedCall) { + if len(call.Args) < 3 || strings.TrimSpace(call.Args[1]) != "CURLOPT_URL" { + return + } + s.reportSink(s.argTaint(call, 2), "curl_easy_setopt[CURLOPT_URL]", call.Line) +} + +func isCPRRequestCall(callee string) bool { + if !strings.HasPrefix(callee, "cpr::") { + return false + } + switch cppCalleeBase(callee) { + case "Get", "Post", "Put", "Delete", "Patch", "Head", "Options", "Download": + return true + default: + return false + } +} + +func isBoostResolverCall(callee string) bool { + if !strings.HasSuffix(callee, ".resolve") && !strings.HasSuffix(callee, "->resolve") && !strings.HasSuffix(callee, "::resolve") { + return false + } + return strings.Contains(strings.ToLower(callee), "resolver") +} diff --git a/internal/codeguard/checks/security/security_taint_cpp_types.go b/internal/codeguard/checks/security/security_taint_cpp_types.go new file mode 100644 index 0000000..6d3d3b6 --- /dev/null +++ b/internal/codeguard/checks/security/security_taint_cpp_types.go @@ -0,0 +1,41 @@ +package security + +// cppTaint tracks one C++ value whose origin is untrusted. paramIndex is set +// for a function parameter whose taint depends on its caller. +type cppTaint struct { + source string + sourceLine int + chain []string + paramIndex int +} + +func (t *cppTaint) extended(step string) *cppTaint { + next := *t + next.chain = append(append([]string{}, t.chain...), step) + return &next +} + +func preferCPPTaint(left *cppTaint, right *cppTaint) *cppTaint { + if left == nil { + return right + } + if right == nil { + return left + } + if left.paramIndex >= 0 && right.paramIndex < 0 { + return right + } + return left +} + +type cppParamSink struct { + paramIndex int + sink string + line int +} + +type cppSummary struct { + returnTaint *cppTaint + paramsToReturn map[int]bool + paramsToSink []cppParamSink +} diff --git a/internal/codeguard/checks/security/security_taint_python_expr.go b/internal/codeguard/checks/security/security_taint_python_expr.go index 2ee0254..99f5156 100644 --- a/internal/codeguard/checks/security/security_taint_python_expr.go +++ b/internal/codeguard/checks/security/security_taint_python_expr.go @@ -19,18 +19,7 @@ var ( // stripPySanitizers removes shlex.quote(...), int(...), and float(...) // spans so sanitized values stop carrying taint. func stripPySanitizers(text string) string { - for { - match := pySanitizerCallPattern.FindStringSubmatchIndex(text) - if match == nil { - return text - } - openParen := match[3] - 1 - closeParen := matchingParenOffset(text, openParen) - if closeParen < 0 { - return text[:match[2]] + text[match[3]:] - } - text = text[:match[2]] + text[closeParen+1:] - } + return stripTaintSanitizerCalls(text, pySanitizerCallPattern) } func matchingParenOffset(text string, open int) int { diff --git a/internal/codeguard/checks/security/security_taint_sanitizers.go b/internal/codeguard/checks/security/security_taint_sanitizers.go new file mode 100644 index 0000000..b91c3bf --- /dev/null +++ b/internal/codeguard/checks/security/security_taint_sanitizers.go @@ -0,0 +1,21 @@ +package security + +import "regexp" + +// stripTaintSanitizerCalls removes complete sanitizer-call spans before an +// expression is searched for tainted identifiers. Language-specific callers +// supply patterns whose first capture includes the opening parenthesis. +func stripTaintSanitizerCalls(text string, pattern *regexp.Regexp) string { + for { + match := pattern.FindStringSubmatchIndex(text) + if match == nil { + return text + } + openParen := match[3] - 1 + closeParen := matchingParenOffset(text, openParen) + if closeParen < 0 { + return text[:match[2]] + text[match[3]:] + } + text = text[:match[2]] + text[closeParen+1:] + } +} diff --git a/internal/codeguard/checks/support/context.go b/internal/codeguard/checks/support/context.go index 70b7a24..e3224a3 100644 --- a/internal/codeguard/checks/support/context.go +++ b/internal/codeguard/checks/support/context.go @@ -21,6 +21,17 @@ type FindingInput struct { Confidence string `json:"confidence,omitempty"` } +type CPPToolIssue struct { + Path string + Message string +} + +type CPPToolResult struct { + Issues []CPPToolIssue + Unavailable bool + Err error +} + type Context struct { Config core.Config AIEnabled bool @@ -61,6 +72,8 @@ type Context struct { IsSDKFacadeFile func(path string) bool IsPromptFile func(rel string) bool RunGovulncheck func(ctx context.Context, dir string, cmdName string) ([]core.Finding, error) + RunCPPFormat func(ctx context.Context, dir string, cfg core.CPPToolingConfig, files []string) CPPToolResult + RunCPPSyntax func(ctx context.Context, dir string, cfg core.CPPToolingConfig) CPPToolResult RunCommandCheck func(ctx context.Context, dir string, check core.CommandCheckConfig) (string, error) RunCommandCheckWithEnv func(ctx context.Context, dir string, check core.CommandCheckConfig, env []string) (string, error) RunDiffCommandCheck func(ctx context.Context, dir string, baseRef string, check core.CommandCheckConfig) (string, error) diff --git a/internal/codeguard/checks/support/cpp_dependency_graph.go b/internal/codeguard/checks/support/cpp_dependency_graph.go index 12ef56f..afa8799 100644 --- a/internal/codeguard/checks/support/cpp_dependency_graph.go +++ b/internal/codeguard/checks/support/cpp_dependency_graph.go @@ -4,8 +4,10 @@ import ( "path" "path/filepath" "regexp" + "strings" "github.com/devr-tools/codeguard/internal/codeguard/core" + "github.com/devr-tools/codeguard/internal/codeguard/cpp/compdb" ) var ( @@ -59,12 +61,13 @@ func BuildCPPDependencyGraph(env Context, target core.TargetConfig) *CPPDependen return nil } seen := make(map[string]map[string]bool, len(nodes)) + includeRoots := cppTargetIncludeRoots(env, target) for _, dependency := range pending { to := "" if dependency.named { to = moduleFiles[dependency.target] } else { - to = resolveCPPInclude(nodes, dependency.from, dependency.target) + to = resolveCPPInclude(nodes, dependency.from, dependency.target, includeRoots) } if to == "" || to == dependency.from { continue @@ -83,16 +86,54 @@ func BuildCPPDependencyGraph(env Context, target core.TargetConfig) *CPPDependen return &CPPDependencyGraph{Graph: NewDependencyGraph(nodes), FileToModule: fileToModule} } -func resolveCPPInclude(nodes map[string]DependencyNode, from string, imported string) string { +func resolveCPPInclude(nodes map[string]DependencyNode, from string, imported string, includeRoots []string) string { imported = filepath.ToSlash(imported) - candidates := []string{ + candidates := make([]string, 0, 2+len(includeRoots)) + candidates = append(candidates, path.Clean(path.Join(path.Dir(from), imported)), path.Clean(imported), + ) + for _, root := range includeRoots { + candidates = append(candidates, path.Clean(path.Join(root, imported))) } + resolved := "" for _, candidate := range candidates { if _, ok := nodes[candidate]; ok { - return candidate + if resolved != "" && resolved != candidate { + return "" + } + resolved = candidate } } - return "" + return resolved +} + +func cppTargetIncludeRoots(env Context, target core.TargetConfig) []string { + db, err := compdb.Load(target.Path, env.Config.Checks.QualityRules.CPPTooling.CompileCommands) + if err != nil { + return nil + } + root, err := filepath.Abs(target.Path) + if err != nil { + return nil + } + if resolved, err := filepath.EvalSymlinks(root); err == nil { + root = resolved + } + seen := make(map[string]bool) + result := make([]string, 0) + for _, entry := range db.Entries { + for _, include := range entry.IncludeDirs { + rel, err := filepath.Rel(root, include) + if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + continue + } + rel = filepath.ToSlash(rel) + if !seen[rel] { + seen[rel] = true + result = append(result, rel) + } + } + } + return result } diff --git a/internal/codeguard/checks/support/parser_clike_scope.go b/internal/codeguard/checks/support/parser_clike_scope.go index bd68108..64f3ae5 100644 --- a/internal/codeguard/checks/support/parser_clike_scope.go +++ b/internal/codeguard/checks/support/parser_clike_scope.go @@ -11,7 +11,7 @@ var ( javaDeclAssignPattern = regexp.MustCompile(`^[ \t]*(?:final[ \t]+)?(?:[\w<>\[\],.?&]+(?:[ \t]+[\w<>\[\],.?&]+)*[ \t]+)?([A-Za-z_]\w*)[ \t]*=[ \t]*([^=].*)$`) cppDeclAssignPattern = regexp.MustCompile(`^[ \t]*(?:constexpr[ \t]+|static[ \t]+|inline[ \t]+|const[ \t]+|volatile[ \t]+|mutable[ \t]+)*(?:[\w:<>[\],.?&*]+(?:[ \t]+[\w:<>[\],.?&*]+)*)[ \t]+([A-Za-z_]\w*)[ \t]*=[ \t]*([^=].*)$`) plainAssignPattern = regexp.MustCompile(`^[ \t]*([A-Za-z_$][\w$]*)[ \t]*([-+*/%&|^]?)=[ \t]*([^=].*)$`) - clikeCallPattern = regexp.MustCompile(`([A-Za-z_$][\w$]*(?:(?:\.|::)[A-Za-z_$][\w$]*)*)[ \t]*\(`) + clikeCallPattern = regexp.MustCompile(`([A-Za-z_$][\w$]*(?:[ \t]*(?:\.|::|->)[ \t]*[A-Za-z_$][\w$]*)*)[ \t]*\(`) ) // clikeAssignments extracts declarations and reassignments from one masked @@ -44,7 +44,7 @@ func declAssignPatternFor(lang CLikeLanguage) *regexp.Regexp { func clikeCalls(text string, startLine int) []ParsedCall { calls := make([]ParsedCall, 0, 2) for _, match := range clikeCallPattern.FindAllStringSubmatchIndex(text, -1) { - callee := text[match[2]:match[3]] + callee := normalizeCLikeCallee(text[match[2]:match[3]]) base := callee if cut := strings.IndexAny(base, ".:"); cut >= 0 { base = base[:cut] @@ -59,6 +59,18 @@ func clikeCalls(text string, startLine int) []ParsedCall { return calls } +// ExtractCLikeCalls extracts call expressions from already-masked C-like +// source. It is useful to lightweight semantic checks that operate on a +// single expression rather than a whole ParsedFile. +func ExtractCLikeCalls(text string, startLine int) []ParsedCall { + return clikeCalls(text, startLine) +} + +func normalizeCLikeCallee(callee string) string { + callee = strings.ReplaceAll(callee, " ", "") + return strings.ReplaceAll(callee, "\t", "") +} + func isCLikeKeyword(word string) bool { switch word { case "if", "else", "for", "while", "switch", "match", "catch", "return", diff --git a/internal/codeguard/checks/support/supply_chain.go b/internal/codeguard/checks/support/supply_chain.go index 15c896f..b7e6cd1 100644 --- a/internal/codeguard/checks/support/supply_chain.go +++ b/internal/codeguard/checks/support/supply_chain.go @@ -17,6 +17,11 @@ var ( tomlKeyPattern = regexp.MustCompile(`^\s*(?:"([^"]+)"|([A-Za-z0-9._-]+))\s*=`) quotedStringPattern = regexp.MustCompile(`["']([^"']+)["']`) cargoInlineVersionPattern = regexp.MustCompile(`(?:^|[,{\s])version\s*=\s*["']([^"']+)["']`) + knownManifestNames = map[string]bool{ + "go.mod": true, "package.json": true, "pyproject.toml": true, + "cargo.toml": true, "vcpkg.json": true, "conanfile.txt": true, + "conanfile.py": true, + } ) func IsSupplyChainManifest(rel string) bool { @@ -24,25 +29,8 @@ func IsSupplyChainManifest(rel string) bool { if isInstalledDependencyMetadataPath(normalized) { return false } - base := strings.ToLower(path.Base(filepath.ToSlash(rel))) - switch { - case base == "go.mod": - return true - case base == "package.json": - return true - case base == "pyproject.toml": - return true - case base == "cargo.toml": - return true - case base == "vcpkg.json": - return true - case base == "conanfile.txt": - return true - case strings.HasPrefix(base, "requirements") && strings.HasSuffix(base, ".txt"): - return true - default: - return false - } + base := strings.ToLower(path.Base(normalized)) + return knownManifestNames[base] || isCMakeManifestName(base) || isRequirementsManifestName(base) } func CollectSupplyChainManifests(env Context, target core.TargetConfig) []core.SupplyChainManifest { @@ -89,14 +77,34 @@ func parseSupplyChainManifest(root string, rel string, data []byte) (core.Supply return parseVCPKGManifest(root, rel, data) case "conanfile.txt": return parseConanTextManifest(root, rel, data), true + case "conanfile.py": + return parseConanPythonManifest(root, rel, data), true default: - if strings.HasPrefix(strings.ToLower(path.Base(rel)), "requirements") && strings.HasSuffix(strings.ToLower(path.Base(rel)), ".txt") { - return parseRequirementsManifest(root, rel, data), true - } + return parseOtherSupplyChainManifest(root, rel, data) + } +} + +func parseOtherSupplyChainManifest(root, rel string, data []byte) (core.SupplyChainManifest, bool) { + base := strings.ToLower(path.Base(rel)) + if isCMakeManifestName(base) { + manifest := parseCMakeManifest(root, rel, data) + complete := base == "cmakelists.txt" || len(manifest.Dependencies) > 0 || len(manifest.AnalysisLimitations) > 0 + return manifest, complete + } + if isRequirementsManifestName(base) { + return parseRequirementsManifest(root, rel, data), true } return core.SupplyChainManifest{}, false } +func isCMakeManifestName(base string) bool { + return base == "cmakelists.txt" || strings.HasSuffix(base, ".cmake") +} + +func isRequirementsManifestName(base string) bool { + return strings.HasPrefix(base, "requirements") && strings.HasSuffix(base, ".txt") +} + func isInstalledDependencyMetadataPath(rel string) bool { parts := strings.Split(filepath.ToSlash(rel), "/") for _, part := range parts { diff --git a/internal/codeguard/checks/support/supply_chain_artifacts.go b/internal/codeguard/checks/support/supply_chain_artifacts.go index 6a21dcb..c3128ff 100644 --- a/internal/codeguard/checks/support/supply_chain_artifacts.go +++ b/internal/codeguard/checks/support/supply_chain_artifacts.go @@ -17,15 +17,17 @@ func NewSupplyChainArtifact(id string, target string, manifests []core.SupplyCha deps[i].LicenseCandidates = append([]core.SupplyChainLicenseCandidate(nil), deps[i].LicenseCandidates...) } lockfiles := append([]string(nil), manifest.Lockfiles...) + limitations := append([]string(nil), manifest.AnalysisLimitations...) cloned = append(cloned, core.SupplyChainManifest{ - Ecosystem: manifest.Ecosystem, - Path: manifest.Path, - Name: manifest.Name, - License: manifest.License, - LicenseLine: manifest.LicenseLine, - PackageManager: manifest.PackageManager, - Lockfiles: lockfiles, - Dependencies: deps, + Ecosystem: manifest.Ecosystem, + Path: manifest.Path, + Name: manifest.Name, + License: manifest.License, + LicenseLine: manifest.LicenseLine, + PackageManager: manifest.PackageManager, + Lockfiles: lockfiles, + Dependencies: deps, + AnalysisLimitations: limitations, }) } return core.Artifact{ diff --git a/internal/codeguard/checks/support/supply_chain_cmake.go b/internal/codeguard/checks/support/supply_chain_cmake.go new file mode 100644 index 0000000..8bd27b4 --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_cmake.go @@ -0,0 +1,127 @@ +package support + +import ( + "fmt" + "regexp" + "slices" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +var cmakeVariablePattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`) + +func parseCMakeManifest(_ string, rel string, data []byte) core.SupplyChainManifest { + manifest := core.SupplyChainManifest{ + Ecosystem: "cmake", + PackageManager: "cmake", + Path: rel, + } + variables := make(map[string]string) + for _, command := range scanCMakeCommands(string(data)) { + args, resolved := resolveCMakeArguments(command.args, variables) + switch strings.ToLower(command.name) { + case "set": + rememberCMakeVariable(variables, args, resolved) + case "find_package": + if dep, ok := parseCMakeFindPackage(args, command.line); ok { + manifest.Dependencies = append(manifest.Dependencies, dep) + } + appendCMakeResolutionLimitation(&manifest, command, resolved) + case "fetchcontent_declare", "externalproject_add", "cpmaddpackage": + if dep, ok := parseCMakeFetch(command.name, args, command.line); ok { + manifest.Dependencies = append(manifest.Dependencies, dep) + } + appendCMakeResolutionLimitation(&manifest, command, resolved) + } + } + sortDependencies(manifest.Dependencies) + manifest.AnalysisLimitations = uniqueSortedStrings(manifest.AnalysisLimitations) + return manifest +} + +func appendCMakeResolutionLimitation(manifest *core.SupplyChainManifest, command cmakeCommand, resolved bool) { + if resolved { + return + } + manifest.AnalysisLimitations = append(manifest.AnalysisLimitations, + fmt.Sprintf("%s dependency declaration at line %d contains an unresolved variable; CodeGuard did not execute CMake to resolve it", command.name, command.line)) +} + +func resolveCMakeArguments(args []cmakeArgument, variables map[string]string) ([]cmakeArgument, bool) { + resolved := make([]cmakeArgument, len(args)) + allResolved := true + for idx, arg := range args { + value, ok := resolveCMakeValue(arg.value, variables) + resolved[idx] = cmakeArgument{value: value, line: arg.line} + allResolved = allResolved && ok + } + return resolved, allResolved +} + +func resolveCMakeValue(value string, variables map[string]string) (string, bool) { + for range 8 { + unresolved := false + changed := false + value = cmakeVariablePattern.ReplaceAllStringFunc(value, func(match string) string { + parts := cmakeVariablePattern.FindStringSubmatch(match) + replacement, ok := variables[parts[1]] + if !ok { + unresolved = true + return match + } + changed = true + return replacement + }) + if unresolved { + return value, false + } + if !changed { + return value, !strings.Contains(value, "$<") && !strings.Contains(value, "$ENV{") + } + } + return value, false +} + +func rememberCMakeVariable(variables map[string]string, args []cmakeArgument, resolved bool) { + if len(args) < 2 || !resolved || !isCMakeIdentifier(args[0].value) { + return + } + values := make([]string, 0, len(args)-1) + for _, arg := range args[1:] { + upper := strings.ToUpper(arg.value) + if upper == "CACHE" || upper == "PARENT_SCOPE" { + break + } + values = append(values, arg.value) + } + if len(values) > 0 { + variables[args[0].value] = strings.Join(values, ";") + } +} + +func uniqueSortedStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + unique := make([]string, 0, len(values)) + for _, value := range values { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + unique = append(unique, value) + } + slices.Sort(unique) + return unique +} + +func isCMakeIdentifier(value string) bool { + if value == "" || !isCMakeIdentifierStart(value[0]) { + return false + } + for _, char := range []byte(value[1:]) { + if !isCMakeIdentifierPart(char) { + return false + } + } + return true +} diff --git a/internal/codeguard/checks/support/supply_chain_cmake_dependencies.go b/internal/codeguard/checks/support/supply_chain_cmake_dependencies.go new file mode 100644 index 0000000..3799323 --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_cmake_dependencies.go @@ -0,0 +1,135 @@ +package support + +import ( + "path" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func parseCMakeFetch(commandName string, args []cmakeArgument, line int) (core.SupplyChainDependency, bool) { + if len(args) == 0 { + return core.SupplyChainDependency{}, false + } + values := cmakeKeywordValues(args) + name := cmakeFetchName(commandName, args[0].value, values) + repository := firstNonEmpty(values["GIT_REPOSITORY"], values["GITHUB_REPOSITORY"], values["GITLAB_REPOSITORY"], values["BITBUCKET_REPOSITORY"]) + url := firstNonEmpty(values["URL"], values["DOWNLOAD_URL"]) + tag, version := firstNonEmpty(values["GIT_TAG"], values["TAG"]), values["VERSION"] + if repository == "" && url == "" && tag == "" && version == "" { + return core.SupplyChainDependency{}, false + } + name = resolvedCMakeFetchName(name, repository, url) + if name == "" { + return core.SupplyChainDependency{}, false + } + requirement := cmakeFetchRequirement(repository, url, tag, version, values["URL_HASH"]) + pinned, resolvedVersion := cmakeFetchPin(url, tag, version, values["URL_HASH"]) + return core.SupplyChainDependency{ + Name: name, + Requirement: requirement, + Version: resolvedVersion, + Scope: "build", + Pinned: pinned, + Line: line, + }, true +} + +func cmakeFetchName(commandName string, firstArg string, values map[string]string) string { + if !strings.EqualFold(commandName, "CPMAddPackage") { + return firstArg + } + if values["NAME"] != "" { + return values["NAME"] + } + name, version, ok := parseCPMCompactReference(firstArg) + if !ok { + return firstArg + } + values["VERSION"] = version + return name +} + +func resolvedCMakeFetchName(name string, repository string, url string) string { + if name == "" || containsDynamicCMakeValue(name) { + name = dependencyNameFromLocation(firstNonEmpty(repository, url)) + } + if containsDynamicCMakeValue(name) { + return "" + } + return name +} + +func cmakeFetchRequirement(repository string, url string, tag string, version string, urlHash string) string { + if url != "" && urlHash != "" { + return url + "#" + urlHash + } + if repository != "" && tag != "" { + return repository + "@" + tag + } + return firstNonEmpty(tag, version, url, repository) +} + +func cmakeFetchPin(url string, tag string, version string, urlHash string) (bool, string) { + resolvedVersion := version + if resolvedVersion == "" && url != "" { + resolvedVersion = versionFromCMakeURL(url) + } + if resolvedVersion == "" && isExactCMakeRevision(tag) { + resolvedVersion = tag + } + switch { + case urlHash != "" && !containsDynamicCMakeValue(urlHash): + return true, resolvedVersion + case tag != "": + return isExactCMakeRevision(tag), resolvedVersion + case version != "": + return isExactCMakeVersion(version), resolvedVersion + default: + return resolvedVersion != "" && !containsDynamicCMakeValue(url), resolvedVersion + } +} + +func cmakeKeywordValues(args []cmakeArgument) map[string]string { + values := make(map[string]string) + known := map[string]struct{}{ + "NAME": {}, "VERSION": {}, "GIT_REPOSITORY": {}, "GITHUB_REPOSITORY": {}, + "GITLAB_REPOSITORY": {}, "BITBUCKET_REPOSITORY": {}, "GIT_TAG": {}, "TAG": {}, + "URL": {}, "DOWNLOAD_URL": {}, "URL_HASH": {}, + } + for idx := 0; idx+1 < len(args); idx++ { + key := strings.ToUpper(args[idx].value) + if _, ok := known[key]; ok { + values[key] = args[idx+1].value + idx++ + } + } + return values +} + +func parseCPMCompactReference(raw string) (string, string, bool) { + raw = strings.TrimSpace(raw) + if strings.HasPrefix(raw, "gh:") || strings.HasPrefix(raw, "gl:") || strings.HasPrefix(raw, "bb:") { + raw = raw[3:] + } + name, version, ok := strings.Cut(raw, "@") + if !ok || name == "" || version == "" { + return "", "", false + } + return dependencyNameFromLocation(name), version, true +} + +func dependencyNameFromLocation(location string) string { + location = strings.TrimSuffix(strings.TrimSpace(location), "/") + location = strings.TrimSuffix(location, ".git") + return path.Base(location) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/internal/codeguard/checks/support/supply_chain_cmake_lexer.go b/internal/codeguard/checks/support/supply_chain_cmake_lexer.go new file mode 100644 index 0000000..7b2ec77 --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_cmake_lexer.go @@ -0,0 +1,97 @@ +package support + +import ( + "strings" + "unicode" +) + +func skipCMakeTrivia(source string, position cmakeScanPosition) cmakeScanPosition { + for position.offset < len(source) { + if unicode.IsSpace(rune(source[position.offset])) { + position = skipCMakeWhitespace(source, position) + continue + } + if source[position.offset] == '#' { + position = skipCMakeComment(source, position) + continue + } + break + } + return position +} + +func skipCMakeWhitespace(source string, position cmakeScanPosition) cmakeScanPosition { + for position.offset < len(source) && unicode.IsSpace(rune(source[position.offset])) { + if source[position.offset] == '\n' { + position.line++ + } + position.offset++ + } + return position +} + +func scanCMakeQuoted(source string, position cmakeScanPosition) (string, cmakeScanPosition, bool) { + var value strings.Builder + for position.offset++; position.offset < len(source); position.offset++ { + if source[position.offset] == '\n' { + position.line++ + } + if source[position.offset] == '\\' && position.offset+1 < len(source) { + position.offset++ + if source[position.offset] == '\n' { + position.line++ + } + value.WriteByte(source[position.offset]) + continue + } + if source[position.offset] == '"' { + position.offset++ + return value.String(), position, true + } + value.WriteByte(source[position.offset]) + } + return value.String(), position, false +} + +func scanCMakeBracketArgument(source string, position cmakeScanPosition) (string, cmakeScanPosition, bool) { + start := position.offset + end := start + 1 + for end < len(source) && source[end] == '=' { + end++ + } + if end >= len(source) || source[end] != '[' { + return "", position, false + } + closing := "]" + strings.Repeat("=", end-start-1) + "]" + contentStart := end + 1 + closeAt := strings.Index(source[contentStart:], closing) + if closeAt < 0 { + position.line += strings.Count(source[start:], "\n") + position.offset = len(source) + return "", position, false + } + value := source[contentStart : contentStart+closeAt] + position.offset = contentStart + closeAt + len(closing) + position.line += strings.Count(source[start:position.offset], "\n") + return value, position, true +} + +func skipCMakeComment(source string, position cmakeScanPosition) cmakeScanPosition { + bracketPosition := position + bracketPosition.offset++ + if _, next, ok := scanCMakeBracketArgument(source, bracketPosition); ok { + return next + } + for position.offset < len(source) && source[position.offset] != '\n' { + position.offset++ + } + return position +} + +func isCMakeIdentifierStart(char byte) bool { + return unicode.IsLetter(rune(char)) || char == '_' +} + +func isCMakeIdentifierPart(char byte) bool { + return isCMakeIdentifierStart(char) || unicode.IsDigit(rune(char)) +} diff --git a/internal/codeguard/checks/support/supply_chain_cmake_scanner.go b/internal/codeguard/checks/support/supply_chain_cmake_scanner.go new file mode 100644 index 0000000..f550614 --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_cmake_scanner.go @@ -0,0 +1,111 @@ +package support + +import ( + "unicode" +) + +type cmakeArgument struct { + value string + line int +} + +type cmakeCommand struct { + name string + args []cmakeArgument + line int +} + +type cmakeScanPosition struct { + offset int + line int +} + +func scanCMakeCommands(source string) []cmakeCommand { + commands := make([]cmakeCommand, 0) + position := cmakeScanPosition{line: 1} + for position.offset < len(source) { + command, next, ok := scanCMakeCommandAt(source, position) + position = next + if ok { + commands = append(commands, command) + } + } + return commands +} + +func scanCMakeCommandAt(source string, position cmakeScanPosition) (cmakeCommand, cmakeScanPosition, bool) { + position = skipCMakeTrivia(source, position) + if position.offset >= len(source) { + return cmakeCommand{}, position, false + } + if !isCMakeIdentifierStart(source[position.offset]) { + position.offset++ + return cmakeCommand{}, position, false + } + start, commandLine := position.offset, position.line + for position.offset < len(source) && isCMakeIdentifierPart(source[position.offset]) { + position.offset++ + } + name := source[start:position.offset] + position = skipCMakeWhitespace(source, position) + if position.offset >= len(source) || source[position.offset] != '(' { + return cmakeCommand{}, position, false + } + position.offset++ + args, next, ok := scanCMakeArguments(source, position) + return cmakeCommand{name: name, args: args, line: commandLine}, next, ok +} + +func scanCMakeArguments(source string, position cmakeScanPosition) ([]cmakeArgument, cmakeScanPosition, bool) { + args := make([]cmakeArgument, 0) + depth := 1 + for position.offset < len(source) { + position = skipCMakeTrivia(source, position) + if position.offset >= len(source) { + break + } + if source[position.offset] == ')' { + depth-- + position.offset++ + if depth == 0 { + return args, position, true + } + continue + } + arg, next, nestedDepth, ok := scanCMakeArgumentAt(source, position) + if !ok { + return args, next, false + } + args = append(args, arg) + depth += nestedDepth + position = next + } + return args, position, false +} + +func scanCMakeArgumentAt(source string, position cmakeScanPosition) (cmakeArgument, cmakeScanPosition, int, bool) { + line := position.line + switch source[position.offset] { + case '"': + value, next, ok := scanCMakeQuoted(source, position) + return cmakeArgument{value: value, line: line}, next, 0, ok + case '[': + value, next, ok := scanCMakeBracketArgument(source, position) + if ok { + return cmakeArgument{value: value, line: line}, next, 0, true + } + } + value, next, nestedDepth := scanCMakeUnquoted(source, position) + return cmakeArgument{value: value, line: line}, next, nestedDepth, value != "" +} + +func scanCMakeUnquoted(source string, position cmakeScanPosition) (string, cmakeScanPosition, int) { + start, nestedDepth := position.offset, 0 + for position.offset < len(source) && !unicode.IsSpace(rune(source[position.offset])) && source[position.offset] != ')' && source[position.offset] != '#' { + if source[position.offset] == '(' { + nestedDepth++ + } + position.offset++ + } + return source[start:position.offset], position, nestedDepth +} diff --git a/internal/codeguard/checks/support/supply_chain_cmake_versions.go b/internal/codeguard/checks/support/supply_chain_cmake_versions.go new file mode 100644 index 0000000..3363b5e --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_cmake_versions.go @@ -0,0 +1,82 @@ +package support + +import ( + "regexp" + "strings" + "unicode" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +var ( + cmakeVersionPattern = regexp.MustCompile(`(?i)(?:^|[-_/])v?(\d+(?:\.\d+)+(?:[-+][A-Za-z0-9.-]+)?)(?:$|[-_/\.])`) + cmakeExactVersion = regexp.MustCompile(`^\d+(?:\.\d+)+(?:[-+][A-Za-z0-9.-]+)?$`) + cmakeCommitPattern = regexp.MustCompile(`^[0-9a-fA-F]{7,64}$`) +) + +func parseCMakeFindPackage(args []cmakeArgument, line int) (core.SupplyChainDependency, bool) { + if len(args) == 0 || args[0].value == "" || containsDynamicCMakeValue(args[0].value) { + return core.SupplyChainDependency{}, false + } + name := args[0].value + version := "" + exact := false + if len(args) > 1 && looksLikeCMakeRequirement(args[1].value) { + version = args[1].value + } + for _, arg := range args[1:] { + if strings.EqualFold(arg.value, "EXACT") { + exact = true + } + } + return core.SupplyChainDependency{ + Name: name, + Requirement: version, + Version: version, + Scope: "build", + Pinned: exact && isExactCMakeVersion(version), + Line: line, + }, true +} + +func looksLikeCMakeRequirement(value string) bool { + if value == "" || !unicode.IsDigit(rune(value[0])) { + return false + } + for _, char := range value { + if !unicode.IsDigit(char) && char != '.' && char != '<' && char != '>' && char != '=' { + return false + } + } + return true +} + +func versionFromCMakeURL(url string) string { + match := cmakeVersionPattern.FindStringSubmatch(url) + if len(match) < 2 { + return "" + } + return match[1] +} + +func isExactCMakeRevision(value string) bool { + trimmed := strings.TrimSpace(value) + lower := strings.ToLower(trimmed) + if trimmed == "" || containsDynamicCMakeValue(trimmed) { + return false + } + switch lower { + case "head", "main", "master", "develop", "development", "dev", "trunk", "latest", "stable": + return false + } + return cmakeCommitPattern.MatchString(trimmed) || isExactCMakeVersion(strings.TrimPrefix(trimmed, "v")) +} + +func isExactCMakeVersion(value string) bool { + value = strings.TrimSpace(value) + return cmakeExactVersion.MatchString(value) && !strings.ContainsAny(value, "*<>=~^,;[]") +} + +func containsDynamicCMakeValue(value string) bool { + return strings.Contains(value, "${") || strings.Contains(value, "$<") || strings.Contains(value, "$ENV{") +} diff --git a/internal/codeguard/checks/support/supply_chain_conan_python.go b/internal/codeguard/checks/support/supply_chain_conan_python.go new file mode 100644 index 0000000..09868af --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_conan_python.go @@ -0,0 +1,127 @@ +package support + +import ( + "fmt" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +type conanLiteral struct { + value string + line int +} + +func parseConanPythonManifest(root string, rel string, data []byte) core.SupplyChainManifest { + manifest := core.SupplyChainManifest{ + Ecosystem: "conan", + PackageManager: "conan", + Path: rel, + Lockfiles: presentLockfiles(root, rel, []string{"conan.lock"}), + } + tokens := scanPythonTokens(string(data)) + constants := make(map[string][]conanLiteral) + for idx := 0; idx < len(tokens); { + if values, name, scope, startLine, next, assignment := parseConanAssignment(tokens, idx, constants); assignment { + if values != nil { + constants[name] = values + if scope != "" { + appendConanLiterals(&manifest, values, scope, startLine) + } + } else if scope != "" { + manifest.AnalysisLimitations = append(manifest.AnalysisLimitations, + fmt.Sprintf("conanfile.py %s declaration at line %d is dynamic; CodeGuard did not execute Python to resolve it", name, startLine)) + } + idx = next + continue + } + if values, scope, startLine, next, call := parseConanRequiresCall(tokens, idx, constants); call { + if values != nil { + appendConanLiterals(&manifest, values, scope, startLine) + } else { + manifest.AnalysisLimitations = append(manifest.AnalysisLimitations, + fmt.Sprintf("conanfile.py self.%s() call at line %d is dynamic; CodeGuard did not execute Python to resolve it", scopeMethodName(scope), startLine)) + } + idx = next + continue + } + idx++ + } + sortDependencies(manifest.Dependencies) + manifest.AnalysisLimitations = uniqueSortedStrings(manifest.AnalysisLimitations) + return manifest +} + +func parseConanAssignment(tokens []pythonToken, idx int, constants map[string][]conanLiteral) ([]conanLiteral, string, string, int, int, bool) { + if idx+1 >= len(tokens) || tokens[idx].kind != 'i' || tokens[idx+1].value != "=" { + return nil, "", "", 0, idx + 1, false + } + name := tokens[idx].value + // Class-level Conan declarations and constants are normally at module or + // class indentation. Avoid treating method-local variables as manifests. + if tokens[idx].column > 5 { + return nil, "", "", 0, idx + 1, false + } + end := pythonExpressionEnd(tokens, idx+2) + values, ok := evaluateConanLiteralExpression(tokens[idx+2:end], constants) + scope := "" + switch name { + case "requires": + scope = "runtime" + case "tool_requires": + scope = "build" + } + if !ok { + return nil, name, scope, tokens[idx].line, max(end, idx+2), true + } + return values, name, scope, tokens[idx].line, max(end, idx+2), true +} + +func parseConanRequiresCall(tokens []pythonToken, idx int, constants map[string][]conanLiteral) ([]conanLiteral, string, int, int, bool) { + if idx+4 >= len(tokens) || tokens[idx].value != "self" || tokens[idx+1].value != "." || tokens[idx+2].kind != 'i' || tokens[idx+3].value != "(" { + return nil, "", 0, idx + 1, false + } + method := tokens[idx+2].value + scope := "" + switch method { + case "requires": + scope = "runtime" + case "tool_requires": + scope = "build" + default: + return nil, "", 0, idx + 1, false + } + closeIdx := matchingPythonDelimiter(tokens, idx+3) + if closeIdx < 0 { + return nil, scope, tokens[idx].line, len(tokens), true + } + arguments := tokens[idx+4 : closeIdx] + firstEnd := firstPythonCallArgumentEnd(arguments) + values, ok := evaluateConanLiteralExpression(arguments[:firstEnd], constants) + if !ok || len(values) != 1 { + return nil, scope, tokens[idx].line, closeIdx + 1, true + } + return values, scope, tokens[idx].line, closeIdx + 1, true +} + +func appendConanLiterals(manifest *core.SupplyChainManifest, values []conanLiteral, scope string, declarationLine int) { + section := "requires" + if scope == "build" { + section = "tool_requires" + } + for _, literal := range values { + line := literal.line + if line == 0 { + line = declarationLine + } + if dep, ok := parseConanReference(literal.value, section, line); ok { + manifest.Dependencies = append(manifest.Dependencies, dep) + } + } +} + +func scopeMethodName(scope string) string { + if scope == "build" { + return "tool_requires" + } + return "requires" +} diff --git a/internal/codeguard/checks/support/supply_chain_conan_python_expression.go b/internal/codeguard/checks/support/supply_chain_conan_python_expression.go new file mode 100644 index 0000000..1a65092 --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_conan_python_expression.go @@ -0,0 +1,143 @@ +package support + +func evaluateConanLiteralExpression(tokens []pythonToken, constants map[string][]conanLiteral) ([]conanLiteral, bool) { + for len(tokens) > 0 && tokens[0].kind == 'n' { + tokens = tokens[1:] + } + for len(tokens) > 0 && tokens[len(tokens)-1].kind == 'n' { + tokens = tokens[:len(tokens)-1] + } + if len(tokens) == 0 { + return nil, false + } + parser := conanExpressionParser{tokens: tokens, constants: constants} + values, ok := parser.parseSequence(0) + for parser.pos < len(tokens) && tokens[parser.pos].kind == 'n' { + parser.pos++ + } + return values, ok && parser.pos == len(tokens) +} + +type conanExpressionParser struct { + tokens []pythonToken + constants map[string][]conanLiteral + pos int +} + +func (parser *conanExpressionParser) parseSequence(closing byte) ([]conanLiteral, bool) { + values := make([]conanLiteral, 0) + for parser.pos < len(parser.tokens) { + parser.skipNewlines() + if closing != 0 && parser.pos < len(parser.tokens) && parser.tokens[parser.pos].value == string(closing) { + parser.pos++ + return values, true + } + atom, ok := parser.parseAtom() + if !ok { + return nil, false + } + values = append(values, atom...) + parser.skipNewlines() + if parser.pos < len(parser.tokens) && parser.tokens[parser.pos].value == "+" { + // Python's + is overloaded for strings and sequences. Without + // executing or type-checking the recipe, treating it as either can + // invent a dependency, so leave it explicitly unresolved. + return nil, false + } + if parser.pos < len(parser.tokens) && parser.tokens[parser.pos].value == "," { + parser.pos++ + continue + } + if closing == 0 { + return values, true + } + if parser.pos < len(parser.tokens) && parser.tokens[parser.pos].value == string(closing) { + parser.pos++ + return values, true + } + return nil, false + } + return values, closing == 0 +} + +func (parser *conanExpressionParser) parseAtom() ([]conanLiteral, bool) { + parser.skipNewlines() + if parser.pos >= len(parser.tokens) { + return nil, false + } + token := parser.tokens[parser.pos] + parser.pos++ + switch { + case token.kind == 's': + return []conanLiteral{{value: token.value, line: token.line}}, true + case token.kind == 'i': + values, ok := parser.constants[token.value] + if !ok { + return nil, false + } + return append([]conanLiteral(nil), values...), true + case token.value == "(": + return parser.parseSequence(')') + case token.value == "[": + return parser.parseSequence(']') + default: + return nil, false + } +} + +func (parser *conanExpressionParser) skipNewlines() { + for parser.pos < len(parser.tokens) && parser.tokens[parser.pos].kind == 'n' { + parser.pos++ + } +} + +func pythonExpressionEnd(tokens []pythonToken, start int) int { + depth := 0 + for idx := start; idx < len(tokens); idx++ { + switch tokens[idx].value { + case "(", "[", "{": + depth++ + case ")", "]", "}": + if depth > 0 { + depth-- + } + } + if tokens[idx].kind == 'n' && depth == 0 { + return idx + } + } + return len(tokens) +} + +func matchingPythonDelimiter(tokens []pythonToken, open int) int { + depth := 0 + for idx := open; idx < len(tokens); idx++ { + switch tokens[idx].value { + case "(": + depth++ + case ")": + depth-- + if depth == 0 { + return idx + } + } + } + return -1 +} + +func firstPythonCallArgumentEnd(tokens []pythonToken) int { + depth := 0 + for idx, token := range tokens { + switch token.value { + case "(", "[", "{": + depth++ + case ")", "]", "}": + depth-- + case ",": + if depth == 0 { + return idx + } + } + } + return len(tokens) +} diff --git a/internal/codeguard/checks/support/supply_chain_conan_python_scanner.go b/internal/codeguard/checks/support/supply_chain_conan_python_scanner.go new file mode 100644 index 0000000..89a6ac3 --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_conan_python_scanner.go @@ -0,0 +1,94 @@ +package support + +import ( + "unicode" +) + +type pythonToken struct { + kind byte + value string + line int + column int +} + +type pythonScanPosition struct { + offset int + line int + column int +} + +func scanPythonTokens(source string) []pythonToken { + tokens := make([]pythonToken, 0) + position := pythonScanPosition{line: 1, column: 1} + for position.offset < len(source) { + position = skipPythonTrivia(source, position) + if position.offset >= len(source) { + break + } + token, next := scanPythonTokenAt(source, position) + tokens = append(tokens, token) + position = next + } + return tokens +} + +func skipPythonTrivia(source string, position pythonScanPosition) pythonScanPosition { + for position.offset < len(source) { + char := source[position.offset] + if char == ' ' || char == '\t' || char == '\r' { + position.offset++ + position.column++ + continue + } + if char != '#' { + break + } + for position.offset < len(source) && source[position.offset] != '\n' { + position.offset++ + position.column++ + } + } + return position +} + +func scanPythonTokenAt(source string, position pythonScanPosition) (pythonToken, pythonScanPosition) { + char := source[position.offset] + if char == '\n' { + token := pythonToken{kind: 'n', value: "\n", line: position.line, column: position.column} + position.offset++ + position.line++ + position.column = 1 + return token, position + } + if isPythonIdentifierStart(char) { + return scanPythonIdentifierToken(source, position) + } + if char == '\'' || char == '"' { + return scanPythonStringToken(source, position, "") + } + token := pythonToken{kind: 'p', value: string(char), line: position.line, column: position.column} + position.offset++ + position.column++ + return token, position +} + +func scanPythonIdentifierToken(source string, position pythonScanPosition) (pythonToken, pythonScanPosition) { + start, tokenLine, tokenColumn := position.offset, position.line, position.column + for position.offset < len(source) && isPythonIdentifierPart(source[position.offset]) { + position.offset++ + position.column++ + } + identifier := source[start:position.offset] + if position.offset < len(source) && (source[position.offset] == '\'' || source[position.offset] == '"') && isPythonStringPrefix(identifier) { + return scanPythonStringToken(source, position, identifier) + } + return pythonToken{kind: 'i', value: identifier, line: tokenLine, column: tokenColumn}, position +} + +func isPythonIdentifierStart(char byte) bool { + return char == '_' || unicode.IsLetter(rune(char)) +} + +func isPythonIdentifierPart(char byte) bool { + return isPythonIdentifierStart(char) || unicode.IsDigit(rune(char)) +} diff --git a/internal/codeguard/checks/support/supply_chain_conan_python_strings.go b/internal/codeguard/checks/support/supply_chain_conan_python_strings.go new file mode 100644 index 0000000..6a05072 --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_conan_python_strings.go @@ -0,0 +1,94 @@ +package support + +import ( + "strconv" + "strings" +) + +func scanPythonStringToken(source string, position pythonScanPosition, prefix string) (pythonToken, pythonScanPosition) { + tokenLine, tokenColumn := position.line, position.column-len(prefix) + value, next, ok := scanPythonString(source, position) + kind := byte('s') + if !ok || strings.Contains(strings.ToLower(prefix), "f") { + kind = 'x' + value = prefix + value + } + return pythonToken{kind: kind, value: value, line: tokenLine, column: tokenColumn}, next +} + +func scanPythonString(source string, position pythonScanPosition) (string, pythonScanPosition, bool) { + quote := source[position.offset] + triple := position.offset+2 < len(source) && source[position.offset+1] == quote && source[position.offset+2] == quote + delimiterLength := 1 + if triple { + delimiterLength = 3 + } + start := position.offset + position.offset += delimiterLength + position.column += delimiterLength + for position.offset < len(source) { + if source[position.offset] == '\\' { + position = skipPythonEscape(source, position) + continue + } + if source[position.offset] == '\n' { + if !triple { + return source[start:position.offset], position, false + } + position.offset++ + position.line++ + position.column = 1 + continue + } + if pythonStringClosesAt(source, position.offset, quote, triple) { + return finishPythonString(source, start, position, delimiterLength, triple) + } + position.offset++ + position.column++ + } + return source[start:], position, false +} + +func skipPythonEscape(source string, position pythonScanPosition) pythonScanPosition { + advance := min(2, len(source)-position.offset) + if advance == 2 && source[position.offset+1] == '\n' { + position.line++ + position.column = 1 + } else { + position.column += advance + } + position.offset += advance + return position +} + +func pythonStringClosesAt(source string, offset int, quote byte, triple bool) bool { + if source[offset] != quote { + return false + } + return !triple || (offset+2 < len(source) && source[offset+1] == quote && source[offset+2] == quote) +} + +func finishPythonString(source string, start int, position pythonScanPosition, delimiterLength int, triple bool) (string, pythonScanPosition, bool) { + end := position.offset + delimiterLength + raw := source[start:end] + value, err := strconv.Unquote(raw) + if triple { + value = source[start+3 : position.offset] + err = nil + } + position.offset = end + position.column += delimiterLength + return value, position, err == nil +} + +func isPythonStringPrefix(value string) bool { + if len(value) > 2 { + return false + } + for _, char := range strings.ToLower(value) { + if !strings.ContainsRune("rubf", char) { + return false + } + } + return value != "" +} diff --git a/internal/codeguard/config/defaults_rules.go b/internal/codeguard/config/defaults_rules.go index 864cb0e..ffa01bb 100644 --- a/internal/codeguard/config/defaults_rules.go +++ b/internal/codeguard/config/defaults_rules.go @@ -1,6 +1,10 @@ package config -import "github.com/devr-tools/codeguard/internal/codeguard/core" +import ( + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) func applyQualityDefaults(dst *core.QualityRulesConfig, def core.QualityRulesConfig) { defaultInt(&dst.MaxFileLines, def.MaxFileLines) @@ -11,6 +15,24 @@ func applyQualityDefaults(dst *core.QualityRulesConfig, def core.QualityRulesCon defaultCommandMap(&dst.LanguageCommands, def.LanguageCommands) applyAIChangeRiskDefaults(&dst.AIChangeRisk, def.AIChangeRisk) applyCoverageDeltaDefaults(&dst.CoverageDelta) + applyCPPToolingDefaults(&dst.CPPTooling) +} + +func applyCPPToolingDefaults(dst *core.CPPToolingConfig) { + dst.ClangFormatMode = strings.ToLower(strings.TrimSpace(dst.ClangFormatMode)) + if dst.ClangFormatMode == "" { + dst.ClangFormatMode = core.ExternalToolModeOff + } + dst.CompilerMode = strings.ToLower(strings.TrimSpace(dst.CompilerMode)) + if dst.CompilerMode == "" { + dst.CompilerMode = core.ExternalToolModeOff + } + if strings.TrimSpace(dst.ClangFormatCommand) == "" { + dst.ClangFormatCommand = "clang-format" + } + if strings.TrimSpace(dst.CompilerCommand) == "" { + dst.CompilerCommand = "clang++" + } } func applyPerformanceDefaults(dst *core.PerformanceRulesConfig) { @@ -95,12 +117,7 @@ func applySecurityDefaults(dst *core.SecurityRulesConfig, def core.SecurityRules if dst.GovulncheckMode == "" { dst.GovulncheckMode = def.GovulncheckMode } - if dst.TaintGo == nil { - dst.TaintGo = boolPtr(true) - } - if dst.TaintPython == nil { - dst.TaintPython = boolPtr(true) - } + applyTaintDefaults(dst) if dst.DemoteFixtureFindings == nil { dst.DemoteFixtureFindings = boolPtr(true) } @@ -121,6 +138,12 @@ func applySecurityDefaults(dst *core.SecurityRulesConfig, def core.SecurityRules } } +func applyTaintDefaults(dst *core.SecurityRulesConfig) { + defaultBoolPtr(&dst.TaintGo, true) + defaultBoolPtr(&dst.TaintPython, true) + defaultBoolPtr(&dst.TaintCPP, true) +} + func applyAIChangeRiskDefaults(dst *core.AIChangeRiskConfig, def core.AIChangeRiskConfig) { defaultBoolPtr(&dst.Enabled, boolValueOrTrue(def.Enabled)) if dst.WarnThreshold == 0 { diff --git a/internal/codeguard/config/example_rules.go b/internal/codeguard/config/example_rules.go index 6d67409..4a6e25e 100644 --- a/internal/codeguard/config/example_rules.go +++ b/internal/codeguard/config/example_rules.go @@ -21,6 +21,12 @@ func exampleQualityRules() core.QualityRulesConfig { WarnThreshold: 30, FailThreshold: 60, }, + CPPTooling: core.CPPToolingConfig{ + ClangFormatMode: core.ExternalToolModeOff, + ClangFormatCommand: "clang-format", + CompilerMode: core.ExternalToolModeOff, + CompilerCommand: "clang++", + }, } } diff --git a/internal/codeguard/config/validate.go b/internal/codeguard/config/validate.go index 8199611..d048ef9 100644 --- a/internal/codeguard/config/validate.go +++ b/internal/codeguard/config/validate.go @@ -3,6 +3,7 @@ package config import ( "errors" "fmt" + "path/filepath" "strings" "time" @@ -24,6 +25,7 @@ func Validate(cfg core.Config) error { validateContractRules(cfg.Checks.ContractRules), validateContextRules(cfg.Checks.ContextRules), validateCoverageDelta(cfg.Checks.QualityRules.CoverageDelta), + validateCPPTooling(cfg.Checks.QualityRules.CPPTooling), validateGraphThresholds(cfg.Checks.DesignRules), validatePerformanceRules(cfg.Checks.PerformanceRules), validateSecretsRules(cfg.Checks.SecurityRules.Secrets), @@ -32,6 +34,24 @@ func Validate(cfg core.Config) error { ) } +func validateCPPTooling(cfg core.CPPToolingConfig) error { + for _, setting := range []struct{ field, mode string }{ + {"quality_rules.cpp_tooling.clang_format_mode", cfg.ClangFormatMode}, + {"quality_rules.cpp_tooling.compiler_mode", cfg.CompilerMode}, + } { + switch strings.ToLower(strings.TrimSpace(setting.mode)) { + case "", core.ExternalToolModeOff, core.ExternalToolModeAuto, core.ExternalToolModeRequired: + default: + return fmt.Errorf("%s must be off, auto, or required", setting.field) + } + } + compileCommands := filepath.Clean(filepath.FromSlash(strings.TrimSpace(cfg.CompileCommands))) + if filepath.IsAbs(compileCommands) || compileCommands == ".." || strings.HasPrefix(compileCommands, ".."+string(filepath.Separator)) { + return errors.New("quality_rules.cpp_tooling.compile_commands must be relative to the target") + } + return nil +} + func validateParsers(parsers core.ParsersConfig) error { switch strings.TrimSpace(strings.ToLower(parsers.TreeSitter)) { case "", core.TreeSitterModeOff, core.TreeSitterModeAuto: diff --git a/internal/codeguard/core/config_cpp_tooling.go b/internal/codeguard/core/config_cpp_tooling.go new file mode 100644 index 0000000..7c540aa --- /dev/null +++ b/internal/codeguard/core/config_cpp_tooling.go @@ -0,0 +1,20 @@ +package core + +// CPPToolingConfig controls optional clang-backed validation for targets that +// explicitly declare a C++ language. Tool modes are off, auto, or required. +// Auto skips an unavailable tool/database, while required reports an +// actionable failure. Command overrides remain subject to the config-command +// trust gate. +type CPPToolingConfig struct { + ClangFormatMode string `json:"clang_format_mode,omitempty" yaml:"clang_format_mode,omitempty"` + ClangFormatCommand string `json:"clang_format_command,omitempty" yaml:"clang_format_command,omitempty"` + CompilerMode string `json:"compiler_mode,omitempty" yaml:"compiler_mode,omitempty"` + CompilerCommand string `json:"compiler_command,omitempty" yaml:"compiler_command,omitempty"` + CompileCommands string `json:"compile_commands,omitempty" yaml:"compile_commands,omitempty"` +} + +const ( + ExternalToolModeOff = "off" + ExternalToolModeAuto = "auto" + ExternalToolModeRequired = "required" +) diff --git a/internal/codeguard/core/config_rule_types.go b/internal/codeguard/core/config_rule_types.go index df169c1..df5a139 100644 --- a/internal/codeguard/core/config_rule_types.go +++ b/internal/codeguard/core/config_rule_types.go @@ -11,6 +11,7 @@ type QualityRulesConfig struct { AIChangeRisk AIChangeRiskConfig `json:"ai_change_risk,omitempty" yaml:"ai_change_risk,omitempty"` AIChecks AIChecksConfig `json:"ai_checks,omitempty" yaml:"ai_checks,omitempty"` CoverageDelta CoverageDeltaConfig `json:"coverage_delta,omitempty" yaml:"coverage_delta,omitempty"` + CPPTooling CPPToolingConfig `json:"cpp_tooling,omitempty" yaml:"cpp_tooling,omitempty"` } // PerformanceRulesConfig tunes the performance section (checks.performance). @@ -158,6 +159,7 @@ type SecurityRulesConfig struct { GovulncheckCommand string `json:"govulncheck_command,omitempty" yaml:"govulncheck_command,omitempty"` TaintGo *bool `json:"taint_go,omitempty" yaml:"taint_go,omitempty"` TaintPython *bool `json:"taint_python,omitempty" yaml:"taint_python,omitempty"` + TaintCPP *bool `json:"taint_cpp,omitempty" yaml:"taint_cpp,omitempty"` TypeScriptTaintMaxDepth int `json:"typescript_taint_max_depth,omitempty" yaml:"typescript_taint_max_depth,omitempty"` LanguageCommands map[string][]CommandCheckConfig `json:"language_commands,omitempty" yaml:"language_commands,omitempty"` Secrets *SecretsRulesConfig `json:"secrets,omitempty" yaml:"secrets,omitempty"` diff --git a/internal/codeguard/core/report_artifact_types.go b/internal/codeguard/core/report_artifact_types.go index e0bc5ba..6a59a23 100644 --- a/internal/codeguard/core/report_artifact_types.go +++ b/internal/codeguard/core/report_artifact_types.go @@ -48,6 +48,9 @@ type SupplyChainManifest struct { PackageManager string `json:"package_manager,omitempty"` Lockfiles []string `json:"lockfiles,omitempty"` Dependencies []SupplyChainDependency `json:"dependencies,omitempty"` + // AnalysisLimitations records dependency declarations that were intentionally + // not evaluated because resolving them would require executing project code. + AnalysisLimitations []string `json:"analysis_limitations,omitempty"` } type SupplyChainDependency struct { diff --git a/internal/codeguard/cpp/compdb/arguments.go b/internal/codeguard/cpp/compdb/arguments.go new file mode 100644 index 0000000..8d30e41 --- /dev/null +++ b/internal/codeguard/cpp/compdb/arguments.go @@ -0,0 +1,79 @@ +package compdb + +import ( + "errors" + "strings" +) + +type commandLexer struct { + arguments []string + current strings.Builder + quote rune + escaped bool + started bool +} + +// splitCommandLine tokenizes the command form for metadata extraction only. +// Its result is never used to select or execute the database-provided program. +func splitCommandLine(command string) ([]string, error) { + lexer := commandLexer{} + for _, character := range command { + lexer.consume(character) + } + if lexer.escaped || lexer.quote != 0 { + return nil, errors.New("unterminated quote or escape in compilation command") + } + lexer.flush() + return lexer.arguments, nil +} + +func (lexer *commandLexer) consume(character rune) { + if lexer.consumeEscaped(character) || lexer.consumeQuoted(character) { + return + } + switch character { + case '\\': + lexer.escaped, lexer.started = true, true + case '\'', '"': + lexer.quote, lexer.started = character, true + case ' ', '\t', '\r', '\n': + lexer.flush() + default: + lexer.current.WriteRune(character) + lexer.started = true + } +} + +func (lexer *commandLexer) consumeEscaped(character rune) bool { + if !lexer.escaped { + return false + } + lexer.current.WriteRune(character) + lexer.escaped, lexer.started = false, true + return true +} + +func (lexer *commandLexer) consumeQuoted(character rune) bool { + if lexer.quote == 0 { + return false + } + switch { + case character == lexer.quote: + lexer.quote = 0 + case character == '\\' && lexer.quote != '\'': + lexer.escaped = true + default: + lexer.current.WriteRune(character) + } + lexer.started = true + return true +} + +func (lexer *commandLexer) flush() { + if !lexer.started { + return + } + lexer.arguments = append(lexer.arguments, lexer.current.String()) + lexer.current.Reset() + lexer.started = false +} diff --git a/internal/codeguard/cpp/compdb/compdb.go b/internal/codeguard/cpp/compdb/compdb.go new file mode 100644 index 0000000..18a037b --- /dev/null +++ b/internal/codeguard/cpp/compdb/compdb.go @@ -0,0 +1,33 @@ +// Package compdb reads C++ JSON compilation databases without executing any +// command text contained in them. +package compdb + +import "errors" + +const maxDatabaseBytes = 16 << 20 + +var ErrNotFound = errors.New("compile_commands.json not found") + +type Database struct { + Path string + Root string + Entries []Entry +} + +type Entry struct { + Directory string + File string + RelativeFile string + Compiler string + IncludeDirs []string + Defines []string + Undefines []string + Standard string +} + +type rawEntry struct { + Directory string `json:"directory"` + File string `json:"file"` + Arguments []string `json:"arguments"` + Command string `json:"command"` +} diff --git a/internal/codeguard/cpp/compdb/discovery.go b/internal/codeguard/cpp/compdb/discovery.go new file mode 100644 index 0000000..8ce852b --- /dev/null +++ b/internal/codeguard/cpp/compdb/discovery.go @@ -0,0 +1,67 @@ +package compdb + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +var conventionalDatabasePaths = []string{ + "compile_commands.json", + "build/compile_commands.json", + "cmake-build-debug/compile_commands.json", + "cmake-build-release/compile_commands.json", +} + +// Find resolves an explicitly configured database or a conventional CMake +// location. A configured relative path must remain beneath root. +func Find(root, configured string) (string, error) { + root, err := canonicalRoot(root) + if err != nil { + return "", err + } + if configured != "" { + return findConfigured(root, configured) + } + return findConventional(root) +} + +func findConfigured(root, configured string) (string, error) { + if filepath.IsAbs(configured) { + return "", fmt.Errorf("compile_commands path must be relative to the C++ target") + } + candidate := filepath.Clean(filepath.Join(root, filepath.FromSlash(configured))) + if !within(root, candidate) { + return "", fmt.Errorf("compile_commands path escapes the C++ target") + } + if _, err := os.Stat(candidate); err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("%w at %s", ErrNotFound, filepath.ToSlash(configured)) + } + return "", err + } + return containedDatabasePath(root, candidate) +} + +func findConventional(root string) (string, error) { + for _, rel := range conventionalDatabasePaths { + candidate := filepath.Join(root, filepath.FromSlash(rel)) + info, err := os.Stat(candidate) + if err != nil || info.IsDir() { + continue + } + if resolved, err := containedDatabasePath(root, candidate); err == nil { + return resolved, nil + } + } + return "", ErrNotFound +} + +func containedDatabasePath(root, candidate string) (string, error) { + resolved, err := filepath.EvalSymlinks(candidate) + if err != nil || !within(root, resolved) { + return "", fmt.Errorf("compile_commands path resolves outside the C++ target") + } + return resolved, nil +} diff --git a/internal/codeguard/cpp/compdb/entry.go b/internal/codeguard/cpp/compdb/entry.go new file mode 100644 index 0000000..8270237 --- /dev/null +++ b/internal/codeguard/cpp/compdb/entry.go @@ -0,0 +1,75 @@ +package compdb + +import ( + "path/filepath" + "strings" +) + +func normalizeEntry(root, databaseDir string, raw rawEntry) (Entry, bool) { + directory, ok := normalizeDirectory(databaseDir, raw.Directory) + if !ok { + return Entry{}, false + } + file, relative, ok := normalizeSource(root, directory, raw.File) + if !ok { + return Entry{}, false + } + arguments, ok := entryArguments(raw) + if !ok { + return Entry{}, false + } + entry := Entry{Directory: directory, File: file, RelativeFile: relative} + entry.extractMetadata(root, arguments) + return entry, true +} + +func normalizeDirectory(databaseDir, value string) (string, bool) { + directory := strings.TrimSpace(value) + if directory == "" { + directory = databaseDir + } else if !filepath.IsAbs(directory) { + directory = filepath.Join(databaseDir, filepath.FromSlash(directory)) + } + directory, err := filepath.Abs(filepath.Clean(directory)) + if err != nil { + return "", false + } + if resolved, err := filepath.EvalSymlinks(directory); err == nil { + directory = resolved + } + return directory, true +} + +func normalizeSource(root, directory, value string) (string, string, bool) { + file := strings.TrimSpace(value) + if file == "" { + return "", "", false + } + if !filepath.IsAbs(file) { + file = filepath.Join(directory, filepath.FromSlash(file)) + } + file, err := filepath.Abs(filepath.Clean(file)) + if err != nil { + return "", "", false + } + file, err = filepath.EvalSymlinks(file) + if err != nil || !within(root, file) { + return "", "", false + } + relative, err := filepath.Rel(root, file) + if err != nil || relative == "." { + return "", "", false + } + return file, filepath.ToSlash(relative), true +} + +func entryArguments(raw rawEntry) ([]string, bool) { + if len(raw.Arguments) > 0 { + return append([]string(nil), raw.Arguments...), true + } + if strings.TrimSpace(raw.Command) == "" { + return nil, true + } + arguments, err := splitCommandLine(raw.Command) + return arguments, err == nil +} diff --git a/internal/codeguard/cpp/compdb/load.go b/internal/codeguard/cpp/compdb/load.go new file mode 100644 index 0000000..1b2e6fc --- /dev/null +++ b/internal/codeguard/cpp/compdb/load.go @@ -0,0 +1,71 @@ +package compdb + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +func Load(root, configured string) (*Database, error) { + root, err := canonicalRoot(root) + if err != nil { + return nil, err + } + path, err := Find(root, strings.TrimSpace(configured)) + if err != nil { + return nil, err + } + raw, err := readRawEntries(path) + if err != nil { + return nil, err + } + return buildDatabase(root, path, raw), nil +} + +func readRawEntries(path string) ([]rawEntry, error) { + // #nosec G304 -- Find canonicalizes the path and confines it to root. + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + info, err := file.Stat() + if err != nil { + return nil, err + } + if info.Size() > maxDatabaseBytes { + return nil, fmt.Errorf("compile_commands.json exceeds %d bytes", maxDatabaseBytes) + } + return decodeRawEntries(io.LimitReader(file, maxDatabaseBytes+1)) +} + +func decodeRawEntries(reader io.Reader) ([]rawEntry, error) { + var raw []rawEntry + decoder := json.NewDecoder(reader) + if err := decoder.Decode(&raw); err != nil { + return nil, fmt.Errorf("parse compile_commands.json: %w", err) + } + var trailing any + err := decoder.Decode(&trailing) + if errors.Is(err, io.EOF) { + return raw, nil + } + if err == nil { + return nil, fmt.Errorf("parse compile_commands.json: unexpected trailing JSON value") + } + return nil, fmt.Errorf("parse compile_commands.json: %w", err) +} + +func buildDatabase(root, databasePath string, raw []rawEntry) *Database { + db := &Database{Path: databasePath, Root: root, Entries: make([]Entry, 0, len(raw))} + for _, item := range raw { + if entry, ok := normalizeEntry(root, filepath.Dir(databasePath), item); ok { + db.Entries = append(db.Entries, entry) + } + } + return db +} diff --git a/internal/codeguard/cpp/compdb/metadata.go b/internal/codeguard/cpp/compdb/metadata.go new file mode 100644 index 0000000..05e1e64 --- /dev/null +++ b/internal/codeguard/cpp/compdb/metadata.go @@ -0,0 +1,94 @@ +package compdb + +import ( + "path/filepath" + "strings" +) + +type metadataKind uint8 + +const ( + metadataUnknown metadataKind = iota + metadataInclude + metadataDefine + metadataUndefine + metadataStandard +) + +type metadataPrefix struct { + prefix string + kind metadataKind +} + +var separatedMetadata = map[string]metadataKind{ + "-I": metadataInclude, "-isystem": metadataInclude, "-iquote": metadataInclude, "/I": metadataInclude, + "-D": metadataDefine, "/D": metadataDefine, + "-U": metadataUndefine, "/U": metadataUndefine, +} + +var prefixedMetadata = []metadataPrefix{ + {"-isystem", metadataInclude}, {"-iquote", metadataInclude}, + {"-std=", metadataStandard}, {"/std:", metadataStandard}, + {"-I", metadataInclude}, {"/I", metadataInclude}, + {"-D", metadataDefine}, {"/D", metadataDefine}, + {"-U", metadataUndefine}, {"/U", metadataUndefine}, +} + +func (entry *Entry) extractMetadata(root string, arguments []string) { + entry.Compiler = compilerFromArguments(arguments) + for index := 1; index < len(arguments); index++ { + kind, value, separated := classifyMetadata(arguments[index]) + if separated && index+1 >= len(arguments) { + continue + } + if separated { + index++ + value = arguments[index] + } + entry.applyMetadata(root, kind, value) + } +} + +func compilerFromArguments(arguments []string) string { + if len(arguments) == 0 { + return "" + } + if len(arguments) > 1 && isCompilerWrapper(arguments[0]) { + return arguments[1] + } + return arguments[0] +} + +func classifyMetadata(argument string) (metadataKind, string, bool) { + if kind, ok := separatedMetadata[argument]; ok { + return kind, "", true + } + for _, spec := range prefixedMetadata { + if strings.HasPrefix(argument, spec.prefix) && len(argument) > len(spec.prefix) { + return spec.kind, argument[len(spec.prefix):], false + } + } + return metadataUnknown, "", false +} + +func (entry *Entry) applyMetadata(root string, kind metadataKind, value string) { + switch kind { + case metadataInclude: + entry.addInclude(root, value) + case metadataDefine: + entry.Defines = append(entry.Defines, value) + case metadataUndefine: + entry.Undefines = append(entry.Undefines, value) + case metadataStandard: + entry.Standard = value + } +} + +func isCompilerWrapper(command string) bool { + switch strings.ToLower(filepath.Base(command)) { + case "ccache", "sccache", "distcc", "icecc": + return true + default: + return false + } +} diff --git a/internal/codeguard/cpp/compdb/paths.go b/internal/codeguard/cpp/compdb/paths.go new file mode 100644 index 0000000..c4f0fa5 --- /dev/null +++ b/internal/codeguard/cpp/compdb/paths.go @@ -0,0 +1,43 @@ +package compdb + +import ( + "path/filepath" + "strings" +) + +func (entry *Entry) addInclude(root, value string) { + value = strings.TrimSpace(value) + if value == "" { + return + } + if !filepath.IsAbs(value) { + value = filepath.Join(entry.Directory, filepath.FromSlash(value)) + } + value, err := filepath.Abs(filepath.Clean(value)) + if err != nil || !within(root, value) { + return + } + value, err = filepath.EvalSymlinks(value) + if err != nil || !within(root, value) { + return + } + for _, existing := range entry.IncludeDirs { + if existing == value { + return + } + } + entry.IncludeDirs = append(entry.IncludeDirs, value) +} + +func within(root, candidate string) bool { + relative, err := filepath.Rel(root, candidate) + return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) +} + +func canonicalRoot(root string) (string, error) { + root, err := filepath.Abs(root) + if err != nil { + return "", err + } + return filepath.EvalSymlinks(root) +} diff --git a/internal/codeguard/rules/catalog_fix_templates_quality.go b/internal/codeguard/rules/catalog_fix_templates_quality.go index 47ad0dd..0340df0 100644 --- a/internal/codeguard/rules/catalog_fix_templates_quality.go +++ b/internal/codeguard/rules/catalog_fix_templates_quality.go @@ -6,6 +6,8 @@ import "github.com/devr-tools/codeguard/internal/codeguard/core" // TypeScript/JavaScript suppression mirrors. The performance heuristics live // in catalog_fix_templates_performance.go. var qualityFixTemplates = map[string]core.FixTemplate{ + "quality.cpp.clang-format": {Kind: deterministic, Text: "Run clang-format -i on the reported C++ file and commit the result. If the formatter is required but unavailable, install clang-format or configure quality_rules.cpp_tooling.clang_format_command for a trusted repository."}, + "quality.cpp.compiler-parse": {Kind: guided, Text: "Generate compile_commands.json (for CMake, configure with CMAKE_EXPORT_COMPILE_COMMANDS=ON), then fix the first syntax diagnostic reported by clang++. Codeguard deliberately ignores compiler executables, plugins, response files, and output flags stored in the database."}, "quality.gofmt": {Kind: deterministic, Text: "Run gofmt -w on the file and commit the formatted result.\n\nBefore:\nfunc main(){fmt.Println(\"hi\")}\n\nAfter:\nfunc main() {\n\tfmt.Println(\"hi\")\n}"}, "quality.parse-error": {Kind: guided, Text: "Fix the syntax error the parser reports so the file parses cleanly.\n\nBefore:\nfunc main() {\n\tfmt.Println(\"hi\")\n// missing closing brace\n\nAfter:\nfunc main() {\n\tfmt.Println(\"hi\")\n}"}, "quality.max-file-lines": {Kind: guided, Text: "Split the file into smaller files with one responsibility each.\n\nBefore:\n// handlers.go: routing, validation, persistence, and rendering in one 900-line file\n\nAfter:\n// handlers.go: HTTP routing only\n// validate.go: input validation\n// store.go: persistence\n// render.go: response rendering"}, diff --git a/internal/codeguard/rules/catalog_fix_templates_security.go b/internal/codeguard/rules/catalog_fix_templates_security.go index 9211f80..f873e14 100644 --- a/internal/codeguard/rules/catalog_fix_templates_security.go +++ b/internal/codeguard/rules/catalog_fix_templates_security.go @@ -27,4 +27,6 @@ var securityFixTemplates = map[string]core.FixTemplate{ "security.taint.python": {Kind: guided, Text: "Break the source-to-sink chain: pass an argv list, parameterized query, or parsed value instead of interpolated input.\n\nBefore:\nos.system(\"ping \" + hostname)\n\nAfter:\nsubprocess.run([\"ping\", \"-c\", \"1\", hostname], check=True) # argv list, no shell\n# for SQL: cursor.execute(\"SELECT * FROM users WHERE name = %s\", (name,))"}, "security.ssrf.go": {Kind: guided, Text: "Validate the destination against an allowlist of trusted hosts before issuing the request.\n\nBefore:\nresp, err := http.Get(r.URL.Query().Get(\"url\"))\n\nAfter:\ntarget, err := url.Parse(r.URL.Query().Get(\"url\"))\nif err != nil || !allowedHosts[target.Hostname()] {\n\thttp.Error(w, \"untrusted destination\", http.StatusBadRequest)\n\treturn\n}\nresp, err := http.Get(target.String()) // also block private and link-local addresses"}, "security.ssrf.python": {Kind: guided, Text: "Validate the destination against an allowlist of trusted hosts before issuing the request.\n\nBefore:\nresp = requests.get(request.args[\"url\"])\n\nAfter:\ntarget = urllib.parse.urlparse(request.args[\"url\"])\nif target.hostname not in ALLOWED_HOSTS:\n abort(400, \"untrusted destination\")\nresp = requests.get(target.geturl()) # also block private and link-local addresses"}, + "security.taint.cpp": {Kind: guided, Text: "Keep untrusted text out of a shell command. Use a fixed executable and pass validated arguments through a process API that does not invoke a shell.\n\nBefore:\nauto command = std::getenv(\"COMMAND\");\nstd::system(command);\n\nAfter:\n// map an allowlisted action to a fixed executable and argv\nprocess::run(\"convert\", {validated_file});"}, + "security.ssrf.cpp": {Kind: guided, Text: "Parse the destination, require an allowlisted scheme and host, and reject private, loopback, and link-local addresses before the request.\n\nBefore:\ncurl_easy_setopt(curl, CURLOPT_URL, user_url.c_str());\n\nAfter:\nauto target = allowlisted_url(user_url);\ncurl_easy_setopt(curl, CURLOPT_URL, target.c_str());"}, } diff --git a/internal/codeguard/rules/catalog_quality.go b/internal/codeguard/rules/catalog_quality.go index 19e53e7..f89254e 100644 --- a/internal/codeguard/rules/catalog_quality.go +++ b/internal/codeguard/rules/catalog_quality.go @@ -3,6 +3,26 @@ package rules import "github.com/devr-tools/codeguard/internal/codeguard/core" var qualityCatalog = map[string]core.RuleMetadata{ + "quality.cpp.clang-format": { + ID: "quality.cpp.clang-format", + Section: "Code Quality", + DefaultLevel: "fail", + ExecutionModel: core.RuleExecutionModelCommandDriven, + LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageCPP), + Title: "C++ clang-format validation", + Description: "Fails when an explicitly configured C++ target is not clang-format clean or the required formatter cannot run.", + HowToFix: "Run clang-format on the reported file, or install/configure clang-format when the integration is required.", + }, + "quality.cpp.compiler-parse": { + ID: "quality.cpp.compiler-parse", + Section: "Code Quality", + DefaultLevel: "fail", + ExecutionModel: core.RuleExecutionModelCommandDriven, + LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageCPP), + Title: "C++ compiler syntax validation", + Description: "Validates target-local compilation database entries with a sanitized clang++ syntax-only invocation.", + HowToFix: "Fix the reported compiler diagnostic and ensure compile_commands.json is available when compiler validation is required.", + }, "quality.gofmt": { ID: "quality.gofmt", Section: "Code Quality", diff --git a/internal/codeguard/rules/catalog_security_owasp.go b/internal/codeguard/rules/catalog_security_owasp.go index 6c60044..bc8e84c 100644 --- a/internal/codeguard/rules/catalog_security_owasp.go +++ b/internal/codeguard/rules/catalog_security_owasp.go @@ -56,6 +56,7 @@ var securityRuleOWASP = map[string]core.OWASPCategory{ "security.javascript.untrusted-input-flow": core.OWASPA03Injection, "security.taint.go": core.OWASPA03Injection, "security.taint.python": core.OWASPA03Injection, + "security.taint.cpp": core.OWASPA03Injection, // Cross-origin message access control. "security.typescript.postmessage-wildcard": core.OWASPA01BrokenAccessControl, @@ -84,6 +85,7 @@ var securityRuleOWASP = map[string]core.OWASPCategory{ // Server-side request forgery (A10). "security.ssrf.go": core.OWASPA10SSRF, "security.ssrf.python": core.OWASPA10SSRF, + "security.ssrf.cpp": core.OWASPA10SSRF, } // withSecurityOWASP returns a copy of catalog with OWASP Top 10 categories diff --git a/internal/codeguard/rules/catalog_security_taint.go b/internal/codeguard/rules/catalog_security_taint.go index ff924f3..41b0658 100644 --- a/internal/codeguard/rules/catalog_security_taint.go +++ b/internal/codeguard/rules/catalog_security_taint.go @@ -48,4 +48,24 @@ var securityTaintCatalog = map[string]core.RuleMetadata{ Description: "Fails when untrusted input flows into the URL of an outbound HTTP request (requests.get/post/etc., urllib urlopen), letting an attacker make the server reach arbitrary or internal hosts. The finding message includes the source-to-sink chain.", HowToFix: "Validate the destination against an allowlist of trusted hosts and block private/link-local addresses before issuing the request.", }, + "security.taint.cpp": { + ID: "security.taint.cpp", + Section: "Security", + DefaultLevel: "fail", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageCPP), + Title: "C++ taint flow", + Description: "Fails when untrusted input from the environment, process arguments, standard input, or a recognized web request flows into a shell or process execution API. The finding message includes the source-to-sink chain.", + HowToFix: "Use a fixed executable and argument vector, or constrain the input with a strict allowlist before invoking the process API.", + }, + "security.ssrf.cpp": { + ID: "security.ssrf.cpp", + Section: "Security", + DefaultLevel: "fail", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageCPP), + Title: "C++ server-side request forgery", + Description: "Fails when untrusted input flows into an outbound URL or host in recognized libcurl, cpr, Boost.Asio, cpprestsdk, or Poco APIs.", + HowToFix: "Parse and validate destinations against a trusted-host allowlist and reject private, loopback, and link-local addresses before connecting.", + }, } diff --git a/internal/codeguard/runner/checks/checks.go b/internal/codeguard/runner/checks/checks.go index df59c06..ed845de 100644 --- a/internal/codeguard/runner/checks/checks.go +++ b/internal/codeguard/runner/checks/checks.go @@ -10,7 +10,6 @@ import ( checkSupport "github.com/devr-tools/codeguard/internal/codeguard/checks/support" "github.com/devr-tools/codeguard/internal/codeguard/core" - govulncheckrunner "github.com/devr-tools/codeguard/internal/codeguard/runner/govulncheck" runnersupport "github.com/devr-tools/codeguard/internal/codeguard/runner/support" ) @@ -200,9 +199,9 @@ func buildCheckContext(ctx context.Context, sc runnersupport.Context) checkSuppo IsPromptFile: func(rel string) bool { return runnersupport.IsPromptFile(sc, rel) }, - RunGovulncheck: func(ctx context.Context, dir string, cmdName string) ([]core.Finding, error) { - return govulncheckrunner.Run(ctx, dir, cmdName, sc) - }, + RunGovulncheck: govulncheckCallback(sc), + RunCPPFormat: runCPPFormat, + RunCPPSyntax: runCPPSyntax, RunCommandCheck: runnersupport.RunCommandCheck, RunCommandCheckWithEnv: runnersupport.RunCommandCheckWithEnv, RunDiffCommandCheck: func(ctx context.Context, dir string, baseRef string, check core.CommandCheckConfig) (string, error) { diff --git a/internal/codeguard/runner/checks/cpp_tooling.go b/internal/codeguard/runner/checks/cpp_tooling.go new file mode 100644 index 0000000..92bb511 --- /dev/null +++ b/internal/codeguard/runner/checks/cpp_tooling.go @@ -0,0 +1,31 @@ +package checks + +import ( + "context" + "errors" + + checksupport "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" + "github.com/devr-tools/codeguard/internal/codeguard/cpp/compdb" + cpptoolingrunner "github.com/devr-tools/codeguard/internal/codeguard/runner/cpptooling" +) + +func runCPPFormat(ctx context.Context, dir string, cfg core.CPPToolingConfig, files []string) checksupport.CPPToolResult { + issues, err := cpptoolingrunner.CheckFormat(ctx, dir, cfg, files) + return cppToolResult(issues, err) +} + +func runCPPSyntax(ctx context.Context, dir string, cfg core.CPPToolingConfig) checksupport.CPPToolResult { + issues, err := cpptoolingrunner.CheckSyntax(ctx, dir, cfg) + return cppToolResult(issues, err) +} + +func cppToolResult(issues []cpptoolingrunner.Issue, err error) checksupport.CPPToolResult { + result := checksupport.CPPToolResult{Err: err} + result.Unavailable = errors.Is(err, cpptoolingrunner.ErrToolUnavailable) || errors.Is(err, compdb.ErrNotFound) + result.Issues = make([]checksupport.CPPToolIssue, 0, len(issues)) + for _, issue := range issues { + result.Issues = append(result.Issues, checksupport.CPPToolIssue{Path: issue.Path, Message: issue.Message}) + } + return result +} diff --git a/internal/codeguard/runner/checks/govulncheck_callback.go b/internal/codeguard/runner/checks/govulncheck_callback.go new file mode 100644 index 0000000..ae62017 --- /dev/null +++ b/internal/codeguard/runner/checks/govulncheck_callback.go @@ -0,0 +1,15 @@ +package checks + +import ( + "context" + + "github.com/devr-tools/codeguard/internal/codeguard/core" + govulncheckrunner "github.com/devr-tools/codeguard/internal/codeguard/runner/govulncheck" + runnersupport "github.com/devr-tools/codeguard/internal/codeguard/runner/support" +) + +func govulncheckCallback(sc runnersupport.Context) func(context.Context, string, string) ([]core.Finding, error) { + return func(ctx context.Context, dir, command string) ([]core.Finding, error) { + return govulncheckrunner.Run(ctx, dir, command, sc) + } +} diff --git a/internal/codeguard/runner/cpptooling/cpptooling.go b/internal/codeguard/runner/cpptooling/cpptooling.go new file mode 100644 index 0000000..3e22c9b --- /dev/null +++ b/internal/codeguard/runner/cpptooling/cpptooling.go @@ -0,0 +1,156 @@ +package cpptooling + +import ( + "context" + "errors" + "fmt" + "os/exec" + "path/filepath" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" + "github.com/devr-tools/codeguard/internal/codeguard/cpp/compdb" + runnersupport "github.com/devr-tools/codeguard/internal/codeguard/runner/support" + "github.com/devr-tools/codeguard/internal/codeguard/trust" +) + +const ( + defaultClangFormat = "clang-format" + defaultCompiler = "clang++" + maxToolOutputBytes = 8 << 20 +) + +var ErrToolUnavailable = errors.New("c++ tool is unavailable") + +type Issue struct { + Path string + Message string +} + +func CheckFormat(ctx context.Context, root string, cfg core.CPPToolingConfig, files []string) ([]Issue, error) { + command, err := resolveCommand(root, cfg.ClangFormatCommand, defaultClangFormat, "quality_rules.cpp_tooling.clang_format_command") + if err != nil { + return nil, err + } + issues := make([]Issue, 0) + for _, file := range files { + if !safeRelative(file) { + continue + } + output, runErr := runnersupport.RunLimitedCommand(ctx, root, maxToolOutputBytes, command, "--dry-run", "--Werror", "--", filepath.FromSlash(file)) + if runErr != nil { + var exitErr *exec.ExitError + if !errors.As(runErr, &exitErr) { + return issues, fmt.Errorf("clang-format integration failed for %s: %w", filepath.ToSlash(file), runErr) + } + issues = append(issues, Issue{Path: filepath.ToSlash(file), Message: toolMessage("clang-format validation failed", output, runErr)}) + } + } + return issues, nil +} + +func CheckSyntax(ctx context.Context, root string, cfg core.CPPToolingConfig) ([]Issue, error) { + command, err := resolveCommand(root, cfg.CompilerCommand, defaultCompiler, "quality_rules.cpp_tooling.compiler_command") + if err != nil { + return nil, err + } + db, err := compdb.Load(root, cfg.CompileCommands) + if err != nil { + return nil, err + } + if len(db.Entries) == 0 { + return nil, fmt.Errorf("compile_commands.json contains no target-local compilation entries") + } + issues := make([]Issue, 0) + checked := 0 + seen := make(map[string]bool) + for _, entry := range db.Entries { + if !isCPPSource(entry.RelativeFile) || seen[entry.RelativeFile] { + continue + } + seen[entry.RelativeFile] = true + checked++ + args := safeSyntaxArgs(entry) + output, runErr := runnersupport.RunLimitedCommand(ctx, root, maxToolOutputBytes, command, args...) + if runErr != nil { + var exitErr *exec.ExitError + if !errors.As(runErr, &exitErr) { + return issues, fmt.Errorf("c++ compiler integration failed for %s: %w", entry.RelativeFile, runErr) + } + issues = append(issues, Issue{Path: entry.RelativeFile, Message: toolMessage("C++ compiler syntax validation failed", output, runErr)}) + } + } + if checked == 0 { + return nil, fmt.Errorf("compile_commands.json contains no target-local C++ source entries") + } + return issues, nil +} + +func isCPPSource(path string) bool { + ext := filepath.Ext(path) + if ext == ".C" { + return true + } + switch strings.ToLower(ext) { + case ".cc", ".cp", ".cpp", ".cxx", ".c++", ".ixx", ".cppm", ".cxxm", ".ccm", ".c++m", ".mpp", ".mxx", ".ii": + return true + default: + return false + } +} + +func safeSyntaxArgs(entry compdb.Entry) []string { + args := []string{"-fsyntax-only"} + if entry.Standard != "" { + args = append(args, "-std="+entry.Standard) + } + for _, include := range entry.IncludeDirs { + args = append(args, "-I"+include) + } + for _, define := range entry.Defines { + args = append(args, "-D"+define) + } + for _, undefine := range entry.Undefines { + args = append(args, "-U"+undefine) + } + // The compiler executable and all other flags in compile_commands.json are + // intentionally discarded. In particular, plugins, response files, output + // paths, wrappers, and driver escape hatches can never reach the subprocess. + return append(args, "--", entry.File) +} + +func resolveCommand(root, configured, builtIn, field string) (string, error) { + command := strings.TrimSpace(configured) + if command == "" { + command = builtIn + } + if command != builtIn { + if err := trust.GuardConfigCommand(field, command); err != nil { + return "", err + } + if strings.ContainsRune(command, filepath.Separator) && !filepath.IsAbs(command) { + command = filepath.Join(root, command) + } + } + resolved, err := exec.LookPath(command) + if err != nil { + return "", fmt.Errorf("%w: %s was not found on PATH", ErrToolUnavailable, command) + } + return resolved, nil +} + +func safeRelative(path string) bool { + path = filepath.Clean(filepath.FromSlash(path)) + return path != "." && !filepath.IsAbs(path) && path != ".." && !strings.HasPrefix(path, ".."+string(filepath.Separator)) +} + +func toolMessage(prefix, output string, err error) string { + detail := strings.TrimSpace(output) + if detail == "" { + detail = err.Error() + } + if len(detail) > 2000 { + detail = detail[:2000] + "..." + } + return prefix + ": " + detail +} diff --git a/pkg/codeguard/sdk_types_config_checks.go b/pkg/codeguard/sdk_types_config_checks.go index af90074..6fe2319 100644 --- a/pkg/codeguard/sdk_types_config_checks.go +++ b/pkg/codeguard/sdk_types_config_checks.go @@ -3,11 +3,19 @@ package codeguard import "github.com/devr-tools/codeguard/internal/codeguard/core" type QualityRulesConfig = core.QualityRulesConfig +type CPPToolingConfig = core.CPPToolingConfig type DesignRulesConfig = core.DesignRulesConfig type PromptRulesConfig = core.PromptRulesConfig type CIRulesConfig = core.CIRulesConfig type SupplyChainRulesConfig = core.SupplyChainRulesConfig type ContractRulesConfig = core.ContractRulesConfig type ContextRulesConfig = core.ContextRulesConfig + +const ( + ExternalToolModeOff = core.ExternalToolModeOff + ExternalToolModeAuto = core.ExternalToolModeAuto + ExternalToolModeRequired = core.ExternalToolModeRequired +) + type WorkflowRuleConfig = core.WorkflowRuleConfig type CommandCheckConfig = core.CommandCheckConfig diff --git a/tests/checks/cpp_tooling_test.go b/tests/checks/cpp_tooling_test.go new file mode 100644 index 0000000..47beeaa --- /dev/null +++ b/tests/checks/cpp_tooling_test.go @@ -0,0 +1,84 @@ +package checks_test + +import ( + "context" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func TestCPPToolingRunsFormatterAndSanitizedCompilerForExplicitTarget(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixture") + } + root := t.TempDir() + bin := t.TempDir() + writeFile(t, filepath.Join(root, "widget.cpp"), "int widget;\n") + writeFile(t, filepath.Join(root, "compile_commands.json"), `[{"directory":".","file":"widget.cpp","arguments":["untrusted-database-compiler","-std=c++20","-c","widget.cpp"]}]`) + writeExecutableFile(t, filepath.Join(bin, "clang-format"), "#!/bin/sh\necho 'widget.cpp: formatting differs'\nexit 1\n") + writeExecutableFile(t, filepath.Join(bin, "clang++"), "#!/bin/sh\necho 'widget.cpp:1:1: error: expected declaration'\nexit 1\n") + t.Setenv("PATH", bin) + cfg := qualityOnlyConfig("cpp-tooling", root, "cpp") + cfg.Checks.QualityRules.CPPTooling = codeguard.CPPToolingConfig{ClangFormatMode: "required", CompilerMode: "required"} + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatal(err) + } + if messages := findingMessagesForRule(report, "quality.cpp.clang-format"); len(messages) != 1 || !strings.Contains(messages[0], "formatting differs") { + t.Fatalf("clang-format messages = %#v", messages) + } + if messages := findingMessagesForRule(report, "quality.cpp.compiler-parse"); len(messages) != 1 || !strings.Contains(messages[0], "expected declaration") { + t.Fatalf("compiler messages = %#v", messages) + } +} + +func TestCPPToolingAutoSkipsUnavailableTools(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "widget.cpp"), "int widget;\n") + t.Setenv("PATH", t.TempDir()) + cfg := qualityOnlyConfig("cpp-tooling-auto", root, "cpp") + cfg.Checks.QualityRules.CPPTooling = codeguard.CPPToolingConfig{ClangFormatMode: "auto", CompilerMode: "auto"} + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatal(err) + } + if messages := findingMessagesForRule(report, "quality.cpp.clang-format"); len(messages) != 0 { + t.Fatalf("clang-format auto messages = %#v", messages) + } + if messages := findingMessagesForRule(report, "quality.cpp.compiler-parse"); len(messages) != 0 { + t.Fatalf("compiler auto messages = %#v", messages) + } +} + +func TestCPPToolingRequiredReportsMissingCompilationDatabase(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixture") + } + root := t.TempDir() + bin := t.TempDir() + writeFile(t, filepath.Join(root, "widget.cpp"), "int widget;\n") + writeExecutableFile(t, filepath.Join(bin, "clang++"), "#!/bin/sh\nexit 0\n") + t.Setenv("PATH", bin) + cfg := qualityOnlyConfig("cpp-tooling-required", root, "cpp") + cfg.Checks.QualityRules.CPPTooling = codeguard.CPPToolingConfig{CompilerMode: "required"} + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatal(err) + } + messages := findingMessagesForRule(report, "quality.cpp.compiler-parse") + if len(messages) != 1 || !strings.Contains(messages[0], "compile_commands.json not found") { + t.Fatalf("compiler messages = %#v", messages) + } +} + +func TestCPPToolingRejectsInvalidMode(t *testing.T) { + cfg := qualityOnlyConfig("cpp-tooling-invalid", t.TempDir(), "cpp") + cfg.Checks.QualityRules.CPPTooling.ClangFormatMode = "sometimes" + if _, err := codeguard.Run(context.Background(), cfg); err == nil || !strings.Contains(err.Error(), "clang_format_mode") { + t.Fatalf("error = %v", err) + } +} diff --git a/tests/checks/security_taint_cpp_test.go b/tests/checks/security_taint_cpp_test.go new file mode 100644 index 0000000..6495542 --- /dev/null +++ b/tests/checks/security_taint_cpp_test.go @@ -0,0 +1,158 @@ +package checks_test + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func TestCPPTaintEnvironmentAndArgvReachProcessSinks(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "main.cpp"), strings.Join([]string{ + "#include ", + "#include ", + "", + "std::string command_from_env() {", + " return std::getenv(\"USER_COMMAND\");", + "}", + "", + "void launch(const std::string& command) {", + " std::system(command.c_str());", + "}", + "", + "int main(int argc, char** argv) {", + " auto command = command_from_env();", + " launch(command);", + " auto alternate = argv[1];", + " popen(alternate, \"r\");", + "}", + "", + }, "\n")) + + report, err := codeguard.Run(context.Background(), securityOnlyConfig("taint-cpp-process", dir, "cpp")) + if err != nil { + t.Fatalf("run: %v", err) + } + + messages := taintMessages(t, report, "security.taint.cpp") + assertChainMessage(t, messages, "std::getenv()", "std::system", "command_from_env()", "launch()") + assertChainMessage(t, messages, "main parameter argv", "popen", "alternate") + assertFindingConfidence(t, report, "Security", "security.taint.cpp", "high") +} + +func TestCPPSSRFRecognizesLibcurlCPRAndBoostResolver(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "fetch.cpp"), strings.Join([]string{ + "#include ", + "", + "void fetch(CURL* curl, const std::string& target) {", + " curl_easy_setopt(curl, CURLOPT_URL, target.c_str());", + "}", + "", + "void handler(const crow::request& request, CURL* curl) {", + " auto target = request.url_params.get(\"url\");", + " fetch(curl, target);", + " cpr::Get(cpr::Url{target});", + " tcp_resolver.resolve(target, \"443\");", + "}", + "", + }, "\n")) + + report, err := codeguard.Run(context.Background(), securityOnlyConfig("taint-cpp-ssrf", dir, "cpp")) + if err != nil { + t.Fatalf("run: %v", err) + } + + messages := taintMessages(t, report, "security.ssrf.cpp") + assertChainMessage(t, messages, "request request data", "curl_easy_setopt[CURLOPT_URL]", "target", "fetch()") + assertChainMessage(t, messages, "request request data", "cpr::Get", "target") + assertChainMessage(t, messages, "request request data", "tcp_resolver.resolve", "target") +} + +func TestCPPSSRFRecognizesPointerRequestParameters(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "handler.cpp"), strings.Join([]string{ + "void handler(const drogon::HttpRequest* request) {", + " auto target = request->getParameter(\"url\");", + " cpr::Get(cpr::Url{target});", + "}", + "", + }, "\n")) + + report, err := codeguard.Run(context.Background(), securityOnlyConfig("taint-cpp-request-pointer", dir, "cpp")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertChainMessage(t, taintMessages(t, report, "security.ssrf.cpp"), "request request data", "cpr::Get", "target") +} + +func TestCPPTaintSourcesFromStandardInput(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "stdin.cpp"), strings.Join([]string{ + "void run() {", + " std::string first;", + " std::cin >> first;", + " std::system(first.c_str());", + " std::string second;", + " std::getline(std::cin, second);", + " popen(second.c_str(), \"r\");", + "}", + "", + }, "\n")) + + report, err := codeguard.Run(context.Background(), securityOnlyConfig("taint-cpp-stdin", dir, "cpp")) + if err != nil { + t.Fatalf("run: %v", err) + } + + messages := taintMessages(t, report, "security.taint.cpp") + assertChainMessage(t, messages, "std::cin", "std::system", "first") + assertChainMessage(t, messages, "std::getline(std::cin)", "popen", "second") +} + +func TestCPPTaintIgnoresConstantsSanitizersCommentsAndStrings(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "safe.cpp"), strings.Join([]string{ + "void safe(CURL* curl) {", + " std::system(\"date\");", + " curl_easy_setopt(curl, CURLOPT_URL, \"https://api.example.com\");", + " auto numeric = std::stoi(std::getenv(\"ACTION\"));", + " std::system(std::to_string(numeric).c_str());", + " auto target = allowlisted_url(std::getenv(\"TARGET_URL\"));", + " cpr::Get(cpr::Url{target});", + " // auto bad = std::getenv(\"BAD\"); std::system(bad);", + " const char* sample = R\"(curl_easy_setopt(curl, CURLOPT_URL, argv[1]);)\";", + "}", + "", + }, "\n")) + + report, err := codeguard.Run(context.Background(), securityOnlyConfig("taint-cpp-safe", dir, "cpp")) + if err != nil { + t.Fatalf("run: %v", err) + } + if messages := taintMessages(t, report, "security.taint.cpp"); len(messages) != 0 { + t.Fatalf("expected no C++ process taint findings, got %v", messages) + } + if messages := taintMessages(t, report, "security.ssrf.cpp"); len(messages) != 0 { + t.Fatalf("expected no C++ SSRF findings, got %v", messages) + } +} + +func TestCPPTaintCanBeDisabled(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "main.cpp"), "int main(int argc, char** argv) { std::system(argv[1]); }\n") + + disabled := false + cfg := securityOnlyConfig("taint-cpp-toggle", dir, "cpp") + cfg.Checks.SecurityRules.TaintCPP = &disabled + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + if messages := taintMessages(t, report, "security.taint.cpp"); len(messages) != 0 { + t.Fatalf("taint_cpp=false must disable the rule, got %v", messages) + } +} diff --git a/tests/checks/supplychain_cpp_manifests_test.go b/tests/checks/supplychain_cpp_manifests_test.go new file mode 100644 index 0000000..15f4a68 --- /dev/null +++ b/tests/checks/supplychain_cpp_manifests_test.go @@ -0,0 +1,185 @@ +package checks_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func TestSupplyChainParsesStaticCMakeDependencies(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "CMakeLists.txt"), ` +include(FetchContent) +file(WRITE "${CMAKE_CURRENT_LIST_DIR}/executed-by-cmake" "unsafe") +set(FMT_TAG "10.2.1") + +FetchContent_Declare( + fmt + GIT_REPOSITORY https://github.com/fmtlib/fmt.git + GIT_TAG ${FMT_TAG} +) +FetchContent_MakeAvailable(fmt) + +ExternalProject_Add(zlib + URL "https://zlib.net/fossils/zlib-1.3.1.tar.gz" + URL_HASH SHA256=38ef96b8 +) + +CPMAddPackage( + NAME Catch2 + GITHUB_REPOSITORY catchorg/Catch2 + VERSION 3.5.2 +) + +find_package(OpenSSL 3.2.1 EXACT REQUIRED) +find_package(Boost 1.84...<1.90 REQUIRED) +FetchContent_Declare(floating GIT_REPOSITORY https://example.com/floating.git GIT_TAG main) +FetchContent_Declare(dynamic GIT_REPOSITORY ${PRIVATE_MIRROR}/dynamic.git GIT_TAG ${DYNAMIC_TAG}) +`) + + report, err := codeguard.Run(context.Background(), supplyChainTestConfig(dir, "cmake-static")) + if err != nil { + t.Fatalf("run: %v", err) + } + + manifest := requireSupplyChainArtifact(t, report, 1).SupplyChain.Manifests[0] + if manifest.Ecosystem != "cmake" || manifest.PackageManager != "cmake" { + t.Fatalf("unexpected CMake manifest identity: %#v", manifest) + } + assertStaticCMakeDependencies(t, manifest.Dependencies) + if len(manifest.AnalysisLimitations) != 1 || !strings.Contains(manifest.AnalysisLimitations[0], "did not execute CMake") { + t.Fatalf("unexpected CMake analysis limitations: %v", manifest.AnalysisLimitations) + } + + messages := supplyChainRuleMessages(report, "supply_chain.unpinned-dependency") + if len(messages) != 3 { + t.Fatalf("CMake unpinned findings = %d, want 3: %v", len(messages), messages) + } + if _, err := os.Stat(filepath.Join(dir, "executed-by-cmake")); !os.IsNotExist(err) { + t.Fatalf("CMake parser executed project code: %v", err) + } +} + +func assertStaticCMakeDependencies(t *testing.T, declared []codeguard.SupplyChainDependency) { + t.Helper() + dependencies := cppDependencyByName(declared) + expectedPins := map[string]bool{ + "fmt": true, "zlib": true, "Catch2": true, "OpenSSL": true, + "Boost": false, "floating": false, "dynamic": false, + } + for name, pinned := range expectedPins { + dependency, ok := dependencies[name] + if !ok || dependency.Pinned != pinned { + t.Fatalf("unexpected CMake dependency %q: %#v", name, dependency) + } + } + if dependencies["Boost"].Requirement != "1.84...<1.90" { + t.Fatalf("CMake version range was not preserved: %#v", dependencies["Boost"]) + } + if dependencies["fmt"].Requirement != "https://github.com/fmtlib/fmt.git@10.2.1" || dependencies["fmt"].Line != 6 { + t.Fatalf("unexpected resolved fmt dependency: %#v", dependencies["fmt"]) + } + if dependencies["zlib"].Version != "1.3.1" || dependencies["zlib"].Line != 13 { + t.Fatalf("unexpected URL dependency: %#v", dependencies["zlib"]) + } +} + +func TestSupplyChainParsesCPMCompactReference(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "dependencies.cmake"), `CPMAddPackage("gh:gabime/spdlog@1.14.1")`) + + report, err := codeguard.Run(context.Background(), supplyChainTestConfig(dir, "cmake-cpm-compact")) + if err != nil { + t.Fatalf("run: %v", err) + } + dependency := requireSupplyChainArtifact(t, report, 1).SupplyChain.Manifests[0].Dependencies[0] + if dependency.Name != "spdlog" || dependency.Version != "1.14.1" || !dependency.Pinned { + t.Fatalf("unexpected compact CPM dependency: %#v", dependency) + } +} + +func TestSupplyChainParsesDeclarativeConanfilePython(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "conanfile.py"), `from conan import ConanFile +from pathlib import Path +Path(__file__).with_name("executed-by-conan").write_text("unsafe") + +FMT = "fmt/10.2.1" +BUILD_TOOLS = ( + "cmake/3.29.0", +) + +class App(ConanFile): + requires = ( + FMT, + "openssl/[>=3.0 <4]", + ) + tool_requires = BUILD_TOOLS + + def requirements(self): + self.requires( + "zlib/1.3.1", + transitive_headers=True, + ) + self.requires(make_reference()) +`) + writeFile(t, filepath.Join(dir, "conan.lock"), `{"version":"0.5","requires":[],"build_requires":[]}`) + + report, err := codeguard.Run(context.Background(), supplyChainTestConfig(dir, "conan-python-static")) + if err != nil { + t.Fatalf("run: %v", err) + } + + manifest := requireSupplyChainArtifact(t, report, 1).SupplyChain.Manifests[0] + dependencies := cppDependencyByName(manifest.Dependencies) + for _, name := range []string{"fmt", "cmake", "openssl", "zlib"} { + if _, ok := dependencies[name]; !ok { + t.Fatalf("missing Conan dependency %q in %#v", name, manifest.Dependencies) + } + } + if dependencies["cmake"].Scope != "build" || dependencies["cmake"].Line != 7 { + t.Fatalf("unexpected Conan tool requirement: %#v", dependencies["cmake"]) + } + if dependencies["openssl"].Pinned { + t.Fatalf("Conan version range should be unpinned: %#v", dependencies["openssl"]) + } + if dependencies["zlib"].Line != 19 { + t.Fatalf("unexpected self.requires line: %#v", dependencies["zlib"]) + } + if len(manifest.AnalysisLimitations) != 1 || !strings.Contains(manifest.AnalysisLimitations[0], "did not execute Python") { + t.Fatalf("unexpected Conan analysis limitations: %v", manifest.AnalysisLimitations) + } + if messages := supplyChainRuleMessages(report, "supply_chain.unpinned-dependency"); len(messages) != 1 || !strings.Contains(messages[0], "openssl") { + t.Fatalf("unexpected Conan Python unpinned findings: %v", messages) + } + if _, err := os.Stat(filepath.Join(dir, "executed-by-conan")); !os.IsNotExist(err) { + t.Fatalf("Conan parser executed project code: %v", err) + } +} + +func TestSupplyChainDoesNotTreatArbitraryPythonAsConanManifest(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "setup.py"), `self.requires("not-a-conan-reference/1.0.0")`) + + report, err := codeguard.Run(context.Background(), supplyChainTestConfig(dir, "non-conan-python")) + if err != nil { + t.Fatalf("run: %v", err) + } + for _, artifact := range report.Artifacts { + if artifact.Kind == "supply_chain" { + t.Fatalf("arbitrary Python produced a supply-chain artifact: %#v", artifact) + } + } +} + +func cppDependencyByName(dependencies []codeguard.SupplyChainDependency) map[string]codeguard.SupplyChainDependency { + indexed := make(map[string]codeguard.SupplyChainDependency, len(dependencies)) + for _, dependency := range dependencies { + indexed[dependency.Name] = dependency + } + return indexed +} diff --git a/tests/support/compdb_test.go b/tests/support/compdb_test.go new file mode 100644 index 0000000..7687060 --- /dev/null +++ b/tests/support/compdb_test.go @@ -0,0 +1,96 @@ +package support_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/devr-tools/codeguard/internal/codeguard/cpp/compdb" +) + +func TestLoadNormalizesCommandMetadataAndKeepsOnlyTargetLocalPaths(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + writeCompDBFile(t, filepath.Join(root, "src", "widget.cpp"), "int widget;\n") + if err := os.MkdirAll(filepath.Join(root, "include"), 0o750); err != nil { + t.Fatal(err) + } + writeCompDBFile(t, filepath.Join(root, "build", "compile_commands.json"), `[ + { + "directory": "..", + "file": "src/widget.cpp", + "command": "malicious-wrapper clang++ -Iinclude -I`+outside+` -DNAME='hello world' -UOLD -std=c++20 -fplugin=evil.so -c src/widget.cpp" + }, + { + "directory": "..", + "file": "../outside.cpp", + "arguments": ["clang++", "-c", "../outside.cpp"] + } +]`) + + db, err := compdb.Load(root, "") + if err != nil { + t.Fatal(err) + } + if len(db.Entries) != 1 { + t.Fatalf("entries = %d, want 1", len(db.Entries)) + } + entry := db.Entries[0] + if entry.RelativeFile != "src/widget.cpp" || entry.Compiler != "malicious-wrapper" { + t.Fatalf("entry = %#v", entry) + } + wantInclude, err := filepath.EvalSymlinks(filepath.Join(root, "include")) + if err != nil { + t.Fatal(err) + } + if len(entry.IncludeDirs) != 1 || entry.IncludeDirs[0] != wantInclude { + t.Fatalf("include dirs = %#v", entry.IncludeDirs) + } + if len(entry.Defines) != 1 || entry.Defines[0] != "NAME=hello world" { + t.Fatalf("defines = %#v", entry.Defines) + } + if entry.Standard != "c++20" { + t.Fatalf("standard = %q", entry.Standard) + } +} + +func TestFindRejectsConfiguredPathOutsideTarget(t *testing.T) { + if _, err := compdb.Find(t.TempDir(), "../compile_commands.json"); err == nil { + t.Fatal("expected escaping compile_commands path to be rejected") + } +} + +func TestFindRejectsCompilationDatabaseSymlinkOutsideTarget(t *testing.T) { + root := t.TempDir() + outside := filepath.Join(t.TempDir(), "compile_commands.json") + writeCompDBFile(t, outside, "[]") + if err := os.Symlink(outside, filepath.Join(root, "compile_commands.json")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + if _, err := compdb.Find(root, "compile_commands.json"); err == nil { + t.Fatal("expected an out-of-target compilation database symlink to be rejected") + } +} + +func TestLoadRejectsMalformedCommandForm(t *testing.T) { + root := t.TempDir() + writeCompDBFile(t, filepath.Join(root, "source.cpp"), "int source;\n") + writeCompDBFile(t, filepath.Join(root, "compile_commands.json"), `[{"directory":".","file":"source.cpp","command":"clang++ 'unterminated"}]`) + db, err := compdb.Load(root, "") + if err != nil { + t.Fatal(err) + } + if len(db.Entries) != 0 { + t.Fatalf("entries = %#v, want malformed command entry skipped", db.Entries) + } +} + +func writeCompDBFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/tests/support/cpp_dependency_graph_test.go b/tests/support/cpp_dependency_graph_test.go new file mode 100644 index 0000000..c27a17b --- /dev/null +++ b/tests/support/cpp_dependency_graph_test.go @@ -0,0 +1,52 @@ +package support_test + +import ( + "os" + "path/filepath" + "testing" + + checksupport "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func TestCPPDependencyGraphUsesTargetLocalCompilationDatabaseIncludes(t *testing.T) { + root := t.TempDir() + writeCPPGraphFile(t, root, "src/main.cpp", "#include \n") + writeCPPGraphFile(t, root, "include/project/widget.h", "#pragma once\n") + writeCPPGraphFile(t, root, "compile_commands.json", `[{"directory":".","file":"src/main.cpp","arguments":["clang++","-Iinclude","-c","src/main.cpp"]}]`) + target := core.TargetConfig{Path: root, Language: "cpp"} + env := checksupport.Context{ + Config: core.Config{Checks: core.CheckConfig{QualityRules: core.QualityRulesConfig{CPPTooling: core.CPPToolingConfig{}}}}, + VisitTargetFiles: func(_ core.TargetConfig, include func(string) bool, visit func(string, []byte)) { + for _, rel := range []string{"src/main.cpp", "include/project/widget.h"} { + if !include(rel) { + continue + } + data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel))) + if err != nil { + t.Fatal(err) + } + visit(rel, data) + } + }, + } + graph := checksupport.BuildCPPDependencyGraph(env, target) + if graph == nil { + t.Fatal("expected graph") + } + edges := graph.Graph.Nodes["src/main.cpp"].Edges + if len(edges) != 1 || edges[0].To != "include/project/widget.h" { + t.Fatalf("edges = %#v", edges) + } +} + +func writeCPPGraphFile(t *testing.T, root, rel, content string) { + t.Helper() + path := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { //nolint:gosec // path is rooted in t.TempDir + t.Fatal(err) + } +} diff --git a/tests/support/cpptooling_runner_test.go b/tests/support/cpptooling_runner_test.go new file mode 100644 index 0000000..1613b31 --- /dev/null +++ b/tests/support/cpptooling_runner_test.go @@ -0,0 +1,119 @@ +package support_test + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/devr-tools/codeguard/internal/codeguard/core" + "github.com/devr-tools/codeguard/internal/codeguard/runner/cpptooling" + "github.com/devr-tools/codeguard/internal/codeguard/trust" +) + +func TestCheckFormatUsesBuiltInToolAndReportsFile(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixture") + } + root := t.TempDir() + bin := t.TempDir() + writeCPPToolExecutable(t, filepath.Join(bin, "clang-format"), "#!/bin/sh\ncase \"$*\" in *bad.cpp*) echo 'bad.cpp: formatting differs'; exit 1;; esac\n") + t.Setenv("PATH", bin) + issues, err := cpptooling.CheckFormat(context.Background(), root, core.CPPToolingConfig{}, []string{"good.cpp", "bad.cpp", "../escape.cpp"}) + if err != nil { + t.Fatal(err) + } + if len(issues) != 1 || issues[0].Path != "bad.cpp" || !strings.Contains(issues[0].Message, "formatting differs") { + t.Fatalf("issues = %#v", issues) + } +} + +func TestCommandOverrideUsesConfigCommandTrustGate(t *testing.T) { + previous := trust.Current() + trust.Set(trust.Policy{}) + defer trust.Set(previous) + _, err := cpptooling.CheckFormat(context.Background(), t.TempDir(), core.CPPToolingConfig{ClangFormatCommand: "/bin/sh"}, nil) + if err == nil || !strings.Contains(err.Error(), "refusing to run config-supplied command") { + t.Fatalf("error = %v", err) + } +} + +func TestCheckSyntaxRebuildsSafeArgumentsAndNeverRunsDatabaseCompiler(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixture") + } + root := t.TempDir() + bin := t.TempDir() + argsFile := filepath.Join(root, "args.txt") + pwned := filepath.Join(root, "pwned") + writeCPPToolExecutable(t, filepath.Join(bin, "clang++"), "#!/bin/sh\nprintf '%s\\n' \"$@\" > \""+argsFile+"\"\n") + writeCPPToolExecutable(t, filepath.Join(bin, "database-compiler"), "#!/bin/sh\ntouch \""+pwned+"\"\n") + t.Setenv("PATH", bin) + writeCPPToolFile(t, filepath.Join(root, "include", "widget.h"), "#pragma once\n") + writeCPPToolFile(t, filepath.Join(root, "src", "widget.cpp"), "int widget;\n") + writeCPPToolFile(t, filepath.Join(root, "compile_commands.json"), `[{"directory":".","file":"src/widget.cpp","arguments":["database-compiler","-Iinclude","-DVALUE=1","-std=c++20","-fplugin=evil.so","@evil.rsp","-o","pwned","-c","src/widget.cpp"]}]`) + issues, err := cpptooling.CheckSyntax(context.Background(), root, core.CPPToolingConfig{}) + if err != nil { + t.Fatal(err) + } + if len(issues) != 0 { + t.Fatalf("issues = %#v", issues) + } + if _, statErr := os.Stat(pwned); !os.IsNotExist(statErr) { + t.Fatalf("database compiler unexpectedly ran: %v", statErr) + } + // #nosec G304 -- argsFile is constructed beneath the test's temporary root. + args, err := os.ReadFile(argsFile) + if err != nil { + t.Fatal(err) + } + text := string(args) + assertCPPToolArguments(t, root, text) + assertCPPToolArgumentsExcluded(t, text) +} + +func assertCPPToolArguments(t *testing.T, root, arguments string) { + t.Helper() + canonicalRoot, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"-fsyntax-only", "-std=c++20", "-I" + filepath.Join(canonicalRoot, "include"), "-DVALUE=1", filepath.Join(canonicalRoot, "src", "widget.cpp")} { + if !strings.Contains(arguments, want) { + t.Fatalf("args %q missing %q", arguments, want) + } + } +} + +func assertCPPToolArgumentsExcluded(t *testing.T, arguments string) { + t.Helper() + lines := strings.Split(strings.TrimSpace(arguments), "\n") + for _, forbidden := range []string{"database-compiler", "-fplugin=evil.so", "@evil.rsp", "-o", "pwned"} { + for _, argument := range lines { + if argument == forbidden { + t.Fatalf("unsafe database argument %q reached compiler: %q", forbidden, arguments) + } + } + } +} + +func writeCPPToolExecutable(t *testing.T, path, content string) { + t.Helper() + writeCPPToolFile(t, path, content) + // #nosec G302 -- this fixture must be executable to stand in for the external tool. + if err := os.Chmod(path, 0o750); err != nil { + t.Fatal(err) + } +} + +func writeCPPToolFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +}