fix: fetcher security — fork+execve, hash verification, tar safety, CLI --help
B1: Replaced all std::system()/popen() with fork+execve — zero shell injection B2+B3: Added sha256/sha512/md5 fields to PackageDef, parser support, actual hash comparison in verify_hash() B4: tar --no-same-owner --no-same-permissions, zip uses unzip instead of tar B5: Patch sha256 verified before application via -i flag (no shell redirect) B6: kappa fetch --help now prints usage and exits 0 Also: removed dead ternary code, added <cstdlib>/<sys/wait.h>/<unistd.h>
This commit is contained in:
@@ -32,5 +32,6 @@ add_executable(kappa
|
|||||||
src/tools/format.cpp
|
src/tools/format.cpp
|
||||||
src/tools/doctor.cpp
|
src/tools/doctor.cpp
|
||||||
src/resolve/plan.cpp
|
src/resolve/plan.cpp
|
||||||
|
src/fetch/fetch.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(kappa PRIVATE include)
|
target_include_directories(kappa PRIVATE include)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
package "foo" {
|
package "foo" {
|
||||||
const version = "1.2.3"
|
const version = "1.2.3"
|
||||||
const source = "https://example.com/foo-${version}.tar.gz"
|
const source = "https://example.com/foo-${version}.tar.gz"
|
||||||
|
sha256 = "e127a709cba24c76de8936cb7083dd768f28cd37eb010492e2f19b71eb1294e4"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
provides = ["libfoo.so.1", "foo"]
|
provides = ["libfoo.so.1", "foo"]
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ struct PackageDef {
|
|||||||
std::string name;
|
std::string name;
|
||||||
std::string version;
|
std::string version;
|
||||||
std::string source;
|
std::string source;
|
||||||
|
std::string sha256;
|
||||||
|
std::string sha512;
|
||||||
|
std::string md5;
|
||||||
std::string license;
|
std::string license;
|
||||||
std::vector<Dependency> depends;
|
std::vector<Dependency> depends;
|
||||||
std::vector<std::string> provides;
|
std::vector<std::string> provides;
|
||||||
|
|||||||
@@ -123,6 +123,22 @@ void Parser::parse_body(PackageDef& pkg) {
|
|||||||
pkg.license = consume(TokenType::String).lexeme;
|
pkg.license = consume(TokenType::String).lexeme;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case TokenType::Ident:
|
||||||
|
if (current_.lexeme == "sha256") {
|
||||||
|
advance(); consume(TokenType::Equals);
|
||||||
|
pkg.sha256 = consume(TokenType::String).lexeme;
|
||||||
|
} else if (current_.lexeme == "sha512") {
|
||||||
|
advance(); consume(TokenType::Equals);
|
||||||
|
pkg.sha512 = consume(TokenType::String).lexeme;
|
||||||
|
} else if (current_.lexeme == "md5") {
|
||||||
|
advance(); consume(TokenType::Equals);
|
||||||
|
pkg.md5 = consume(TokenType::String).lexeme;
|
||||||
|
} else {
|
||||||
|
throw ParseError(current_.line, current_.col,
|
||||||
|
msg_unknown_decl(current_.lexeme));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case TokenType::KwProvides:
|
case TokenType::KwProvides:
|
||||||
consume(TokenType::KwProvides);
|
consume(TokenType::KwProvides);
|
||||||
consume(TokenType::Equals);
|
consume(TokenType::Equals);
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
#include "kappa/fetch/fetch.hpp"
|
||||||
|
#include "kappa/paths.hpp"
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <format>
|
||||||
|
#include <string>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace kappa::fetch {
|
||||||
|
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace std::string_view_literals;
|
||||||
|
|
||||||
|
static int exec_cmd(const std::vector<std::string>& argv) {
|
||||||
|
if (argv.empty()) { return -1; }
|
||||||
|
std::vector<char*> cargs;
|
||||||
|
for (auto& a : argv) { cargs.push_back(const_cast<char*>(a.c_str())); }
|
||||||
|
cargs.push_back(nullptr);
|
||||||
|
|
||||||
|
pid_t pid = fork();
|
||||||
|
if (pid == 0) {
|
||||||
|
execvp(cargs[0], cargs.data());
|
||||||
|
_exit(127);
|
||||||
|
}
|
||||||
|
if (pid < 0) { return -1; }
|
||||||
|
int status = 0;
|
||||||
|
waitpid(pid, &status, 0);
|
||||||
|
return WIFEXITED(status) ? WEXITSTATUS(status) : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::string exec_capture(const std::vector<std::string>& argv) {
|
||||||
|
int pipefd[2];
|
||||||
|
if (pipe(pipefd) != 0) { return ""; }
|
||||||
|
|
||||||
|
pid_t pid = fork();
|
||||||
|
if (pid == 0) {
|
||||||
|
close(pipefd[0]);
|
||||||
|
dup2(pipefd[1], STDOUT_FILENO);
|
||||||
|
close(pipefd[1]);
|
||||||
|
|
||||||
|
std::vector<char*> cargs;
|
||||||
|
for (auto& a : argv) { cargs.push_back(const_cast<char*>(a.c_str())); }
|
||||||
|
cargs.push_back(nullptr);
|
||||||
|
execvp(cargs[0], cargs.data());
|
||||||
|
_exit(127);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pid < 0) { close(pipefd[0]); close(pipefd[1]); return ""; }
|
||||||
|
|
||||||
|
close(pipefd[1]);
|
||||||
|
std::array<char, 256> buf;
|
||||||
|
std::string result;
|
||||||
|
ssize_t n;
|
||||||
|
while ((n = read(pipefd[0], buf.data(), buf.size() - 1)) > 0) {
|
||||||
|
buf[static_cast<std::size_t>(n)] = '\0';
|
||||||
|
result += buf.data();
|
||||||
|
}
|
||||||
|
close(pipefd[0]);
|
||||||
|
waitpid(pid, nullptr, 0);
|
||||||
|
|
||||||
|
if (!result.empty() && result.back() == '\n') { result.pop_back(); }
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::string interpret_url(const dsl::PackageDef& pkg) {
|
||||||
|
auto url = pkg.source;
|
||||||
|
for (auto& [from, to] : {
|
||||||
|
std::pair{"${name}"sv, std::string_view(pkg.name)},
|
||||||
|
std::pair{"${version}"sv, std::string_view(pkg.version)}}) {
|
||||||
|
std::size_t pos = 0;
|
||||||
|
while ((pos = url.find(from, pos)) != std::string::npos) {
|
||||||
|
url.replace(pos, from.size(), to);
|
||||||
|
pos += to.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool verify_hash(const fs::path& file, std::string_view algo,
|
||||||
|
std::string_view expected) {
|
||||||
|
if (expected.empty()) { return true; }
|
||||||
|
std::string tool;
|
||||||
|
if (algo == "sha256") { tool = "sha256sum"; }
|
||||||
|
else if (algo == "sha512") { tool = "sha512sum"; }
|
||||||
|
else if (algo == "md5") { tool = "md5sum"; }
|
||||||
|
else { return false; }
|
||||||
|
|
||||||
|
auto output = exec_capture({tool, file.string()});
|
||||||
|
if (output.empty()) { return false; }
|
||||||
|
|
||||||
|
auto space = output.find(' ');
|
||||||
|
auto computed = (space != std::string::npos)
|
||||||
|
? output.substr(0, space)
|
||||||
|
: output;
|
||||||
|
return computed == expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
FetchResult fetch(const dsl::PackageDef& pkg) {
|
||||||
|
FetchResult result;
|
||||||
|
|
||||||
|
auto url = interpret_url(pkg);
|
||||||
|
if (url.empty()) {
|
||||||
|
result.error = "empty source URL";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto ext_pos = url.rfind('.');
|
||||||
|
auto ext = (ext_pos != std::string::npos)
|
||||||
|
? url.substr(ext_pos + 1)
|
||||||
|
: std::string{};
|
||||||
|
if (ext == "gz" || ext == "xz" || ext == "zst") {
|
||||||
|
auto prev = url.rfind('.', ext_pos - 1);
|
||||||
|
if (prev != std::string::npos) {
|
||||||
|
std::string compound{url.substr(prev + 1,
|
||||||
|
ext_pos - prev - 1)};
|
||||||
|
ext = compound + "." + ext;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto dest_name = pkg.name + "-" + pkg.version;
|
||||||
|
auto dest_file = fs::path(paths::temp_dir) / (dest_name + "." + ext);
|
||||||
|
result.work_dir = fs::path(paths::temp_dir) / dest_name;
|
||||||
|
|
||||||
|
if (ext == "git") {
|
||||||
|
int rc = exec_cmd({"git", "clone", url, result.work_dir.string()});
|
||||||
|
if (rc != 0) { result.error = "git clone failed"; return result; }
|
||||||
|
} else {
|
||||||
|
fs::create_directories(paths::temp_dir);
|
||||||
|
int rc = exec_cmd({"curl", "-L", "-o", dest_file.string(), url});
|
||||||
|
if (rc != 0) { result.error = "download failed"; return result; }
|
||||||
|
|
||||||
|
bool verified = false;
|
||||||
|
for (auto algo : {"sha512", "sha256", "md5"}) {
|
||||||
|
std::string_view expected;
|
||||||
|
if (std::string_view(algo) == "sha512") { expected = pkg.sha512; }
|
||||||
|
else if (std::string_view(algo) == "sha256") { expected = pkg.sha256; }
|
||||||
|
else { expected = pkg.md5; }
|
||||||
|
|
||||||
|
if (!expected.empty()) {
|
||||||
|
if (verify_hash(dest_file, algo, expected)) {
|
||||||
|
verified = true;
|
||||||
|
} else {
|
||||||
|
result.error = std::format("{} mismatch", algo);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!verified && !pkg.sha256.empty()) {
|
||||||
|
result.error = "hash verification failed";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto extract_cmd = std::string{"tar"};
|
||||||
|
if (ext == "zip") {
|
||||||
|
int rc2 = exec_cmd({"unzip", "-o", dest_file.string(),
|
||||||
|
"-d", result.work_dir.string()});
|
||||||
|
if (rc2 != 0) { result.error = "extraction failed"; return result; }
|
||||||
|
} else {
|
||||||
|
int rc2 = exec_cmd({"tar", "xf", dest_file.string(),
|
||||||
|
"-C", paths::temp_dir.string(),
|
||||||
|
"--no-same-owner", "--no-same-permissions"});
|
||||||
|
if (rc2 != 0) { result.error = "extraction failed"; return result; }
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::remove(dest_file);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (auto& patch : pkg.patches) {
|
||||||
|
auto patch_path = patch.url;
|
||||||
|
auto patch_file = patch_path;
|
||||||
|
if (patch_path.starts_with("http")) {
|
||||||
|
auto local = fs::path(paths::temp_dir)
|
||||||
|
/ fs::path(patch_path).filename();
|
||||||
|
int rc = exec_cmd({"curl", "-L", "-o", local.string(),
|
||||||
|
patch_path});
|
||||||
|
if (rc != 0) { continue; }
|
||||||
|
patch_file = local.string();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!patch.sha256.empty()) {
|
||||||
|
if (!verify_hash(patch_file, "sha256", patch.sha256)) {
|
||||||
|
result.error = "patch hash mismatch: " + patch.url;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exec_cmd({"patch", "-p" + std::to_string(patch.level),
|
||||||
|
"-d", result.work_dir.string(), "-i", patch_file});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace kappa::fetch
|
||||||
+30
-2
@@ -2,6 +2,7 @@
|
|||||||
#include "kappa/config/eval.hpp"
|
#include "kappa/config/eval.hpp"
|
||||||
#include "kappa/dsl/parser.hpp"
|
#include "kappa/dsl/parser.hpp"
|
||||||
#include "kappa/dsl/system.hpp"
|
#include "kappa/dsl/system.hpp"
|
||||||
|
#include "kappa/fetch/fetch.hpp"
|
||||||
#include "kappa/resolve/plan.hpp"
|
#include "kappa/resolve/plan.hpp"
|
||||||
#include "kappa/tools/doctor.hpp"
|
#include "kappa/tools/doctor.hpp"
|
||||||
#include "kappa/tools/format.hpp"
|
#include "kappa/tools/format.hpp"
|
||||||
@@ -30,6 +31,7 @@ Subcommands:
|
|||||||
format <file> Format a .kap file to canonical style (printed to stdout)
|
format <file> Format a .kap file to canonical style (printed to stdout)
|
||||||
doctor <file> Check a .kap file for issues and warnings
|
doctor <file> Check a .kap file for issues and warnings
|
||||||
resolve <config> Resolve a build plan from a system config
|
resolve <config> Resolve a build plan from a system config
|
||||||
|
fetch <package> Download and verify source for a package
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
@@ -99,7 +101,8 @@ int main(int argc, char* argv[]) {
|
|||||||
|| (subcommand == "validate")
|
|| (subcommand == "validate")
|
||||||
|| (subcommand == "format")
|
|| (subcommand == "format")
|
||||||
|| (subcommand == "doctor")
|
|| (subcommand == "doctor")
|
||||||
|| (subcommand == "resolve");
|
|| (subcommand == "resolve")
|
||||||
|
|| (subcommand == "fetch");
|
||||||
|
|
||||||
if (!valid_subcommand) {
|
if (!valid_subcommand) {
|
||||||
std::cerr << "error: unknown subcommand '" << subcommand << "'\n\n";
|
std::cerr << "error: unknown subcommand '" << subcommand << "'\n\n";
|
||||||
@@ -107,9 +110,18 @@ int main(int argc, char* argv[]) {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the file argument (skip flags)
|
|
||||||
const char* file_arg = nullptr;
|
const char* file_arg = nullptr;
|
||||||
for (int i = 2; i < argc; ++i) {
|
for (int i = 2; i < argc; ++i) {
|
||||||
|
if (std::string_view(argv[i]) == "-h"
|
||||||
|
|| std::string_view(argv[i]) == "--help") {
|
||||||
|
print_usage();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (std::string_view(argv[i]) == "-V"
|
||||||
|
|| std::string_view(argv[i]) == "--version") {
|
||||||
|
std::cout << version << '\n';
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
if (!is_flag(argv[i])) {
|
if (!is_flag(argv[i])) {
|
||||||
file_arg = argv[i];
|
file_arg = argv[i];
|
||||||
break;
|
break;
|
||||||
@@ -287,5 +299,21 @@ int main(int argc, char* argv[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (subcommand == "fetch") {
|
||||||
|
try {
|
||||||
|
auto pkg = dsl::parse(source);
|
||||||
|
auto result = fetch::fetch(pkg);
|
||||||
|
if (result.ok()) {
|
||||||
|
std::cout << "fetched to " << result.work_dir << "\n";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
std::cerr << "fetch failed: " << result.error << "\n";
|
||||||
|
return 1;
|
||||||
|
} catch (const std::runtime_error& e) {
|
||||||
|
handle_parse_error(file_arg, source, e);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return 1; // unreachable
|
return 1; // unreachable
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user