mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 20:20:49 +00:00
Fix python command returning -1 by selective wrapper interception and explicit bypass
This commit is contained in:
parent
14bb6899d8
commit
9417be1ac5
8 changed files with 354 additions and 44 deletions
|
|
@ -2,31 +2,13 @@ import { ui } from "../../environment/userInteraction.js";
|
||||||
import { safeSpawn } from "../../utils/safeSpawn.js";
|
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||||
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||||
import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
|
import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
|
||||||
import { PIP_COMMAND, PIP3_COMMAND, PYTHON_COMMAND, PYTHON3_COMMAND } from "./pipSettings.js";
|
import { PIP_COMMAND, PIP3_COMMAND } from "./pipSettings.js";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import fsSync from "node:fs";
|
import fsSync from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import ini from "ini";
|
import ini from "ini";
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if this pip invocation should bypass safe-chain and spawn directly.
|
|
||||||
* Returns true if the tool is python/python3 but NOT being run with -m pip/pip3.
|
|
||||||
* @param {string} command - The command executable
|
|
||||||
* @param {string[]} args - The arguments
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
function shouldBypassSafeChain(command, args) {
|
|
||||||
if (command === PYTHON_COMMAND || command === PYTHON3_COMMAND) {
|
|
||||||
// Check if args start with -m pip
|
|
||||||
if (args.length >= 2 && args[0] === "-m" && (args[1] === PIP_COMMAND || args[1] === PIP3_COMMAND)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets fallback CA bundle environment variables used by Python libraries.
|
* Sets fallback CA bundle environment variables used by Python libraries.
|
||||||
* These are applied in addition to the PIP_CONFIG_FILE to ensure all Python
|
* These are applied in addition to the PIP_CONFIG_FILE to ensure all Python
|
||||||
|
|
@ -73,23 +55,6 @@ function setFallbackCaBundleEnvironmentVariables(env, combinedCaPath) {
|
||||||
* @returns {Promise<{status: number}>} Exit status of the pip command
|
* @returns {Promise<{status: number}>} Exit status of the pip command
|
||||||
*/
|
*/
|
||||||
export async function runPip(command, args) {
|
export async function runPip(command, args) {
|
||||||
// Check if we should bypass safe-chain (python/python3 without -m pip)
|
|
||||||
if (shouldBypassSafeChain(command, args)) {
|
|
||||||
ui.writeVerbose(`Safe-chain: Bypassing safe-chain for non-pip invocation: ${command} ${args.join(" ")}`);
|
|
||||||
// Spawn the ORIGINAL command with ORIGINAL args
|
|
||||||
const { spawn } = await import("child_process");
|
|
||||||
return new Promise((_resolve) => {
|
|
||||||
const proc = spawn(command, args, { stdio: "inherit" });
|
|
||||||
proc.on("exit", (/** @type {number | null} */ code) => {
|
|
||||||
process.exit(code ?? 0);
|
|
||||||
});
|
|
||||||
proc.on("error", (/** @type {Error} */ err) => {
|
|
||||||
ui.writeError(`Error executing command: ${err.message}`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
|
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,12 +58,22 @@ end
|
||||||
|
|
||||||
# `python -m pip`, `python -m pip3`.
|
# `python -m pip`, `python -m pip3`.
|
||||||
function python
|
function python
|
||||||
wrapSafeChainCommand "python" $argv
|
# Intercept only `python -m pip|pip3`; otherwise call real python
|
||||||
|
if test (count $argv) -ge 2; and test $argv[1] = "-m"; and (test $argv[2] = "pip" -o test $argv[2] = "pip3")
|
||||||
|
wrapSafeChainCommand "python" $argv
|
||||||
|
else
|
||||||
|
command python $argv
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# `python3 -m pip`, `python3 -m pip3'.
|
# `python3 -m pip`, `python3 -m pip3'.
|
||||||
function python3
|
function python3
|
||||||
wrapSafeChainCommand "python3" $argv
|
# Intercept only `python3 -m pip|pip3`; otherwise call real python3
|
||||||
|
if test (count $argv) -ge 2; and test $argv[1] = "-m"; and (test $argv[2] = "pip" -o test $argv[2] = "pip3")
|
||||||
|
wrapSafeChainCommand "python3" $argv
|
||||||
|
else
|
||||||
|
command python3 $argv
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function printSafeChainWarning
|
function printSafeChainWarning
|
||||||
|
|
|
||||||
|
|
@ -54,12 +54,22 @@ function poetry() {
|
||||||
|
|
||||||
# `python -m pip`, `python -m pip3`.
|
# `python -m pip`, `python -m pip3`.
|
||||||
function python() {
|
function python() {
|
||||||
wrapSafeChainCommand "python" "$@"
|
# Intercept only `python -m pip|pip3`; otherwise run the real python to avoid loops/shadowing
|
||||||
|
if [[ "$1" == "-m" && ( "$2" == "pip" || "$2" == "pip3" ) ]]; then
|
||||||
|
wrapSafeChainCommand "python" "$@"
|
||||||
|
else
|
||||||
|
command python "$@"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# `python3 -m pip`, `python3 -m pip3'.
|
# `python3 -m pip`, `python3 -m pip3'.
|
||||||
function python3() {
|
function python3() {
|
||||||
wrapSafeChainCommand "python3" "$@"
|
# Intercept only `python3 -m pip|pip3`; otherwise run the real python3
|
||||||
|
if [[ "$1" == "-m" && ( "$2" == "pip" || "$2" == "pip3" ) ]]; then
|
||||||
|
wrapSafeChainCommand "python3" "$@"
|
||||||
|
else
|
||||||
|
command python3 "$@"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
function printSafeChainWarning() {
|
function printSafeChainWarning() {
|
||||||
|
|
|
||||||
|
|
@ -56,12 +56,22 @@ function poetry {
|
||||||
|
|
||||||
# `python -m pip`, `python -m pip3`.
|
# `python -m pip`, `python -m pip3`.
|
||||||
function python {
|
function python {
|
||||||
Invoke-WrappedCommand 'python' $args
|
# Intercept only `python -m pip|pip3`; otherwise invoke real python
|
||||||
|
if (($args.Length -ge 2) -and ($args[0] -eq '-m') -and (($args[1] -eq 'pip') -or ($args[1] -eq 'pip3'))) {
|
||||||
|
Invoke-WrappedCommand 'python' $args
|
||||||
|
} else {
|
||||||
|
Invoke-RealCommand 'python' $args
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# `python3 -m pip`, `python3 -m pip3'.
|
# `python3 -m pip`, `python3 -m pip3'.
|
||||||
function python3 {
|
function python3 {
|
||||||
Invoke-WrappedCommand 'python3' $args
|
# Intercept only `python3 -m pip|pip3`; otherwise invoke real python3
|
||||||
|
if (($args.Length -ge 2) -and ($args[0] -eq '-m') -and (($args[1] -eq 'pip') -or ($args[1] -eq 'pip3'))) {
|
||||||
|
Invoke-WrappedCommand 'python3' $args
|
||||||
|
} else {
|
||||||
|
Invoke-RealCommand 'python3' $args
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,20 +16,22 @@ const yarnVersion = process.env.YARN_VERSION || "latest";
|
||||||
const pnpmVersion = process.env.PNPM_VERSION || "latest";
|
const pnpmVersion = process.env.PNPM_VERSION || "latest";
|
||||||
|
|
||||||
export class DockerTestContainer {
|
export class DockerTestContainer {
|
||||||
constructor() {
|
constructor(profile = "default") {
|
||||||
this.containerName = `safe-chain-test-${Math.random()
|
this.containerName = `safe-chain-test-${Math.random()
|
||||||
.toString(36)
|
.toString(36)
|
||||||
.substring(2, 15)}`;
|
.substring(2, 15)}`;
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
|
this.profile = profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
static buildImage() {
|
static buildImage(profile = "default") {
|
||||||
try {
|
try {
|
||||||
const buildArgs = [
|
const buildArgs = [
|
||||||
`--build-arg NODE_VERSION=${nodeVersion}`,
|
`--build-arg NODE_VERSION=${nodeVersion}`,
|
||||||
`--build-arg NPM_VERSION=${npmVersion}`,
|
`--build-arg NPM_VERSION=${npmVersion}`,
|
||||||
`--build-arg YARN_VERSION=${yarnVersion}`,
|
`--build-arg YARN_VERSION=${yarnVersion}`,
|
||||||
`--build-arg PNPM_VERSION=${pnpmVersion}`,
|
`--build-arg PNPM_VERSION=${pnpmVersion}`,
|
||||||
|
profile === "pyenv" ? "--build-arg ENABLE_PYENV=true" : "",
|
||||||
].join(" ");
|
].join(" ");
|
||||||
|
|
||||||
execSync(
|
execSync(
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ ARG NPM_VERSION=latest
|
||||||
ARG YARN_VERSION=latest
|
ARG YARN_VERSION=latest
|
||||||
ARG PNPM_VERSION=latest
|
ARG PNPM_VERSION=latest
|
||||||
ARG PYTHON_VERSION=3
|
ARG PYTHON_VERSION=3
|
||||||
|
ARG ENABLE_PYENV=false
|
||||||
|
|
||||||
SHELL ["/bin/bash", "-c"]
|
SHELL ["/bin/bash", "-c"]
|
||||||
ENV BASH_ENV=~/.bashrc
|
ENV BASH_ENV=~/.bashrc
|
||||||
|
|
@ -84,3 +85,10 @@ RUN npm install -g /pkgs/*.tgz
|
||||||
WORKDIR /testapp
|
WORKDIR /testapp
|
||||||
RUN npm init -y
|
RUN npm init -y
|
||||||
|
|
||||||
|
# Optional: install pyenv and set a managed Python version when enabled
|
||||||
|
RUN if [ "$ENABLE_PYENV" = "true" ]; then \
|
||||||
|
apt-get update && apt-get install -y git build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev libffi-dev && \
|
||||||
|
git clone https://github.com/pyenv/pyenv.git /root/.pyenv && \
|
||||||
|
bash -lc 'export PYENV_ROOT=/root/.pyenv; export PATH="$PYENV_ROOT/bin:$PATH"; eval "$(pyenv init -)"; pyenv install 3.12.3; pyenv global 3.12.3'; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -569,6 +569,137 @@ describe("E2E: pip coverage", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it(`python3 --version completes quickly (no recursion/loop)`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
const start = Date.now();
|
||||||
|
const result = await shell.runCommand("python3 --version");
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
durationMs < 3000,
|
||||||
|
`Command should complete quickly. Took ${durationMs}ms. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.match(/Python 3\.\d+\.\d+/),
|
||||||
|
`Should output Python version. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`alias python=python3 still bypasses and is fast`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
await shell.runCommand("alias python=python3");
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
const result = await shell.runCommand("python --version");
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
durationMs < 3000,
|
||||||
|
`Command should complete quickly. Took ${durationMs}ms. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.match(/Python 3\.\d+\.\d+/),
|
||||||
|
`Should output Python 3 version via alias. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!result.output.includes("Safe-chain"),
|
||||||
|
`Non-pip alias should bypass safe-chain. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`alias python=python3, python -m pip goes through safe-chain`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
await shell.runCommand("alias python=python3");
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"python -m pip install --break-system-packages certifi --safe-chain-logging=verbose"
|
||||||
|
);
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
durationMs < 10000,
|
||||||
|
`Install should not hang or loop under alias. Took ${durationMs}ms. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Safe-chain") ||
|
||||||
|
result.output.includes("no malware found"),
|
||||||
|
`pip flow SHOULD be protected even with alias. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Shadowing function tests: user-defined shell function overrides wrapper
|
||||||
|
it(`function python(){ python3 "$@"; } bypasses non-pip and is fast`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
await shell.runCommand('function python(){ python3 "$@"; }');
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
const result = await shell.runCommand("python --version");
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
durationMs < 3000,
|
||||||
|
`Command should complete quickly under shadowing function. Took ${durationMs}ms. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.match(/Python 3\.\d+\.\d+/),
|
||||||
|
`Should output Python 3 version via shadowing function. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!result.output.includes("Safe-chain"),
|
||||||
|
`Non-pip via shadowing function should bypass safe-chain. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`function python(){ python3 "$@"; } with python -m pip is protected`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
await shell.runCommand('function python(){ python3 "$@"; }');
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"python -m pip install --break-system-packages certifi --safe-chain-logging=verbose"
|
||||||
|
);
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
durationMs < 10000,
|
||||||
|
`Install should not hang or loop under shadowing function. Took ${durationMs}ms. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Safe-chain") ||
|
||||||
|
result.output.includes("no malware found"),
|
||||||
|
`pip flow SHOULD be protected even with shadowing function. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`shadow function + python -m pip blocks malware`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
await shell.runCommand('function python(){ python3 "$@"; }');
|
||||||
|
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"python -m pip install --break-system-packages safe-chain-pi-test"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("blocked 1 malicious package downloads:"),
|
||||||
|
`Should block malware under shadow function. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("safe_chain_pi_test@0.0.1"),
|
||||||
|
`Should identify malware package. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Exiting without installing malicious packages."),
|
||||||
|
`Should refuse installation. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const listResult = await shell.runCommand("python -m pip list");
|
||||||
|
assert.ok(
|
||||||
|
!listResult.output.includes("safe-chain-pi-test"),
|
||||||
|
`Malicious package should not be installed. Output was:\n${listResult.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it(`python --version should bypass safe-chain and work normally`, async () => {
|
it(`python --version should bypass safe-chain and work normally`, async () => {
|
||||||
const shell = await container.openShell("zsh");
|
const shell = await container.openShell("zsh");
|
||||||
const result = await shell.runCommand("python --version");
|
const result = await shell.runCommand("python --version");
|
||||||
|
|
@ -817,6 +948,25 @@ describe("E2E: pip coverage", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it(`python3 -m pip completes quickly and shows protection`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
const start = Date.now();
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"python3 -m pip install --break-system-packages certifi --safe-chain-logging=verbose"
|
||||||
|
);
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
durationMs < 10000,
|
||||||
|
`Install should not hang or loop. Took ${durationMs}ms. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Safe-chain") ||
|
||||||
|
result.output.includes("no malware found"),
|
||||||
|
`python3 -m pip SHOULD go through safe-chain. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it(`python3 -m pip3 DOES go through safe-chain (sanity check)`, async () => {
|
it(`python3 -m pip3 DOES go through safe-chain (sanity check)`, async () => {
|
||||||
const shell = await container.openShell("zsh");
|
const shell = await container.openShell("zsh");
|
||||||
const result = await shell.runCommand(
|
const result = await shell.runCommand(
|
||||||
|
|
|
||||||
155
test/e2e/pyenv.e2e.spec.js
Normal file
155
test/e2e/pyenv.e2e.spec.js
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { describe, it, before, after } from "node:test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
import { DockerTestContainer } from "./DockerTestContainer.js";
|
||||||
|
|
||||||
|
let hasPyenv = false;
|
||||||
|
|
||||||
|
describe("E2E: pyenv wrappers only intercept -m pip", () => {
|
||||||
|
let c;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
DockerTestContainer.buildImage("pyenv");
|
||||||
|
c = new DockerTestContainer("pyenv");
|
||||||
|
await c.start();
|
||||||
|
const sh = await c.openShell("bash");
|
||||||
|
// Ensure pyenv is on PATH and shims initialized for this shell
|
||||||
|
await sh.runCommand(
|
||||||
|
'export PYENV_ROOT=/root/.pyenv; export PATH="$PYENV_ROOT/bin:$PATH"; eval "$(pyenv init -)" || true'
|
||||||
|
);
|
||||||
|
await sh.runCommand("pip3 cache purge >/dev/null 2>&1 || true");
|
||||||
|
// Assert pyenv is installed and active
|
||||||
|
const v = await sh.runCommand("pyenv --version || echo MISSING");
|
||||||
|
hasPyenv = /pyenv [0-9]/.test(v);
|
||||||
|
if (hasPyenv) {
|
||||||
|
const versions = await sh.runCommand("pyenv versions || true");
|
||||||
|
// Non-fatal check: ensure at least one version appears
|
||||||
|
assert.match(versions, /(system|\d+\.\d+)/, "pyenv versions should list entries");
|
||||||
|
const whichPy = await sh.runCommand("pyenv which python || true");
|
||||||
|
assert.match(whichPy, /\.pyenv\/versions\//, "pyenv which python should point to pyenv version dir");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pyenv local sets project-specific version", async () => {
|
||||||
|
if (!hasPyenv) return;
|
||||||
|
const sh = await c.openShell("bash");
|
||||||
|
await sh.runCommand("mkdir -p /work/localtest && cd /work/localtest && pyenv local 3.12.3");
|
||||||
|
const inside = await sh.runCommand("cd /work/localtest && python --version 2>&1");
|
||||||
|
assert.match(inside, /Python 3\.12\./);
|
||||||
|
const outside = await sh.runCommand("cd / && pyenv version && python --version 2>&1");
|
||||||
|
assert.notMatch(outside, /3\.12\./, "outside dir should not be forced to 3.12 by local file");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pyenv shell overrides version for current session", async () => {
|
||||||
|
if (!hasPyenv) return;
|
||||||
|
const sh = await c.openShell("bash");
|
||||||
|
// Set shell-specific version
|
||||||
|
await sh.runCommand("pyenv shell 3.12.3");
|
||||||
|
const v1 = await sh.runCommand("pyenv version");
|
||||||
|
assert.match(v1, /3\.12\.3 \(set by PYENV_VERSION/);
|
||||||
|
const p1 = await sh.runCommand("python --version 2>&1");
|
||||||
|
assert.match(p1, /Python 3\.12\./);
|
||||||
|
// Unset and verify it reverts
|
||||||
|
await sh.runCommand("pyenv shell --unset");
|
||||||
|
const v2 = await sh.runCommand("pyenv version");
|
||||||
|
assert.match(v2, /system \(set by .*\.pyenv\/version\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await c.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("python --version bypasses and succeeds", async () => {
|
||||||
|
const sh = await c.openShell("bash");
|
||||||
|
await sh.runCommand("safe-chain setup --include-python >/dev/null 2>&1 || true");
|
||||||
|
const res = await sh.runCommand("python --version 2>&1; echo EXIT:$?\n");
|
||||||
|
assert.ok(/Python\s+\d+\.\d+\.\d+/.test(res.output), `version missing: ${res.output}`);
|
||||||
|
assert.ok(res.output.includes("EXIT:0"), `exit not 0: ${res.output}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("python3 --version bypasses and succeeds", async () => {
|
||||||
|
const sh = await c.openShell("bash");
|
||||||
|
const res = await sh.runCommand("python3 --version 2>&1; echo EXIT:$?\n");
|
||||||
|
assert.ok(/Python\s+3\./.test(res.output), `version missing: ${res.output}`);
|
||||||
|
assert.ok(res.output.includes("EXIT:0"), `exit not 0: ${res.output}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shadowing function: command python succeeds (explicit bypass)", async () => {
|
||||||
|
const sh = await c.openShell("bash");
|
||||||
|
await sh.runCommand("python() { return 1; }; export -f python");
|
||||||
|
const res = await sh.runCommand("type -a python; command python --version 2>&1; echo EXIT:$?\n");
|
||||||
|
assert.ok(/Python\s+\d+\.\d+\.\d+/.test(res.output), `version missing: ${res.output}`);
|
||||||
|
assert.ok(res.output.includes("EXIT:0"), `exit not 0: ${res.output}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("python -m pip install succeeds under wrappers", async () => {
|
||||||
|
const sh = await c.openShell("bash");
|
||||||
|
await sh.runCommand("pip3 cache purge >/dev/null 2>&1 || true");
|
||||||
|
const res = await sh.runCommand("python3 -m pip install --break-system-packages certifi 2>&1\n");
|
||||||
|
assert.ok(res.output.match(/Successfully installed|Requirement already satisfied/), `pip install did not succeed: ${res.output}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("python -m pip blocks malware under pyenv-style shadow", async () => {
|
||||||
|
const sh = await c.openShell("bash");
|
||||||
|
await sh.runCommand("safe-chain setup --include-python >/dev/null 2>&1 || true");
|
||||||
|
await sh.runCommand("pip3 cache purge >/dev/null 2>&1 || true");
|
||||||
|
// Emulate pyenv-style function forwarding
|
||||||
|
await sh.runCommand('python() { python3 "$@"; }; export -f python');
|
||||||
|
|
||||||
|
const res = await sh.runCommand("python -m pip install --break-system-packages safe-chain-pi-test 2>&1\n");
|
||||||
|
assert.ok(
|
||||||
|
res.output.includes("blocked 1 malicious package downloads:"),
|
||||||
|
`Should block malware. Output:\n${res.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
res.output.includes("safe_chain_pi_test@0.0.1"),
|
||||||
|
`Should identify malware package. Output:\n${res.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
res.output.includes("Exiting without installing malicious packages."),
|
||||||
|
`Should refuse installation. Output:\n${res.output}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const list = await sh.runCommand("python -m pip list 2>&1\n");
|
||||||
|
assert.ok(
|
||||||
|
!list.output.includes("safe-chain-pi-test"),
|
||||||
|
`Malicious package should not be installed. Output:\n${list.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("python -m pip works and installs (no shadow)", async () => {
|
||||||
|
const sh = await c.openShell("bash");
|
||||||
|
await sh.runCommand("safe-chain setup --include-python >/dev/null 2>&1 || true");
|
||||||
|
await sh.runCommand("pip3 cache purge >/dev/null 2>&1 || true");
|
||||||
|
|
||||||
|
const res = await sh.runCommand("python -m pip install --break-system-packages certifi --safe-chain-logging=verbose 2>&1\n");
|
||||||
|
assert.ok(
|
||||||
|
res.output.match(/Successfully installed|Requirement already satisfied/),
|
||||||
|
`pip install should succeed. Output:\n${res.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pip3 download works and scans (no shadow)", async () => {
|
||||||
|
const sh = await c.openShell("bash");
|
||||||
|
await sh.runCommand("safe-chain setup --include-python >/dev/null 2>&1 || true");
|
||||||
|
await sh.runCommand("pip3 cache purge >/dev/null 2>&1 || true");
|
||||||
|
|
||||||
|
const res = await sh.runCommand("pip3 download requests --safe-chain-logging=verbose 2>&1\n");
|
||||||
|
assert.ok(
|
||||||
|
res.output.includes("no malware found."),
|
||||||
|
`Download should be scanned. Output:\n${res.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pip3 wheel works and proxies registry (no shadow)", async () => {
|
||||||
|
const sh = await c.openShell("bash");
|
||||||
|
await sh.runCommand("safe-chain setup --include-python >/dev/null 2>&1 || true");
|
||||||
|
await sh.runCommand("pip3 cache purge >/dev/null 2>&1 || true");
|
||||||
|
|
||||||
|
const res = await sh.runCommand("pip3 wheel requests --safe-chain-logging=verbose 2>&1\n");
|
||||||
|
assert.ok(
|
||||||
|
res.output.includes("Safe-chain: Set up MITM tunnel") ||
|
||||||
|
res.output.includes("Finished proxying request"),
|
||||||
|
`Wheel should route via proxy. Output:\n${res.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue