From adc87edc9a4f9d34655e6b00b5751fd7ada8ac2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Haris=20Smajlovi=C4=87?= Date: Sat, 5 Sep 2026 00:25:38 +0200 Subject: [PATCH 1/3] Secure skat preliminarily enabled --- Dockerfile | 12 +- install.sh | 4 +- sfkit/protocol/register_data.py | 57 ++++++ sfkit/protocol/run_protocol.py | 3 + sfkit/utils/constants.py | 2 +- sfkit/utils/sfskat_protocol.py | 295 +++++++++++++++++++++++++++ tests/protocol/test_register_data.py | 5 + tests/protocol/test_run_protocol.py | 4 + 8 files changed, 379 insertions(+), 3 deletions(-) create mode 100644 sfkit/utils/sfskat_protocol.py diff --git a/Dockerfile b/Dockerfile index 63c0819..6e7b5ee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,6 +61,15 @@ RUN git clone https://github.com/froelich/sf-relate . && \ rm -rf .git +# -------------------- sf-skat -------------------- # +FROM go AS sf-skat + +RUN git clone https://github.com/swanhong/secure-skat . && \ + git checkout b79ed20 && \ + go build -o secure-rvas secure-rvas.go && \ + rm -rf .git + + # -------------------- sfkit-proxy -------------------- # FROM go AS sfkit-proxy @@ -201,7 +210,7 @@ RUN microdnf install -y proxychains-ng && \ ENV HOME=${WORK} \ OPENSSL_FORCE_FIPS_MODE=1 \ - PATH="${WORK}/.venv/bin:$PATH:${WORK}:${WORK}/sfgwas:${WORK}/sf-relate:${WORK}/sfgwas-lmm/scripts" \ + PATH="${WORK}/.venv/bin:$PATH:${WORK}:${WORK}/sfgwas:${WORK}/sf-relate:${WORK}/sfgwas-lmm/scripts:${WORK}/sf-skat" \ PYTHONPATH="${WORK}/.venv/lib/python3.12/site-packages:${WORK}/.venv/lib64/python3.12/site-packages" \ PYTHONUNBUFFERED=TRUE \ PYTHONWARNINGS="ignore:pkg_resources is deprecated as an API:UserWarning" \ @@ -215,6 +224,7 @@ COPY --from=secure-gwas --chown=$USER ${WORK} ./secure-gwas/ COPY --from=sfgwas --chown=$USER ${WORK} ./sfgwas/ COPY --from=sfgwas-lmm --chown=$USER ${WORK} ./sfgwas-lmm/ COPY --from=sf-relate --chown=$USER ${WORK} ./sf-relate/ +COPY --from=sf-skat --chown=$USER ${WORK}/secure-rvas ./sf-skat/secure-rvas COPY --from=sfkit-proxy --chown=$USER ${WORK}/*-proxy ./ COPY --from=sfkit --chown=$USER ${WORK}/dist/sfkit*.whl ./ diff --git a/install.sh b/install.sh index 03fffe9..6b899c0 100755 --- a/install.sh +++ b/install.sh @@ -62,6 +62,7 @@ rm -rf ~/.local/sf-relate && mv sf-relate ~/.local/ rm -rf ~/.local/secure-dti && mv secure-dti ~/.local/ rm -rf ~/.local/secure-gwas && mv secure-gwas ~/.local/ rm -rf ~/.local/sfgwas-lmm && mv sfgwas-lmm ~/.local/ +rm -rf ~/.local/sf-skat && mv sf-skat ~/.local/ echo # check if ldd version is at least 2.34 @@ -69,7 +70,7 @@ glibc_minor_ver=$(ldd --version | awk 'NR==1{print $NF}' | cut -d. -f2) if [ "${glibc_minor_ver}" -lt 34 ]; then echo Patching sfkit binaries... mkdir -p ~/.local/lib/ && mv lib/* ~/.local/lib/ - for p in ~/.local/bin/sfkit-proxy ~/.local/sfgwas/sfgwas ~/.local/sf-relate/sf-relate ~/.local/secure-dti/mpc/code/bin/* ~/.local/secure-gwas/code/bin/* ; do + for p in ~/.local/bin/sfkit-proxy ~/.local/sfgwas/sfgwas ~/.local/sf-relate/sf-relate ~/.local/sf-skat/secure-rvas ~/.local/secure-dti/mpc/code/bin/* ~/.local/secure-gwas/code/bin/* ; do patchelf --set-interpreter ~/.local/lib/ld-linux-x86-64.so.2 "$p" done echo @@ -90,6 +91,7 @@ $PWD/.venv/bin:\ \$HOME/.local/sfgwas:\ \$HOME/.local/sfgwas-lmm:\ \$HOME/.local/sf-relate:\ +\$HOME/.local/sf-skat:\ \$HOME/.local/secure-dti/mpc/code/bin:\ \$HOME/.local/secure-gwas/code/bin\ \"" >> "$rc" diff --git a/sfkit/protocol/register_data.py b/sfkit/protocol/register_data.py index 675d3e3..296898f 100644 --- a/sfkit/protocol/register_data.py +++ b/sfkit/protocol/register_data.py @@ -58,6 +58,8 @@ def register_data( data_path = validate_sfrelate(doc_ref_dict, username, data_path, role) elif study_type == "Secure-DTI": data_path = validate_dti(doc_ref_dict, username, data_path, role) + elif study_type == "SF-SKAT": + data_path = validate_skat(doc_ref_dict, username, data_path, role) else: raise ValueError(f"Unknown study type: {study_type}") @@ -288,6 +290,61 @@ def validate_dti(doc_ref_dict: dict, username: str, data_path: str, role: str) - return data_path +def validate_skat( + doc_ref_dict: dict, username: str, data_path: str, role: str +) -> str: + """ + Validate data for the SF-SKAT workflow. + + SF-SKAT does not run data preparation itself: data_path must already contain + a `prepared/` directory tree produced by running `secure-rvas prepare` + out-of-band (see https://github.com/swanhong/secure-skat). data_path is the + directory that *contains* `prepared/` (i.e. secure-rvas's `run_dir`). + + Only called for roles 1 (Cohort A) and 2 (Cohort B) -- role 0 (auxiliary) + holds no data and never reaches this function. + """ + data_path = validate_data_path(data_path) + + if data_path == "demo" or (constants.IS_DOCKER and doc_ref_dict["demo"]): + using_demo() + + ancestries = [ + a.strip() + for a in doc_ref_dict["parameters"]["ancestries"]["value"].split(",") + if a.strip() + ] + chromosomes = [ + c.strip() + for c in doc_ref_dict["parameters"]["chromosomes"]["value"].split(",") + if c.strip() + ] + cohort = "A" if role == "1" else "B" + + for ancestry in ancestries: + for chromosome in chromosomes: + chr_dir = os.path.join(data_path, "prepared", ancestry, f"chr{chromosome}") + condition_or_fail( + os.path.isfile(os.path.join(chr_dir, "genes.txt")), + f"Could not find {chr_dir}/genes.txt", + ) + condition_or_fail( + os.path.isfile(os.path.join(chr_dir, "block_sizes.txt")), + f"Could not find {chr_dir}/block_sizes.txt", + ) + cohort_dir = os.path.join(chr_dir, cohort) + condition_or_fail( + os.path.isfile(os.path.join(cohort_dir, "cov.txt")), + f"Could not find {cohort_dir}/cov.txt", + ) + condition_or_fail( + os.path.isfile(os.path.join(cohort_dir, "pheno.txt")), + f"Could not find {cohort_dir}/pheno.txt", + ) + + return data_path + + def validate_geno_binary_file_prefix(geno_binary_file_prefix: str) -> str: if not geno_binary_file_prefix: if constants.IS_DOCKER and os.path.exists("/data/geno"): diff --git a/sfkit/protocol/run_protocol.py b/sfkit/protocol/run_protocol.py index 12611e1..3c6fe1e 100644 --- a/sfkit/protocol/run_protocol.py +++ b/sfkit/protocol/run_protocol.py @@ -9,6 +9,7 @@ from sfkit.utils.sfgwas_lmm_protocol import run_sfgwas_lmm_protocol from sfkit.utils.sfgwas_protocol import run_sfgwas_protocol from sfkit.utils.sfrelate_protocol import run_sfrelate_protocol +from sfkit.utils.sfskat_protocol import run_sfskat_protocol def run_protocol( @@ -93,6 +94,8 @@ def run_protocol( run_sfrelate_protocol(role, demo) elif study_type == "Secure-DTI": run_dti_protocol(role, demo) + elif study_type == "SF-SKAT": + run_sfskat_protocol(role, demo) else: raise ValueError(f"Unknown study type: {study_type}") else: diff --git a/sfkit/utils/constants.py b/sfkit/utils/constants.py index 521cf6e..9c4ee7d 100644 --- a/sfkit/utils/constants.py +++ b/sfkit/utils/constants.py @@ -39,7 +39,7 @@ def is_installed(binary: str) -> bool: if os.environ.get("PATH"): os.environ[ "PATH" - ] += f":{EXECUTABLES_PREFIX}bin:{EXECUTABLES_PREFIX}sfgwas:{EXECUTABLES_PREFIX}sf-relate:{EXECUTABLES_PREFIX}secure-gwas/code/bin" + ] += f":{EXECUTABLES_PREFIX}bin:{EXECUTABLES_PREFIX}sfgwas:{EXECUTABLES_PREFIX}sf-relate:{EXECUTABLES_PREFIX}sf-skat:{EXECUTABLES_PREFIX}secure-gwas/code/bin" SFKIT_PREFIX = "sfkit: " OUT_FOLDER = os.path.join(os.environ.get("SFKIT_DIR", ""), "out") diff --git a/sfkit/utils/sfskat_protocol.py b/sfkit/utils/sfskat_protocol.py new file mode 100644 index 0000000..b70ab9f --- /dev/null +++ b/sfkit/utils/sfskat_protocol.py @@ -0,0 +1,295 @@ +""" +Run the SF-SKAT protocol + +SF-SKAT (https://github.com/swanhong/secure-skat) does not perform data preparation +itself: participants must have already run ``secure-rvas prepare`` out-of-band to +produce a ``prepared/`` directory tree, and register that directory's parent (the +tool's ``run_dir``) with sfkit. Role 0 is an auxiliary, data-free compute party +(like CP0 in SF-GWAS); role 1 is Cohort A; role 2 is Cohort B. +""" + +import hashlib +import os +import random +import time + +import tomlkit +from nacl.encoding import HexEncoder +from nacl.public import Box, PrivateKey, PublicKey + +from sfkit.api import get_doc_ref_dict, update_firestore, website_send_file +from sfkit.utils import constants +from sfkit.utils.helper_functions import ( + condition_or_fail, + copy_results_to_cloud_storage, + copy_to_out_folder, + install_go, + run_command, +) +from sfkit.utils.sfgwas_helper_functions import boot_sfkit_proxy, to_float_int_or_bool +from sfkit.utils.sfgwas_protocol import sync_with_other_vms + + +def run_sfskat_protocol(role: str, demo: bool = False) -> None: + print("\n\n Begin running SF-SKAT protocol \n\n") + if not (constants.IS_DOCKER or constants.IS_INSTALLED_VIA_SCRIPT): + install_sfskat() + + doc_ref_dict: dict = get_doc_ref_dict() + ancestries = parse_list_param(doc_ref_dict["parameters"]["ancestries"]["value"], ["EUR"]) + + if not demo: + generate_shared_keys(int(role), ancestries) + + print("Begin updating config files") + config_path = update_config(role) + + sync_with_other_vms(role, demo) + start_sfskat(role, config_path) + + +def install_sfskat() -> None: + update_firestore("update_firestore::task=Installing dependencies") + print("Begin installing dependencies") + + install_go() + + if os.path.isdir("sf-skat"): + print("sf-skat already exists") + else: + print("Installing sf-skat") + run_command(["git", "clone", "https://github.com/swanhong/secure-skat.git", "sf-skat"]) + cwd = os.getcwd() + os.chdir("sf-skat") + run_command(["go", "build", "-o", "secure-rvas", "secure-rvas.go"]) + os.chdir(cwd) + + print("Finished installing dependencies") + + +def parse_list_param(value, default: list) -> list: + if not value: + return default + items = [item.strip() for item in str(value).split(",") if item.strip()] + return items or default + + +def get_registered_data_path() -> str: + data_path_file = os.path.join(constants.SFKIT_DIR, "data_path.txt") + with open(data_path_file, "r") as f: + data_path = f.readline().rstrip() + condition_or_fail(bool(data_path), "Data path not found in data_path.txt") + return data_path + + +def get_run_dir(role: str) -> str: + # Role 0 (auxiliary) holds no data and never registers a data_path; it just + # needs a local working directory for secure-rvas to write metrics into. + if role == "0": + run_dir = os.path.join(constants.SFKIT_DIR, "skat_run") + os.makedirs(run_dir, exist_ok=True) + return run_dir + return get_registered_data_path() + + +def shared_keys_root() -> str: + return os.path.join(constants.SFKIT_DIR, "skat_shared_keys") + + +def generate_shared_keys(role: int, ancestries: list) -> None: + """ + Derive the per-ancestry shared PRG keys secure-rvas expects + (shared_key_global.bin and shared_key_{a}_{b}.bin for every pair + involving this party), without any extra communication: each pairwise + key is a Diffie-Hellman shared secret (deriving identically on both + ends via NaCl Box), and the global key is deterministically seeded + from CP0's public key, exactly as sfkit already does for SF-GWAS. + """ + doc_ref_dict: dict = get_doc_ref_dict() + update_firestore("update_firestore::task=Generating cryptographic keys") + print("Generating shared keys...") + + private_key_path = os.path.join(constants.SFKIT_DIR, "my_private_key.txt") + with open(private_key_path, "r") as f: + my_private_key = PrivateKey(f.readline().rstrip().encode(), encoder=HexEncoder) + + pairwise_base_keys: dict = {} + for i, other_username in enumerate(doc_ref_dict["participants"]): + if i == role: + continue + other_public_key_str: str = doc_ref_dict["personal_parameters"][other_username]["PUBLIC_KEY"]["value"] + while not other_public_key_str: + print(f"No public key found for {other_username}. Waiting...") + time.sleep(5) + doc_ref_dict = get_doc_ref_dict() + other_public_key_str = doc_ref_dict["personal_parameters"][other_username]["PUBLIC_KEY"]["value"] + other_public_key = PublicKey(other_public_key_str.encode(), encoder=HexEncoder) + condition_or_fail( + my_private_key != other_public_key, + "Private and public keys must be different", + ) + pairwise_base_keys[i] = Box(my_private_key, other_public_key).shared_key() + + cp0_username: str = doc_ref_dict["participants"][0] + cp0_public_key_str: str = doc_ref_dict["personal_parameters"][cp0_username]["PUBLIC_KEY"]["value"] + + root = shared_keys_root() + for ancestry in ancestries: + ancestry_dir = os.path.join(root, ancestry) + os.makedirs(ancestry_dir, exist_ok=True) + + random.seed(f"{cp0_public_key_str}|{ancestry}") + global_key = random.getrandbits(256).to_bytes(32, "big") + with open(os.path.join(ancestry_dir, "shared_key_global.bin"), "wb") as f: + f.write(global_key) + + for other_role, base_key in pairwise_base_keys.items(): + derived = hashlib.sha256(base_key + ancestry.encode()).digest() + a, b = sorted((role, other_role)) + with open(os.path.join(ancestry_dir, f"shared_key_{a}_{b}.bin"), "wb") as f: + f.write(derived) + + print(f"Shared keys generated and saved to {root}.") + + +def update_config(role: str) -> str: + """ + Build the flat run_config.toml that secure-rvas's ``party`` subcommand expects. + Only fields that matter at MPC runtime (as opposed to ``prepare``, which sfkit + does not invoke) are driven by study parameters; the rest are unused + placeholders kept just to satisfy secure-rvas's config validation. + """ + doc_ref_dict: dict = get_doc_ref_dict() + pars = {**doc_ref_dict["parameters"], **doc_ref_dict["advanced_parameters"]} + + def scalar(key: str, default): + raw = pars.get(key, {}).get("value", "") + return to_float_int_or_bool(raw) if raw != "" else default + + ancestries = parse_list_param(pars.get("ancestries", {}).get("value"), ["EUR"]) + chromosomes = [int(c) for c in parse_list_param(pars.get("chromosomes", {}).get("value"), ["21", "22"])] + masks = parse_list_param(pars.get("masks", {}).get("value"), ["LoF=HC"]) + phenotype_columns = parse_list_param(pars.get("phenotype_columns", {}).get("value"), ["phenotype1"]) + + data = { + "run_dir": get_run_dir(role), + "chromosomes": chromosomes, + # The following fields are only used by `secure-rvas prepare`, which sfkit + # does not invoke (participants must already have run it out-of-band). + # They're set to placeholders solely to satisfy config validation. + "genotype": "unused", + "gene_panel": "unused", + "annotation": "unused", + "phenotype": "unused", + "covariate": "unused", + "ancestry": "unused", + "phenotype_id_column": "IID", + "covariate_id_column": "IID", + "covariate_column": "unused", + "ancestry_id_column": "IID", + "ancestry_column": "unused", + "samples_per_cohort": 0, + "sample_seed": 42, + "role_seed": 42, + "gene_selection": {"mode": "all"}, + # Operative fields, driven by study parameters. + "phenotype_columns": phenotype_columns, + "ancestries": ancestries, + "num_cov": scalar("num_cov", 16), + "masks": masks, + "max_maf": scalar("max_maf", 0.01), + "ckks": scalar("ckks", "PN14QP436S45"), + "mpc_num_threads": scalar("mpc_num_threads", 2), + "data_bits": scalar("data_bits", 60), + "fractional_bits": scalar("fractional_bits", 30), + "probes": scalar("probes", 30), + "seed": scalar("seed", 42), + "plink2_bin": "plink2", + "binding_ipaddr": "0.0.0.0", + "servers": {}, + } + + for i, participant in enumerate(doc_ref_dict["participants"]): + ip_addr = doc_ref_dict["personal_parameters"][participant]["IP_ADDRESS"]["value"] + ports: list = doc_ref_dict["personal_parameters"][participant]["PORTS"]["value"].split(",") + + server = { + "ipaddr": "127.0.0.1" if constants.SFKIT_PROXY_ON else ip_addr, + "ports": {}, + } + for j, port in enumerate(ports): + if port != "null" and i != j: + server["ports"][f"party{j}"] = port + data["servers"][f"party{i}"] = server + + config_path = os.path.join(constants.SFKIT_DIR, "skat_config.toml") + with open(config_path, "w") as f: + f.write(tomlkit.dumps(data)) + + return config_path + + +def start_sfskat(role: str, config_path: str) -> None: + update_firestore("update_firestore::task=Performing SF-SKAT protocol") + print("\n\n starting SF-SKAT \n\n") + + sfkit_proxy = None + if constants.SFKIT_PROXY_ON: + sfkit_proxy = boot_sfkit_proxy(role, config_path) + + binary = ( + "secure-rvas" + if (constants.IS_DOCKER or constants.IS_INSTALLED_VIA_SCRIPT) + else os.path.join("sf-skat", "secure-rvas") + ) + + run_command( + [ + binary, + "party", + "--config", config_path, + "--party", role, + "--shared-keys", shared_keys_root(), + ], + fail_message="Failed SF-SKAT protocol", + role=role, + ) + + if sfkit_proxy: + sfkit_proxy.terminate() + + print("\n\n Finished SF-SKAT \n\n") + + if role == "1": + process_output_files(role) + + update_firestore("update_firestore::status=Finished protocol!") + + +def process_output_files(role: str) -> None: + # NOTE: demo mode isn't supported for SF-SKAT (there's no bundled demo + # dataset, since sfkit never runs `secure-rvas prepare` itself), so this + # always reads the data path registered via `sfkit register_data`. + doc_ref_dict: dict = get_doc_ref_dict() + user_id: str = doc_ref_dict["participants"][int(role)] + + run_dir = get_registered_data_path() + secure_dir = os.path.join(run_dir, "secure") + metrics_dir = os.path.join(run_dir, "metrics") + + copy_to_out_folder([secure_dir, metrics_dir]) + + if results_path := doc_ref_dict["personal_parameters"][user_id].get("RESULTS_PATH", {}).get("value", ""): + copy_results_to_cloud_storage(role, results_path, secure_dir) + + send_results: str = doc_ref_dict["personal_parameters"][user_id].get("SEND_RESULTS", {}).get("value") + if send_results == "Yes": + pars = {**doc_ref_dict["parameters"], **doc_ref_dict["advanced_parameters"]} + ancestries = parse_list_param(pars.get("ancestries", {}).get("value"), ["EUR"]) + for ancestry in ancestries: + results_file = os.path.join(secure_dir, ancestry, "all_secure_results.tsv") + if os.path.exists(results_file): + with open(results_file, "rb") as f: + website_send_file(f, f"{ancestry}_all_secure_results.tsv") + + print("Finished processing output files") diff --git a/tests/protocol/test_register_data.py b/tests/protocol/test_register_data.py index 65d3710..fd6636a 100644 --- a/tests/protocol/test_register_data.py +++ b/tests/protocol/test_register_data.py @@ -21,6 +21,7 @@ def test_register_data(mocker: Callable[..., Generator[MockerFixture, None, None ) mocker.patch("sfkit.protocol.register_data.validate_mpcgwas") mocker.patch("sfkit.protocol.register_data.validate_pca") + mocker.patch("sfkit.protocol.register_data.validate_skat", return_value="data_path") mocker.patch("sfkit.protocol.register_data.encrypt_mpcgwas") mocker.patch( "sfkit.protocol.register_data.checksumdir.dirhash", return_value="sha1hash" @@ -50,6 +51,10 @@ def test_register_data(mocker: Callable[..., Generator[MockerFixture, None, None local_mock_doc_ref_dict["study_type"] = "PCA" register_data.register_data("geno_binary_file_prefix", "data_path") + # Test SF-SKAT + local_mock_doc_ref_dict["study_type"] = "SF-SKAT" + register_data.register_data("geno_binary_file_prefix", "data_path") + # Test unknown study type local_mock_doc_ref_dict["study_type"] = "UNKNOWN-STUDY-TYPE" with pytest.raises(ValueError): diff --git a/tests/protocol/test_run_protocol.py b/tests/protocol/test_run_protocol.py index cdff6ec..952f266 100644 --- a/tests/protocol/test_run_protocol.py +++ b/tests/protocol/test_run_protocol.py @@ -17,6 +17,7 @@ def test_run_protocol(mocker): mocker.patch("sfkit.protocol.run_protocol.run_gwas_protocol") mocker.patch("sfkit.protocol.run_protocol.run_sfgwas_protocol") mocker.patch("sfkit.protocol.run_protocol.run_pca_protocol") + mocker.patch("sfkit.protocol.run_protocol.run_sfskat_protocol") mocker.patch( "sfkit.protocol.run_protocol.other_participant_not_ready", side_effect=[True] + [False] * 10, @@ -44,6 +45,9 @@ def test_run_protocol(mocker): mock_doc_ref_dict_copy["study_type"] = "PCA" run_protocol.run_protocol() + mock_doc_ref_dict_copy["study_type"] = "SF-SKAT" + run_protocol.run_protocol() + mock_doc_ref_dict_copy["study_type"] = "garbage" mock_doc_ref_dict_copy["demo"] = True with pytest.raises(ValueError): From 4170f2e536b6db5d0bac3c251a3604b0485600c4 Mon Sep 17 00:00:00 2001 From: Denis Loginov <137337+dinvlad@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:48:29 -0400 Subject: [PATCH 2/3] Fix rvas copy instruction in Dockefile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 6e7b5ee..e126efa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -224,7 +224,7 @@ COPY --from=secure-gwas --chown=$USER ${WORK} ./secure-gwas/ COPY --from=sfgwas --chown=$USER ${WORK} ./sfgwas/ COPY --from=sfgwas-lmm --chown=$USER ${WORK} ./sfgwas-lmm/ COPY --from=sf-relate --chown=$USER ${WORK} ./sf-relate/ -COPY --from=sf-skat --chown=$USER ${WORK}/secure-rvas ./sf-skat/secure-rvas +COPY --from=sf-skat --chown=$USER ${WORK}/*-rvas ./sf-skat/ COPY --from=sfkit-proxy --chown=$USER ${WORK}/*-proxy ./ COPY --from=sfkit --chown=$USER ${WORK}/dist/sfkit*.whl ./ From 97a48b0a314adc324eb502b48ea80c2b28fe51e0 Mon Sep 17 00:00:00 2001 From: Denis Loginov <137337+dinvlad@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:51:22 -0400 Subject: [PATCH 3/3] Update Dockerfile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index e126efa..aa6e93a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -224,7 +224,7 @@ COPY --from=secure-gwas --chown=$USER ${WORK} ./secure-gwas/ COPY --from=sfgwas --chown=$USER ${WORK} ./sfgwas/ COPY --from=sfgwas-lmm --chown=$USER ${WORK} ./sfgwas-lmm/ COPY --from=sf-relate --chown=$USER ${WORK} ./sf-relate/ -COPY --from=sf-skat --chown=$USER ${WORK}/*-rvas ./sf-skat/ +COPY --from=sf-skat --chown=$USER ${WORK}/*-rvas ./sf-skat/ COPY --from=sfkit-proxy --chown=$USER ${WORK}/*-proxy ./ COPY --from=sfkit --chown=$USER ${WORK}/dist/sfkit*.whl ./