diff --git a/Makefile b/Makefile index e1e1208c4..7a47f558a 100644 --- a/Makefile +++ b/Makefile @@ -58,7 +58,7 @@ check-with-klee: $(MAKE) check RESOLVE_BUILD_KLEE=ON test: configure - cmake --build $(RESOLVE_CMAKE_BUILD_DIR) --target test-CVEAssert test-libresolve + cmake --build $(RESOLVE_CMAKE_BUILD_DIR) --target test-CVEAssert test-libresolve test-reach-rs test-with-klee: $(MAKE) test RESOLVE_BUILD_KLEE=ON diff --git a/resolve-cli/CMakeLists.txt b/resolve-cli/CMakeLists.txt index c4fe57b91..b0bb545eb 100644 --- a/resolve-cli/CMakeLists.txt +++ b/resolve-cli/CMakeLists.txt @@ -4,6 +4,53 @@ set(RESOLVE_PYTHON_VERSION "3.12" CACHE STRING "Python version used for the resolve CLI environment") option(RESOLVE_BUNDLE_PYTHON "Install a uv-managed Python into the resolve install prefix" OFF) +# Build the Rust replacement for the Python reachability wrapper +find_program(CARGO_EXECUTABLE cargo REQUIRED) + +if(CMAKE_BUILD_TYPE STREQUAL "Debug" OR CMAKE_BUILD_TYPE STREQUAL "") + set(REACH_RS_CARGO_PROFILE debug) + set(REACH_RS_CARGO_FLAGS) +else() + set(REACH_RS_CARGO_PROFILE release) + set(REACH_RS_CARGO_FLAGS --release) +endif() + +set(REACH_RS_CRATE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/src/resolve/reach") +set(REACH_RS_TARGET_DIR "${CMAKE_CURRENT_BINARY_DIR}/reach-rs-target") +set(REACH_RS_BINARY "${REACH_RS_TARGET_DIR}/${REACH_RS_CARGO_PROFILE}/reach") +set(REACH_RS_CARGO_ENV + "CARGO_TARGET_DIR=${REACH_RS_TARGET_DIR}" + "RESOLVE_LIBREACH_DIR=$" +) + +add_custom_target(reach-rs ALL + COMMAND ${CMAKE_COMMAND} -E env + ${REACH_RS_CARGO_ENV} + ${CARGO_EXECUTABLE} build --locked ${REACH_RS_CARGO_FLAGS} + WORKING_DIRECTORY "${REACH_RS_CRATE_DIR}" + BYPRODUCTS "${REACH_RS_BINARY}" + DEPENDS libreach + COMMENT "Building the Rust reach binary" + USES_TERMINAL + VERBATIM +) + +add_custom_target(test-reach-rs + COMMAND ${CMAKE_COMMAND} -E env + ${REACH_RS_CARGO_ENV} + ${CARGO_EXECUTABLE} test --locked + WORKING_DIRECTORY "${REACH_RS_CRATE_DIR}" + DEPENDS libreach + COMMENT "Running the Rust reach tests" + USES_TERMINAL + VERBATIM +) + +install(PROGRAMS "${REACH_RS_BINARY}" + DESTINATION "${CMAKE_INSTALL_BINDIR}" + RENAME reach-rs +) + # Make the install prefix a Python environment for the resolve CLI tools. install(CODE " set(_resolve_python_version \"${RESOLVE_PYTHON_VERSION}\") diff --git a/resolve-cli/src/resolve/reach/Cargo.lock b/resolve-cli/src/resolve/reach/Cargo.lock new file mode 100644 index 000000000..8a527d55b --- /dev/null +++ b/resolve-cli/src/resolve/reach/Cargo.lock @@ -0,0 +1,683 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "facts-rs" +version = "0.1.0" +dependencies = [ + "bytemuck", + "object", + "zstd", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "gloo-utils" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "reach" +version = "0.1.0" +dependencies = [ + "clap", + "facts-rs", + "serde", + "serde_json", + "vers-rs", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3b143e2833c57ab9ad3ea280d21fd34e285a42837aeb0ee301f4f41890fa00e" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e578a843d40b4189a4d66bba51d7684f57da5bd7c304c64e14bd63efbef49509" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tsify" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b26cf145f2f3b9ff84e182c448eaf05468e247f148cf3d2a7d67d78ff023a0" +dependencies = [ + "gloo-utils", + "serde", + "serde_json", + "tsify-macros", + "wasm-bindgen", +] + +[[package]] +name = "tsify-macros" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a94b0f0954b3e59bfc2c246b4c8574390d94a4ad4ad246aaf2fb07d7dfd3b47" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vers-rs" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6143511ab2bfe590aa7231a98fbb124d47462ee0cac70eabae56ee0ec6ba951" +dependencies = [ + "derive_more", + "js-sys", + "percent-encoding", + "semver", + "serde", + "serde-wasm-bindgen", + "thiserror", + "tsify", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/resolve-cli/src/resolve/reach/Cargo.toml b/resolve-cli/src/resolve/reach/Cargo.toml new file mode 100644 index 000000000..f904a0879 --- /dev/null +++ b/resolve-cli/src/resolve/reach/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "reach" +version = "0.1.0" +edition = "2024" +build = "build.rs" + +[dependencies] +clap = { version = "4.6.6", features = ["derive"] } +facts-rs = { path = "../../../../resolve-facts/rs" } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.151" +vers-rs = "0.1.2" diff --git a/resolve-cli/src/resolve/reach/build.rs b/resolve-cli/src/resolve/reach/build.rs new file mode 100644 index 000000000..00134f79f --- /dev/null +++ b/resolve-cli/src/resolve/reach/build.rs @@ -0,0 +1,34 @@ +use std::{env, path::PathBuf}; + +fn main() { + println!("cargo:rerun-if-env-changed=RESOLVE_LIBREACH_DIR"); + + let library_dir = PathBuf::from( + env::var_os("RESOLVE_LIBREACH_DIR").expect( + "RESOLVE_LIBREACH_DIR is not set; build through the CMake reach-rs target or set it to the native library directory", + ), + ); + + for library in ["libreach.a", "libresolve_facts.a"] { + let path = library_dir.join(library); + if !path.is_file() { + panic!("required native library does not exist: {}", path.display()); + } + } + + println!( + "cargo:rerun-if-changed={}", + library_dir.join("libreach.a").display() + ); + println!( + "cargo:rerun-if-changed={}", + library_dir.join("libresolve_facts.a").display() + ); + println!("cargo:rustc-link-search=native={}", library_dir.display()); + println!("cargo:rustc-link-lib=static=reach"); + println!("cargo:rustc-link-lib=static=resolve_facts"); + println!("cargo:rustc-link-lib=dylib=stdc++"); + println!("cargo:rustc-link-lib=dylib=pthread"); + println!("cargo:rustc-link-lib=dylib=dl"); + println!("cargo:rustc-link-lib=dylib=m"); +} diff --git a/resolve-cli/src/resolve/reach/src/analysis.rs b/resolve-cli/src/resolve/reach/src/analysis.rs new file mode 100644 index 000000000..6c1ebae2f --- /dev/null +++ b/resolve-cli/src/resolve/reach/src/analysis.rs @@ -0,0 +1,60 @@ +use facts_rs::FactsBuf; + +use crate::{ + functions::FunctionIndex, + libreach::{Graph, GraphBuildOptions}, + vulnerability::{ReachabilityStatus, VulnerabilityAnalysis}, +}; + +pub fn populate_reachability_results( + analyses: &mut [VulnerabilityAnalysis], + facts: &FactsBuf, + functions: &FunctionIndex, + entry: &str, + graph_options: &GraphBuildOptions<'_>, +) -> Result<(), String> { + let entry_id = functions + .find(entry, "") + .ok_or_else(|| format!("entry function '{entry}' was not found in the facts"))?; + + for analysis in analyses.iter_mut() { + analysis.function_id = functions.find( + &analysis.vuln.affected_function, + &analysis.vuln.affected_file, + ); + + if analysis.function_id.is_none() && analysis.reachability == ReachabilityStatus::Unknown { + analysis.reachability = ReachabilityStatus::NotFound; + } + } + + if !analyses + .iter() + .any(|analysis| analysis.reachability == ReachabilityStatus::Unknown) + { + return Ok(()); + } + + let graph = Graph::build_with_options(facts, graph_options)?; + println!( + "[REACH] Built a libreach graph with {} edges.", + graph.edge_count() + ); + + for analysis in analyses + .iter_mut() + .filter(|analysis| analysis.reachability == ReachabilityStatus::Unknown) + { + let destination = analysis + .function_id + .ok_or_else(|| "an unresolved analysis has no function ID".to_owned())?; + analysis.paths = graph.query(entry_id, destination, 1)?; + analysis.reachability = if analysis.paths.is_empty() { + ReachabilityStatus::NoPath + } else { + ReachabilityStatus::Reachable + }; + } + + Ok(()) +} diff --git a/resolve-cli/src/resolve/reach/src/functions.rs b/resolve-cli/src/resolve/reach/src/functions.rs new file mode 100644 index 000000000..44159928a --- /dev/null +++ b/resolve-cli/src/resolve/reach/src/functions.rs @@ -0,0 +1,157 @@ +use std::{ + io::Write, + process::{Command, Stdio}, +}; + +use facts_rs::{FactsBuf, NodeType}; + +use crate::libreach::ReachNodeID; + +#[derive(Debug)] +struct Function { + id: ReachNodeID, + symbol: String, + demangled: String, + source_file: String, + module_file: String, +} + +#[derive(Debug)] +pub struct FunctionIndex { + functions: Vec, +} + +impl FunctionIndex { + pub fn build(facts: &FactsBuf) -> Result { + let mut functions = Vec::new(); + + for (module_index, module) in facts.view().modules().enumerate() { + let module = module + .map_err(|error| format!("failed to read facts module {module_index}: {error}"))?; + let module_id = u32::try_from(module_index) + .map_err(|_| "facts contain too many modules".to_owned())?; + let module_file = module + .node_ref(0) + .and_then(|node| node.source_file()) + .unwrap_or_default() + .to_owned(); + + for node in module.node_refs() { + if node.node_type() != Ok(NodeType::Function) { + continue; + } + let Some(symbol) = node.name() else { + continue; + }; + + functions.push(Function { + id: ReachNodeID { + module: module_id, + node: node.id(), + }, + symbol: symbol.to_owned(), + demangled: String::new(), + source_file: node.source_file().unwrap_or_default().to_owned(), + module_file: module_file.clone(), + }); + } + } + + let symbols = functions + .iter() + .map(|function| function.symbol.as_str()) + .collect::>(); + let demangled = demangle(&symbols)?; + for (function, demangled) in functions.iter_mut().zip(demangled) { + function.demangled = demangled; + } + + Ok(Self { functions }) + } + + pub fn find(&self, name: &str, file: &str) -> Option { + if let Some(function) = self + .functions + .iter() + .find(|function| function.symbol == name && function.matches_file(file)) + { + return Some(function.id); + } + + let matches = self + .functions + .iter() + .filter(|function| function.demangled.contains(name) && function.matches_file(file)) + .collect::>(); + + if matches.len() > 1 { + println!( + "[REACH] WARNING: Multiple functions match '{}:{}'. Using '{}'.", + file, name, matches[0].demangled + ); + } + + matches.first().map(|function| function.id) + } + + pub fn display_name(&self, id: ReachNodeID) -> Option<&str> { + self.functions + .iter() + .find(|function| function.id == id) + .map(|function| function.demangled.as_str()) + } +} + +impl Function { + fn matches_file(&self, file: &str) -> bool { + file.is_empty() || self.source_file.contains(file) || self.module_file.contains(file) + } +} + +fn demangle(symbols: &[&str]) -> Result, String> { + if symbols.is_empty() { + return Ok(Vec::new()); + } + + let input = format!("{}\n", symbols.join("\n")); + let mut child = Command::new("c++filt") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| format!("failed to start c++filt: {error}"))?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| "failed to open c++filt input".to_owned())?; + let writer = std::thread::spawn(move || stdin.write_all(input.as_bytes())); + let output = child + .wait_with_output() + .map_err(|error| format!("failed to wait for c++filt: {error}"))?; + + let write_result = writer + .join() + .map_err(|_| "c++filt input writer panicked".to_owned())?; + if !output.status.success() { + return Err(format!( + "c++filt failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + write_result.map_err(|error| format!("failed to write to c++filt: {error}"))?; + + let demangled = String::from_utf8(output.stdout) + .map_err(|error| format!("c++filt returned invalid UTF-8: {error}"))? + .lines() + .map(str::to_owned) + .collect::>(); + if demangled.len() != symbols.len() { + return Err(format!( + "c++filt returned {} names for {} symbols", + demangled.len(), + symbols.len() + )); + } + + Ok(demangled) +} diff --git a/resolve-cli/src/resolve/reach/src/libreach.rs b/resolve-cli/src/resolve/reach/src/libreach.rs new file mode 100644 index 000000000..b3bfe3128 --- /dev/null +++ b/resolve-cli/src/resolve/reach/src/libreach.rs @@ -0,0 +1,326 @@ +use std::{ffi::c_void, ptr::NonNull, slice}; + +use facts_rs::{FactsBuf, NodeID}; +use serde::Deserialize; + +#[repr(C)] +struct ReachGraph { + _private: [u8; 0], +} + +#[repr(C)] +struct ReachQueryResult { + _private: [u8; 0], +} + +#[repr(C)] +struct ReachError { + _private: [u8; 0], +} + +#[repr(C)] +struct ReachStringView { + data: *const u8, + len: usize, +} + +#[repr(C)] +struct ReachLoadedSymbol { + symbol: ReachStringView, + library: ReachStringView, +} + +#[repr(C)] +struct ReachBuildOptions { + loaded_symbols: *const ReachLoadedSymbol, + loaded_symbol_count: usize, + dynlink: u8, + filter_loaded_symbols: u8, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +#[repr(C)] +pub struct ReachNodeID { + pub module: u32, + pub node: NodeID, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReachEdgeType { + DirectCall, + IndirectCall, + Contains, + Successor, + External, + ExternalIndirectCall, +} + +impl ReachEdgeType { + pub const fn as_str(self) -> &'static str { + match self { + Self::DirectCall => "DirectCall", + Self::IndirectCall => "IndirectCall", + Self::Contains => "Contains", + Self::Successor => "Succ", + Self::External => "Extern", + Self::ExternalIndirectCall => "ExternIndirectCall", + } + } +} + +impl TryFrom for ReachEdgeType { + type Error = String; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(Self::DirectCall), + 1 => Ok(Self::IndirectCall), + 2 => Ok(Self::Contains), + 3 => Ok(Self::Successor), + 4 => Ok(Self::External), + 5 => Ok(Self::ExternalIndirectCall), + _ => Err(format!("libreach returned unknown edge type {value}")), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReachPath { + pub nodes: Vec, + pub edges: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct LoadedSymbol { + pub symbol: String, + pub library: String, +} + +#[derive(Debug, Default)] +pub struct GraphBuildOptions<'a> { + pub loaded_symbols: Option<&'a [LoadedSymbol]>, + pub dynlink: bool, +} + +#[repr(C)] +struct ReachPathView { + nodes: *const ReachNodeID, + node_count: usize, + edges: *const u8, + edge_count: usize, +} + +unsafe extern "C" { + fn reach_graph_build( + facts: *const c_void, + options: *const ReachBuildOptions, + error: *mut *mut ReachError, + ) -> *mut ReachGraph; + fn reach_graph_free(graph: *mut ReachGraph); + fn reach_graph_edge_count(graph: *const ReachGraph) -> usize; + + fn reach_graph_query( + graph: *const ReachGraph, + src: ReachNodeID, + dst: ReachNodeID, + max_paths: usize, + error: *mut *mut ReachError, + ) -> *mut ReachQueryResult; + fn reach_query_result_free(result: *mut ReachQueryResult); + fn reach_query_result_path_count(result: *const ReachQueryResult) -> usize; + fn reach_query_result_path( + result: *const ReachQueryResult, + index: usize, + path: *mut ReachPathView, + ) -> u8; + + fn reach_error_data(error: *const ReachError) -> *const u8; + fn reach_error_len(error: *const ReachError) -> usize; + fn reach_error_free(error: *mut ReachError); +} + +pub struct Graph { + raw: NonNull, +} + +impl Graph { + pub fn build_with_options( + facts: &FactsBuf, + options: &GraphBuildOptions<'_>, + ) -> Result { + let loaded_symbols = options + .loaded_symbols + .unwrap_or_default() + .iter() + .map(|symbol| ReachLoadedSymbol { + symbol: ReachStringView::new(&symbol.symbol), + library: ReachStringView::new(&symbol.library), + }) + .collect::>(); + let ffi_options = ReachBuildOptions { + loaded_symbols: if loaded_symbols.is_empty() { + std::ptr::null() + } else { + loaded_symbols.as_ptr() + }, + loaded_symbol_count: loaded_symbols.len(), + dynlink: u8::from(options.dynlink), + filter_loaded_symbols: u8::from(options.loaded_symbols.is_some()), + }; + let mut error = std::ptr::null_mut(); + let graph = unsafe { + reach_graph_build( + std::ptr::from_ref(facts).cast(), + std::ptr::from_ref(&ffi_options), + &mut error, + ) + }; + + match NonNull::new(graph) { + Some(raw) => Ok(Self { raw }), + None => Err(unsafe { take_error(error, "libreach could not build the graph") }), + } + } + + pub fn edge_count(&self) -> usize { + unsafe { reach_graph_edge_count(self.raw.as_ptr()) } + } + + pub fn query( + &self, + source: ReachNodeID, + destination: ReachNodeID, + max_paths: usize, + ) -> Result, String> { + let mut error = std::ptr::null_mut(); + let result = unsafe { + reach_graph_query( + self.raw.as_ptr(), + source, + destination, + max_paths, + &mut error, + ) + }; + let result = NonNull::new(result) + .ok_or_else(|| unsafe { take_error(error, "libreach could not complete the query") })?; + let result = QueryResult { raw: result }; + + result.paths() + } +} + +impl ReachStringView { + fn new(value: &str) -> Self { + Self { + data: value.as_ptr(), + len: value.len(), + } + } +} + +impl Drop for Graph { + fn drop(&mut self) { + unsafe { reach_graph_free(self.raw.as_ptr()) }; + } +} + +struct QueryResult { + raw: NonNull, +} + +impl QueryResult { + fn paths(&self) -> Result, String> { + let path_count = unsafe { reach_query_result_path_count(self.raw.as_ptr()) }; + let mut paths = Vec::with_capacity(path_count); + + for index in 0..path_count { + let mut view = ReachPathView { + nodes: std::ptr::null(), + node_count: 0, + edges: std::ptr::null(), + edge_count: 0, + }; + let found = unsafe { reach_query_result_path(self.raw.as_ptr(), index, &mut view) }; + if found == 0 { + return Err(format!("libreach did not return path {index}")); + } + + let nodes = unsafe { slice_from_raw_parts(view.nodes, view.node_count) }.to_vec(); + let edges = unsafe { slice_from_raw_parts(view.edges, view.edge_count) } + .iter() + .copied() + .map(ReachEdgeType::try_from) + .collect::, _>>()?; + paths.push(ReachPath { nodes, edges }); + } + + Ok(paths) + } +} + +impl Drop for QueryResult { + fn drop(&mut self) { + unsafe { reach_query_result_free(self.raw.as_ptr()) }; + } +} + +unsafe fn slice_from_raw_parts<'a, T>(data: *const T, len: usize) -> &'a [T] { + if len == 0 { + &[] + } else { + unsafe { slice::from_raw_parts(data, len) } + } +} + +unsafe fn take_error(error: *mut ReachError, fallback: &str) -> String { + let Some(error) = NonNull::new(error) else { + return fallback.to_owned(); + }; + let length = unsafe { reach_error_len(error.as_ptr()) }; + let data = unsafe { reach_error_data(error.as_ptr()) }; + let message = if data.is_null() { + fallback.to_owned() + } else { + String::from_utf8_lossy(unsafe { slice::from_raw_parts(data, length) }).into_owned() + }; + unsafe { reach_error_free(error.as_ptr()) }; + message +} + +#[cfg(test)] +mod tests { + use facts_rs::{EdgeKind, FactsBuilder, NodeType}; + + use super::{Graph, GraphBuildOptions, ReachEdgeType, ReachNodeID}; + + #[test] + fn builds_and_queries_a_graph() { + let mut builder = FactsBuilder::new(); + let module = builder.add_module(3); + assert_eq!(builder.add_node(module, NodeType::Module), Some(0)); + let function = builder.add_node(module, NodeType::Function).unwrap(); + let block = builder.add_node(module, NodeType::BasicBlock).unwrap(); + assert!(builder.add_edge(module, function, block, EdgeKind::EntryPoint)); + + let graph = + Graph::build_with_options(&builder.freeze(), &GraphBuildOptions::default()).unwrap(); + let paths = graph + .query( + ReachNodeID { + module, + node: function, + }, + ReachNodeID { + module, + node: block, + }, + 1, + ) + .unwrap(); + + assert_eq!(graph.edge_count(), 1); + assert_eq!(paths.len(), 1); + assert_eq!(paths[0].edges, vec![ReachEdgeType::Contains]); + } +} diff --git a/resolve-cli/src/resolve/reach/src/main.rs b/resolve-cli/src/resolve/reach/src/main.rs new file mode 100644 index 000000000..9b225511d --- /dev/null +++ b/resolve-cli/src/resolve/reach/src/main.rs @@ -0,0 +1,131 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use clap::{ArgAction, Parser}; +use facts_rs::FactsBuf; +use serde::Deserialize; + +use analysis::populate_reachability_results; +use functions::FunctionIndex; +use libreach::{GraphBuildOptions, LoadedSymbol}; +use serializer::write_report; +use vcpkg::populate_version_results; +use vulnerability::{VulnerabilityAnalysis, VulnerabilityJSON}; + +mod analysis; +mod functions; +mod libreach; +mod serializer; +mod vcpkg; +mod vulnerability; + +#[derive(Parser, Debug)] +struct Args { + /// Input vulnerabilities.json + #[arg(short, long)] + input: PathBuf, + + /// Files containing facts (ELF, .so, .facts) + #[arg(short, long, required = true, num_args=1, action= ArgAction::Append)] + facts: Vec, + + /// The file to write the final report into + #[arg(short, long, default_value = "reach.json")] // TODO: .reach.json + output: PathBuf, + + /// Source tree containing vcpkg-overlays + #[arg(short, long)] + src: Option, + + // TODO: C++ WORKER ARGS HERE FOR OTHER SETTINGS + /// Entry function to traverse to vulnerable sink from + #[arg(short, long, default_value = "main")] + entry: String, + + /// Include external-linkage functions as indirect-call targets + #[arg(long)] + dynlink: bool, + + /// JSON log of symbols loaded through dlsym + #[arg(long)] + dlsym_log: Option, +} + +#[derive(Deserialize)] +struct DlsymLog { + loaded_symbols: Vec, +} + +fn load_vuln_json(path: &Path) -> Result { + let contents = + fs::read(path).map_err(|error| format!("failed to read '{}': {error}", path.display()))?; + + serde_json::from_slice(&contents) + .map_err(|error| format!("failed to parse '{}': {error}", path.display())) +} + +fn load_facts(paths: &[PathBuf]) -> Result { + FactsBuf::read_files(paths).map_err(|error| format!("failed to load facts: {error}")) +} + +fn load_dlsym_log(path: &Path) -> Result, String> { + let contents = + fs::read(path).map_err(|error| format!("failed to read '{}': {error}", path.display()))?; + let log: DlsymLog = serde_json::from_slice(&contents) + .map_err(|error| format!("failed to parse '{}': {error}", path.display()))?; + + Ok(log.loaded_symbols) +} + +fn run() -> Result<(), String> { + let args = Args::parse(); + let input = load_vuln_json(&args.input)?; + let mut analyses: Vec = + input.vulnerabilities.into_iter().map(Into::into).collect(); + + if let Some(src_dir) = args.src.as_deref() { + populate_version_results(&mut analyses, src_dir)?; + } else { + println!( + "[REACH] WARNING: No source code directory provided, package versions will not be populated." + ); + } + + let facts = load_facts(&args.facts)?; + let module_count = facts + .view() + .modules() + .try_fold(0usize, |count, module| module.map(|_| count + 1)) + .map_err(|error| format!("failed to iterate over facts modules: {error}"))?; + + println!( + "[REACH] Loaded {module_count} facts modules from {} input files.", + args.facts.len() + ); + + let functions = FunctionIndex::build(&facts)?; + let loaded_symbols = args.dlsym_log.as_deref().map(load_dlsym_log).transpose()?; + let graph_options = GraphBuildOptions { + loaded_symbols: loaded_symbols.as_deref(), + dynlink: args.dynlink, + }; + populate_reachability_results( + &mut analyses, + &facts, + &functions, + &args.entry, + &graph_options, + )?; + write_report(&args.output, &analyses, &facts, &functions)?; + + Ok(()) +} + +fn main() { + if let Err(error) = run() { + eprintln!("error: {error}"); + std::process::exit(1); + } +} diff --git a/resolve-cli/src/resolve/reach/src/serializer.rs b/resolve-cli/src/resolve/reach/src/serializer.rs new file mode 100644 index 000000000..17facf7e6 --- /dev/null +++ b/resolve-cli/src/resolve/reach/src/serializer.rs @@ -0,0 +1,227 @@ +use std::{ + fs::{self, File}, + io::{BufWriter, Write}, + path::Path, +}; + +use facts_rs::FactsBuf; +use serde::Serialize; + +use crate::{ + functions::FunctionIndex, + libreach::{ReachEdgeType, ReachNodeID, ReachPath}, + vulnerability::{ReachabilityStatus, VulnerabilityAnalysis}, +}; + +#[derive(Serialize)] +struct ReachabilityReport { + reachability_results: Vec, +} + +#[derive(Serialize)] +struct ReportResult { + cve_id: String, + classification: &'static str, + justification: Justification, +} + +#[derive(Serialize)] +struct Justification { + conclusion: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + call_path: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + control_flow_path: Option>, +} + +pub fn write_report( + path: &Path, + analyses: &[VulnerabilityAnalysis], + facts: &FactsBuf, + functions: &FunctionIndex, +) -> Result<(), String> { + let report = build_report(analyses, facts, functions)?; + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent) + .map_err(|error| format!("failed to create '{}': {error}", parent.display()))?; + } + + let file = File::create(path) + .map_err(|error| format!("failed to create '{}': {error}", path.display()))?; + let mut writer = BufWriter::new(file); + serde_json::to_writer_pretty(&mut writer, &report) + .map_err(|error| format!("failed to serialize '{}': {error}", path.display()))?; + writer + .write_all(b"\n") + .map_err(|error| format!("failed to write '{}': {error}", path.display()))?; + writer + .flush() + .map_err(|error| format!("failed to write '{}': {error}", path.display()))?; + + println!("[REACH] Wrote '{}'.", path.display()); + Ok(()) +} + +fn build_report( + analyses: &[VulnerabilityAnalysis], + facts: &FactsBuf, + functions: &FunctionIndex, +) -> Result { + let reachability_results = analyses + .iter() + .map(|analysis| build_result(analysis, facts, functions)) + .collect::, _>>()?; + + Ok(ReachabilityReport { + reachability_results, + }) +} + +fn build_result( + analysis: &VulnerabilityAnalysis, + facts: &FactsBuf, + functions: &FunctionIndex, +) -> Result { + let target = format!( + "{}:{}", + analysis.vuln.affected_file, analysis.vuln.affected_function + ); + + let (classification, justification) = match analysis.reachability { + ReachabilityStatus::NotFound => ( + "unreachable", + Justification::new( + "Not Found", + format!( + "The affected function {target} was not found in compiled program metadata." + ), + ), + ), + ReachabilityStatus::NoPath => ( + "unreachable", + Justification::new( + "Not Reachable", + format!( + "Control Flow Graph analysis found no paths to target function {target}." + ), + ), + ), + ReachabilityStatus::NotVulnerable => ( + "unreachable", + Justification::new( + "Not Vulnerable", + "The package version is not considered vulnerable according to the supplied version information. It may or may not still be reachable." + .to_owned(), + ), + ), + ReachabilityStatus::Reachable => { + let path = analysis + .paths + .first() + .ok_or_else(|| format!("reachable result '{}' has no path", analysis.vuln.cve_id))?; + let (call_path, control_flow_path) = format_path(path, facts, functions)?; + ( + "potentially reachable", + Justification { + conclusion: "Statically Reachable", + reason: Some( + "Control Flow Graph analysis found the following candidate path..." + .to_owned(), + ), + call_path: Some(call_path), + control_flow_path: Some(control_flow_path), + }, + ) + } + ReachabilityStatus::Unknown => ( + "Unable to assess", + Justification { + conclusion: "Error: internal tool failure", + reason: None, + call_path: None, + control_flow_path: None, + }, + ), + }; + + Ok(ReportResult { + cve_id: analysis.vuln.cve_id.clone(), + classification, + justification, + }) +} + +fn format_path( + path: &ReachPath, + facts: &FactsBuf, + functions: &FunctionIndex, +) -> Result<(Vec, Vec), String> { + if path.nodes.len() != path.edges.len() + 1 { + return Err("libreach returned a path with mismatched nodes and edges".to_owned()); + } + + let nodes = path + .nodes + .iter() + .copied() + .map(|id| format_node(id, facts, functions)) + .collect::, _>>()?; + let mut call_path = vec![nodes[0].clone()]; + let mut control_flow_path = vec![nodes[0].clone()]; + + for (edge, formatted_node) in path.edges.iter().zip(nodes.into_iter().skip(1)) { + let step = format!("{} -> {formatted_node}", edge.as_str()); + control_flow_path.push(step.clone()); + if !matches!(edge, ReachEdgeType::Contains | ReachEdgeType::Successor) { + call_path.push(step); + } + } + + Ok((call_path, control_flow_path)) +} + +fn format_node( + id: ReachNodeID, + facts: &FactsBuf, + functions: &FunctionIndex, +) -> Result { + let module = facts + .view() + .modules() + .nth(id.module as usize) + .ok_or_else(|| format!("facts do not contain module {}", id.module))? + .map_err(|error| format!("failed to read facts module {}: {error}", id.module))?; + let node = module.node_ref(id.node).ok_or_else(|| { + format!( + "facts module {} does not contain node {}", + id.module, id.node + ) + })?; + let kind = node + .node_type() + .map_err(|value| format!("facts node ({}, {}) has type {value}", id.module, id.node))?; + let name = functions + .display_name(id) + .map(str::to_owned) + .or_else(|| node.name().map(str::to_owned)) + .or_else(|| node.idx().map(|index| index.to_string())) + .unwrap_or_default(); + + Ok(format!("{kind:?}({name}) (({}, {}))", id.module, id.node)) +} + +impl Justification { + fn new(conclusion: &'static str, reason: String) -> Self { + Self { + conclusion, + reason: Some(reason), + call_path: None, + control_flow_path: None, + } + } +} diff --git a/resolve-cli/src/resolve/reach/src/vcpkg.rs b/resolve-cli/src/resolve/reach/src/vcpkg.rs new file mode 100644 index 000000000..16bef7c56 --- /dev/null +++ b/resolve-cli/src/resolve/reach/src/vcpkg.rs @@ -0,0 +1,191 @@ +use std::{ + fs::File, + path::{Path, PathBuf}, +}; + +use serde::Deserialize; +use vers_rs::GenericVersionRange; +use vers_rs::range::VersionRange; +use vers_rs::schemes::semver::SemVer; + +use crate::vulnerability::{ReachabilityStatus, VulnerabilityAnalysis}; + +#[derive(Debug, Deserialize)] +struct VcpkgManifest { + name: Option, + version: Option, + #[serde(rename = "version-semver")] + version_semver: Option, + #[serde(rename = "version-string")] + version_string: Option, + #[serde(rename = "version-date")] + version_date: Option, +} + +impl VcpkgManifest { + fn into_version(self) -> Option { + self.version + .or(self.version_semver) + .or(self.version_string) + .or(self.version_date) + } +} + +fn normalize_semver(version: &str) -> String { + let version = version.trim(); + let suffix_start = version.find(['-', '+']).unwrap_or(version.len()); + let (core, suffix) = version.split_at(suffix_start); + let components: Vec<&str> = core.split('.').collect(); + + if !components.iter().all(|component| { + !component.is_empty() + && component + .chars() + .all(|character| character.is_ascii_digit()) + }) { + return version.to_owned(); + } + + match components.len() { + 1 => format!("{core}.0.0{suffix}"), + 2 => format!("{core}.0{suffix}"), + _ => version.to_owned(), + } +} + +fn normalize_constraint(constraint: &str) -> String { + let constraint = constraint.trim(); + let Some(version_start) = constraint.find(|character: char| character.is_ascii_digit()) else { + return constraint.to_owned(); + }; + let (operator, version) = constraint.split_at(version_start); + + format!("{operator}{}", normalize_semver(version)) +} + +fn normalize_range(vuln_range: &str) -> String { + let (prefix, constraints) = match vuln_range.strip_prefix("vers:") { + Some(range) => match range.split_once('/') { + Some((scheme, constraints)) => (format!("vers:{scheme}/"), constraints), + None => ("vers:generic/".to_owned(), vuln_range), + }, + None => ("vers:generic/".to_owned(), vuln_range), + }; + let constraints = constraints + .split('|') + .map(normalize_constraint) + .collect::>() + .join("|"); + + format!("{prefix}{constraints}") +} + +fn is_vulnerable(vuln_range: &str, actual_version: &str) -> Result { + let range_spec = normalize_range(vuln_range); + let range = range_spec + .parse::>() + .map_err(|error| format!("failed to parse version range '{vuln_range}': {error}"))?; + let normalized_version = normalize_semver(actual_version); + let version = normalized_version + .parse::() + .map_err(|error| format!("failed to parse package version '{actual_version}': {error}"))?; + + range + .contains(&version) + .map_err(|error| format!("failed to compare package versions: {error}")) +} + +fn get_version(src_dir: &Path, package_name: &str) -> Result<(Option, PathBuf), String> { + let overlay_manifest = src_dir + .join("vcpkg-overlays") + .join("ports") + .join(package_name) + .join("vcpkg.json"); + let manifest_path = if overlay_manifest.is_file() { + overlay_manifest + } else { + src_dir.join("vcpkg.json") + }; + + let manifest_file = File::open(&manifest_path) + .map_err(|error| format!("failed to read '{}': {error}", manifest_path.display()))?; + let manifest: VcpkgManifest = serde_json::from_reader(manifest_file) + .map_err(|error| format!("failed to parse '{}': {error}", manifest_path.display()))?; + + if manifest.name.as_deref() != Some(package_name) { + return Ok((None, manifest_path)); + } + + Ok((manifest.into_version(), manifest_path)) +} + +pub fn populate_version_results( + sinks: &mut [VulnerabilityAnalysis], + src_dir: &Path, +) -> Result<(), String> { + for sink in sinks { + let (actual_version, manifest_path) = get_version(src_dir, &sink.vuln.package_name)?; + let Some(actual_version) = actual_version else { + println!( + "[REACH] WARNING: Could not find a matching vcpkg package in '{}'.", + manifest_path.display() + ); + continue; + }; + + println!( + "[REACH] Populated package version for '{}' from '{}': {}", + sink.vuln.package_name, + manifest_path.display(), + actual_version + ); + + match is_vulnerable(&sink.vuln.package_version, &actual_version) { + Ok(true) => println!( + "[REACH] Package version '{}' is vulnerable according to '{}'.", + actual_version, sink.vuln.package_version + ), + Ok(false) => { + sink.reachability = ReachabilityStatus::NotVulnerable; + println!( + "[REACH] Package version '{}' is not vulnerable according to '{}'.", + actual_version, sink.vuln.package_version + ); + } + Err(error) => println!( + "[REACH] WARNING: Could not compare the package version for '{}': {error}. Reachability analysis will continue.", + sink.vuln.package_name + ), + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{is_vulnerable, normalize_range, normalize_semver}; + + #[test] + fn normalizes_short_numeric_versions() { + assert_eq!(normalize_semver("0"), "0.0.0"); + assert_eq!(normalize_semver("2.21"), "2.21.0"); + assert_eq!(normalize_semver("2.21-beta.1"), "2.21.0-beta.1"); + assert_eq!(normalize_semver("7.10.3"), "7.10.3"); + } + + #[test] + fn normalizes_each_range_constraint() { + assert_eq!( + normalize_range(">= 2.20|<3"), + "vers:generic/>= 2.20.0|<3.0.0" + ); + assert_eq!(normalize_range("vers:generic/2.21"), "vers:generic/2.21.0"); + } + + #[test] + fn compares_short_versions() { + assert!(is_vulnerable("0", "0").unwrap()); + assert!(is_vulnerable("2.21", "2.21").unwrap()); + } +} diff --git a/resolve-cli/src/resolve/reach/src/vulnerability.rs b/resolve-cli/src/resolve/reach/src/vulnerability.rs new file mode 100644 index 000000000..750f84d98 --- /dev/null +++ b/resolve-cli/src/resolve/reach/src/vulnerability.rs @@ -0,0 +1,54 @@ +use serde::Deserialize; + +use crate::libreach::{ReachNodeID, ReachPath}; + +/// vulnerabilities.json +#[derive(Debug, Deserialize)] +pub struct VulnerabilityJSON { + pub vulnerabilities: Vec, +} + +/// A vulnerability inside vulnerabilities.json +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct Vulnerability { + #[serde(alias = "cve_id")] + pub cve_id: String, + #[serde(alias = "package_name")] + pub package_name: String, + #[serde(alias = "package_version")] + pub package_version: String, + #[serde(alias = "affected_function")] + pub affected_function: String, + #[serde(alias = "affected_file")] + pub affected_file: String, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum ReachabilityStatus { + #[default] + Unknown, + NotFound, + NoPath, + NotVulnerable, + Reachable, +} + +#[derive(Debug)] +pub struct VulnerabilityAnalysis { + pub vuln: Vulnerability, + pub reachability: ReachabilityStatus, + pub function_id: Option, + pub paths: Vec, +} + +impl From for VulnerabilityAnalysis { + fn from(vulnerability: Vulnerability) -> Self { + Self { + vuln: vulnerability, + reachability: ReachabilityStatus::Unknown, + function_id: None, + paths: Vec::new(), + } + } +}