git mirror - github.com/owenewans/holy - branch master
clone: https://src.holypkg.eu/holy/

file src/check.c

#define _DEFAULT_SOURCE
#define _POSIX_C_SOURCE 200809L
#include "check.h"
#include "package.h"
#include "stage.h"
#include "scan.h"
#include "elf.h"
#include "verify.h"
#include "script.h"

#include <archive.h>
#include <archive_entry.h>
#include <errno.h>
#include <fcntl.h>
#include <linux/openat2.h>
#include <openssl/evp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/syscall.h>
#include <unistd.h>

static int parent_fd(int root, const char *path, char **storage, const char **base)
{
    char *cursor, *slash;
    size_t length;
    int current = root, next;
    *storage = strdup(path);
    if (!*storage) return -1;
    length = strlen(*storage);
    if (length && (*storage)[length - 1] == '/') (*storage)[length - 1] = '\0';
    slash = strrchr(*storage, '/');
    if (!slash) { *base = *storage; return root; }
    *slash++ = '\0';
    *base = slash;
    cursor = *storage;
    while (*cursor) {
        char *end = strchr(cursor, '/');
        if (end) *end = '\0';
        next = openat(current, cursor, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
        if (current != root) close(current);
        if (next < 0) {
            int error = errno;
            free(*storage);
            *storage = NULL;
            errno = error;
            return -1;
        }
        current = next;
        cursor = end ? end + 1 : cursor + strlen(cursor);
    }
    return current;
}

static int hash_file(int fd, unsigned char digest[32])
{
    EVP_MD_CTX *ctx = EVP_MD_CTX_new();
    char buffer[65536];
    unsigned int length;
    ssize_t got;
    int ok = ctx && EVP_DigestInit_ex(ctx, EVP_sha256(), NULL) == 1;
    while (ok && (got = read(fd, buffer, sizeof buffer)) != 0) {
        if (got < 0) {
            if (errno == EINTR) continue;
            ok = 0;
            break;
        }
        if (EVP_DigestUpdate(ctx, buffer, (size_t)got) != 1) ok = 0;
    }
    if (ok) ok = EVP_DigestFinal_ex(ctx, digest, &length) == 1 && length == 32;
    EVP_MD_CTX_free(ctx);
    return ok;
}

static int compare_file(struct archive *a, struct archive_entry *entry, int parent,
                        const char *name, const struct stat *st, char **interpreter,
                        int *elf_class, uint16_t *machine, int *script)
{
    EVP_MD_CTX *ctx = EVP_MD_CTX_new();
    char buffer[65536];
    unsigned char expected[32], actual[32];
    unsigned int length;
    la_ssize_t got;
    la_int64_t total = 0;
    struct stat opened;
    int fd = -1, ok = 0;
    *interpreter = NULL;
    *elf_class = 0;
    *machine = 0;
    *script = 0;
    if (!ctx || EVP_DigestInit_ex(ctx, EVP_sha256(), NULL) != 1 ||
        !S_ISREG(st->st_mode) || st->st_size != archive_entry_size(entry)) goto done;
    fd = openat(parent, name, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK);
    if (fd < 0 || fstat(fd, &opened) ||
        opened.st_dev != st->st_dev || opened.st_ino != st->st_ino ||
        opened.st_size != st->st_size || opened.st_mode != st->st_mode ||
        opened.st_uid != st->st_uid || opened.st_gid != st->st_gid) goto done;
    while ((got = archive_read_data(a, buffer, sizeof buffer)) > 0) {
        if (total > archive_entry_size(entry) - got ||
            EVP_DigestUpdate(ctx, buffer, (size_t)got) != 1) goto done;
        total += got;
    }
    if (got < 0 || total != archive_entry_size(entry) ||
        EVP_DigestFinal_ex(ctx, expected, &length) != 1 || length != 32 ||
        !hash_file(fd, actual) || memcmp(expected, actual, 32)) goto done;
    if (st->st_size >= 4) {
        unsigned char magic[4];
        if (pread(fd, magic, sizeof magic, 0) != sizeof magic) goto done;
        if (!memcmp(magic, "\177ELF", sizeof magic)) {
            struct holy_elf_info info;
            int result = holy_elf_read_fd(fd, &info);
            int has_interpreter = info.interpreter != NULL;
            if (result == 0 && info.interpreter) {
                *interpreter = strdup(info.interpreter);
                *elf_class = info.elf_class;
                *machine = info.machine;
            }
            holy_elf_free(&info);
            if (result || (has_interpreter && !*interpreter))
                goto done;
        }
    }
    if (!*interpreter) {
        *script = holy_script_read_fd(fd, st->st_size, st->st_mode, interpreter);
        if (*script < 0) goto done;
    }
    ok = 1;
done:
    if (!ok) { free(*interpreter); *interpreter = NULL; }
    if (fd >= 0) close(fd);
    EVP_MD_CTX_free(ctx);
    return ok;
}

/* 1: compatible ELF, 0: missing, 2: wrong arch, -2: missing syscall, -1: unknown. */
static int interpreter_status(int root, const char *interpreter,
                              int elf_class, uint16_t machine)
{
    char *path;
    struct stat st;
    struct open_how how = {0};
    int fd = -1, result = -1;
    size_t length = strlen(interpreter);
    if (interpreter[0] != '/' || !interpreter[1] ||
        length > (size_t)-1 - 6) return -1;
    path = malloc(length + 6);
    if (!path) return -1;
    snprintf(path, length + 6, "DATA%s", interpreter);
    if (!holy_safe_archive_path(path)) { free(path); return -1; }
    free(path);
    how.flags = O_RDONLY | O_CLOEXEC | O_NONBLOCK;
    how.resolve = RESOLVE_IN_ROOT | RESOLVE_NO_MAGICLINKS;
    fd = (int)syscall(SYS_openat2, root, interpreter, &how, sizeof how);
    if (fd < 0) {
        if (errno == ENOENT) result = 0;
        else if (errno == ENOSYS) result = -2;
    } else if (!fstat(fd, &st) && S_ISREG(st.st_mode) && (st.st_mode & 0111)) {
        struct holy_elf_info info;
        int parsed = holy_elf_read_fd(fd, &info);
        if (!parsed)
            result = info.elf_class == elf_class && info.machine == machine ? 1 : 2;
        holy_elf_free(&info);
    }
    if (fd >= 0) close(fd);
    return result;
}

static int compare_link(struct archive_entry *entry, int parent, const char *name)
{
    const char *expected = archive_entry_symlink(entry);
    size_t length;
    char *actual;
    ssize_t got;
    int ok;
    if (!expected) return 0;
    length = strlen(expected);
    if (length > (size_t)-1 - 2) return 0;
    actual = malloc(length + 2);
    if (!actual) return 0;
    got = readlinkat(parent, name, actual, length + 1);
    ok = got >= 0 && (size_t)got == length && !memcmp(actual, expected, length);
    free(actual);
    return ok;
}

static int compare_hardlink(int root, struct archive_entry *entry,
                            const struct stat *current)
{
    const char *target = archive_entry_hardlink(entry);
    char *storage = NULL;
    const char *name;
    struct stat st;
    int parent, ok = 0;
    if (!target || !holy_safe_archive_path(target) ||
        strncmp(target, "DATA/", 5) || !target[5]) return 0;
    parent = parent_fd(root, target + 5, &storage, &name);
    if (parent < 0) return 0;
    if (!fstatat(parent, name, &st, AT_SYMLINK_NOFOLLOW) &&
        S_ISREG(st.st_mode) && st.st_dev == current->st_dev &&
        st.st_ino == current->st_ino) ok = 1;
    if (parent != root) close(parent);
    free(storage);
    return ok;
}

static void json_string(const char *text)
{
    const unsigned char *p = (const unsigned char *)text;
    putchar('"');
    for (; *p; ++p) {
        if (*p == '"' || *p == '\\') { putchar('\\'); putchar(*p); }
        else if (*p >= 32 && *p < 127) putchar(*p);
        else printf("\\u%04x", (unsigned int)*p);
    }
    putchar('"');
}

static void report_changed(const char *path, const char *code, int json)
{
    if (!json) fprintf(stderr, "holypkg: %s payload: %s\n",
                       !strcmp(code, "missing-payload") ? "missing" : "changed", path);
    else {
        printf("{\"schema\":\"holy-check-1\",\"code\":\"%s\",\"severity\":\"error\",\"status\":\"fail\",\"path\":", code);
        json_string(path);
        fputs("}\n", stdout);
    }
}

static void report_interpreter(const char *consumer, const char *interpreter,
                               int status, int json, const char *evidence)
{
    const char *code = status == 0 ? "missing-interpreter" :
                       status == 2 ? "incompatible-interpreter" :
                       status == -2 ? "unavailable-path-resolution" : "unknown-interpreter";
    if (!json) {
        fprintf(stderr, "holypkg: %s %s for %s\n", code, interpreter, consumer);
        if (status == -2)
            fputs("holypkg: requires openat2 with RESOLVE_IN_ROOT\n", stderr);
    }
    else {
        printf("{\"schema\":\"holy-check-1\",\"code\":\"%s\",\"severity\":\"%s\",\"status\":\"%s\",\"consumer\":",
                code, status >= 0 ? "error" : "warning", status >= 0 ? "fail" : "unknown");
        json_string(consumer);
        fputs(",\"path\":", stdout);
        json_string(interpreter);
        fputs(",\"evidence\":", stdout);
        json_string(evidence);
        if (status == -2) fputs(",\"requires\":\"openat2:RESOLVE_IN_ROOT\"", stdout);
        puts("}");
    }
}

int holy_check_local(const char *package, const char *root_path, int json)
{
    struct archive *a = NULL;
    struct archive_entry *entry;
    char *snapshot = holy_stage_local(package, "holy-check");
    int root = -1, status, ok = 0, completed = 0, unavailable = 0;
    size_t checked = 0, findings = 0, unknowns = 0;
    if (!snapshot) fprintf(stderr, "holypkg: could not stage regular local input\n");
    if (!snapshot || !holy_verify_with_output(snapshot, 0) ||
        !holy_scan_local_with_output(snapshot, 0)) {
        if (json) puts("{\"schema\":\"holy-check-1\",\"code\":\"invalid-package\",\"severity\":\"error\",\"status\":\"unknown\"}");
        if (snapshot) { unlink(snapshot); free(snapshot); }
        return 2;
    }
    root = open(root_path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
    if (root < 0) { perror("holypkg: check root"); goto done; }
    a = archive_read_new();
    if (!a || archive_read_support_filter_lz4(a) != ARCHIVE_OK ||
        archive_read_support_format_tar(a) != ARCHIVE_OK ||
        archive_read_open_filename(a, snapshot, 8192) != ARCHIVE_OK) goto done;
    while ((status = archive_read_next_header(a, &entry)) == ARCHIVE_OK) {
        const char *path = archive_entry_pathname(entry);
        const char *name;
        char *storage = NULL;
        char *interpreter = NULL;
        int elf_class = 0;
        int script = 0;
        uint16_t machine = 0;
        struct stat st;
        int parent, matches;
        if (!path || strncmp(path, "DATA/", 5) || !path[5]) {
            if (archive_read_data_skip(a) != ARCHIVE_OK) goto done;
            continue;
        }
        if (!holy_safe_archive_path(path)) {
            fprintf(stderr, "holypkg: unsafe payload path\n");
            goto done;
        }
        ++checked;
        parent = parent_fd(root, path + 5, &storage, &name);
        if (parent < 0) {
            if (errno != ENOENT && errno != ENOTDIR && errno != ELOOP) goto done;
            report_changed(path, errno == ENOENT ? "missing-payload" : "changed-payload", json);
            ++findings;
            if (archive_read_data_skip(a) != ARCHIVE_OK) goto done;
            continue;
        }
        matches = !fstatat(parent, name, &st, AT_SYMLINK_NOFOLLOW);
        if (!matches && errno != ENOENT) {
            if (parent != root) close(parent);
            free(storage);
            goto done;
        }
        if (!matches) {
            if (parent != root) close(parent);
            free(storage);
            report_changed(path, "missing-payload", json);
            ++findings;
            if (archive_read_data_skip(a) != ARCHIVE_OK) goto done;
            continue;
        }
        matches = (st.st_mode & 07777) == archive_entry_perm(entry) &&
                  st.st_uid == (uid_t)archive_entry_uid(entry) &&
                  st.st_gid == (gid_t)archive_entry_gid(entry);
        if (matches && archive_entry_hardlink(entry))
            matches = S_ISREG(st.st_mode) && compare_hardlink(root, entry, &st);
        else if (matches && archive_entry_filetype(entry) == AE_IFREG)
            matches = compare_file(a, entry, parent, name, &st, &interpreter,
                                   &elf_class, &machine, &script);
        else if (matches && archive_entry_filetype(entry) == AE_IFLNK)
            matches = S_ISLNK(st.st_mode) && compare_link(entry, parent, name);
        else if (matches && archive_entry_filetype(entry) == AE_IFDIR)
            matches = S_ISDIR(st.st_mode);
        else matches = 0;
        if (parent != root) close(parent);
        free(storage);
        if (!matches) {
            report_changed(path, "changed-payload", json);
            ++findings;
        } else if (interpreter) {
            int loader = script == 3 ? -1 : script ? holy_script_target_status(root, interpreter) :
                         interpreter_status(root, interpreter, elf_class, machine);
            if (script == 2 && loader == 1) loader = -1;
            if (loader != 1) {
                report_interpreter(path, interpreter, loader, json,
                                   script == 3 ? "shebang-unparsed" :
                                   script == 2 ? "shebang-env" :
                                   script == 1 ? "shebang" : "elf:PT_INTERP");
                ++findings;
                if (loader < 0) ++unknowns;
                if (loader == -2) unavailable = 1;
            }
        }
        free(interpreter);
        if (archive_read_data_skip(a) != ARCHIVE_OK) goto done;
    }
    if (status != ARCHIVE_EOF) goto done;
    if (json) {
        if (findings)
            printf("{\"schema\":\"holy-check-1\",\"status\":\"%s\",\"coverage\":\"local-payload\",\"checked\":%zu,\"findings\":%zu,\"unknowns\":%zu}\n",
                   findings == unknowns ? "unknown" : "fail", checked, findings, unknowns);
        else printf("{\"schema\":\"holy-check-1\",\"status\":\"pass\",\"coverage\":\"local-payload\",\"checked\":%zu}\n", checked);
    } else if (findings) printf("checked %zu payload objects, %zu findings, %zu unknown\n",
                                checked, findings, unknowns);
    else printf("checked %zu payload objects\n", checked);
    ok = findings == 0;
    completed = 1;
done:
    if (!completed && json)
        puts("{\"schema\":\"holy-check-1\",\"code\":\"check-error\",\"severity\":\"error\",\"status\":\"unknown\"}");
    if (a) archive_read_free(a);
    if (root >= 0) close(root);
    unlink(snapshot);
    free(snapshot);
    return completed && ok ? 0 : unavailable ? 6 : 1;
}