Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/scripts/install-test-deps.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/bin/sh

# Installs the packages that .github/scripts/test-curl-oauth-live.sh needs:
# curl and a python interpreter. Only missing packages are installed, so the
# script does nothing on images that already have both. RHEL/CentOS 7 images
# supply curl and python2 already, so no package manager runs there.

set -eu

have() {
command -v "$1" >/dev/null 2>&1
}

have_python() {
have python3 || have python2 || have python
}

if have curl && have_python; then
echo 'curl and python are already present; no packages to install.'
exit 0
fi

if have apk; then
packages=""
have curl || packages="$packages curl"
have_python || packages="$packages python3"
# shellcheck disable=SC2086 # deliberate word splitting into package names
apk add --no-cache $packages
elif have dnf; then
packages=""
have curl || packages="$packages curl"
have_python || packages="$packages python3"
# shellcheck disable=SC2086 # deliberate word splitting into package names
dnf install -y -q $packages
elif have yum; then
packages=""
have curl || packages="$packages curl"
have_python || packages="$packages python"
# shellcheck disable=SC2086 # deliberate word splitting into package names
yum install -y -q $packages
elif have apt-get; then
packages=""
have curl || packages="$packages curl"
have_python || packages="$packages python3"
apt-get update -qq
# shellcheck disable=SC2086 # deliberate word splitting into package names
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq $packages
else
echo 'ERROR: no supported package manager found (apk, dnf, yum, apt-get).' >&2
exit 1
fi

echo 'Test dependencies are installed.'
167 changes: 167 additions & 0 deletions .github/scripts/test-credential-handling.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
#!/bin/bash

set -euo pipefail

repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
work_dir=$(mktemp -d)
trap 'rm -rf "$work_dir"' EXIT

fail() {
echo "FAIL: $*" >&2
exit 1
}

# Report an absent or broken search tool as a failure instead of a pass. grep
# returns 0 for a match, 1 for no match, and 2 or more for an error. Only 1 is
# an acceptable result here.
assert_no_match() {
local description=$1 pattern=$2 tree=$3 include=$4
local output status

set +e
output=$(grep -rnE "$pattern" "$repo_root/$tree" --include="$include")
status=$?
set -e

if [ "$status" -eq 0 ]; then
echo "$output" >&2
fail "$description"
fi
if [ "$status" -ne 1 ]; then
fail "grep failed with status $status while checking: $description"
fi
}

curl() {
printf '%s\n' "$@" >"$CURL_ARGS_FILE"
cat >"$CURL_STDIN_FILE"
}

test_curl_helper() {
local script=$1 token_mode=$2
local helper mode expected_key

helper=$(awk '/^curl_command\(\)/,/^}/' "$repo_root/$script")
[ -n "$helper" ] || fail "curl_command not found in $script"
eval "$helper"

# Mode 1 uses the oauth2-bearer configuration key. Mode 0 is the fallback
# for curl older than 7.33.0 and uses a raw Authorization header. Both must
# keep the credential on stdin.
for mode in 1 0; do
# shellcheck disable=SC2034 # read by the curl_command body under eval
curl_has_oauth2_bearer=$mode
if [ "$mode" -eq 1 ]; then
expected_key='oauth2-bearer = "REGRESSION_SECRET_TOKEN"'
else
expected_key='header = "Authorization: Bearer REGRESSION_SECRET_TOKEN"'
fi

CURL_ARGS_FILE="$work_dir/args"
CURL_STDIN_FILE="$work_dir/stdin"
# shellcheck disable=SC2034 # read by the curl_command body under eval
proxy=""
cs_falcon_oauth_token="REGRESSION_SECRET_TOKEN"

if [ "$token_mode" = "argument" ]; then
curl_command "$cs_falcon_oauth_token" "https://api.example.invalid/resource"
else
curl_command "https://api.example.invalid/resource"
fi

if grep -qF "$cs_falcon_oauth_token" "$CURL_ARGS_FILE"; then
fail "$script exposed the bearer token in curl arguments (mode=$mode)"
fi
grep -qF 'https://api.example.invalid/resource' "$CURL_ARGS_FILE" ||
fail "$script did not pass the expected URL (mode=$mode)"
grep -qF "$cs_falcon_oauth_token" "$CURL_STDIN_FILE" ||
fail "$script did not provide the bearer token through stdin (mode=$mode)"
grep -qF "$expected_key" "$CURL_STDIN_FILE" ||
fail "$script did not use the expected credential mechanism (mode=$mode)"
grep -qF -- '--proto' "$CURL_ARGS_FILE" ||
fail "$script did not restrict the request protocol (mode=$mode)"
grep -qF -- '--proto-redir' "$CURL_ARGS_FILE" ||
fail "$script did not restrict the redirect protocol (mode=$mode)"
done
}

test_curl_helper \
bash/containers/falcon-container-sensor-pull/falcon-container-sensor-pull.sh argument
test_curl_helper bash/install/falcon-linux-install.sh global
test_curl_helper bash/install/falcon-linux-uninstall.sh global
test_curl_helper bash/migrate/falcon-linux-migrate.sh global

test_xtrace_guard() {
local script=$1 guard trace_file

guard=$(awk '/^case \$- in$/,/^esac$/' "$repo_root/$script")
[ -n "$guard" ] || fail "xtrace guard not found in $script"
trace_file="$work_dir/xtrace"

FALCON_CLIENT_SECRET="XTRACE_SECRET_SENTINEL" \
bash -xc "$guard; : \"\$FALCON_CLIENT_SECRET\"" \
>/dev/null 2>"$trace_file"

if grep -qF 'XTRACE_SECRET_SENTINEL' "$trace_file"; then
fail "$script allowed a credential into bash xtrace output"
fi
}

test_xtrace_guard bash/containers/falcon-container-sensor-pull/falcon-container-sensor-pull.sh
test_xtrace_guard bash/install/falcon-linux-install.sh
test_xtrace_guard bash/install/falcon-linux-uninstall.sh
test_xtrace_guard bash/migrate/falcon-linux-migrate.sh

test_hash_verification() {
local script=$1 helper test_file expected_sha

helper=$(awk '/^verify_sha256\(\)/,/^}/' "$repo_root/$script")
[ -n "$helper" ] || fail "verify_sha256 not found in $script"
(
eval "$helper"
# verify_sha256 calls die on a mismatch; keep the stub inside the subshell.
# shellcheck disable=SC2329 # invoked indirectly by verify_sha256
die() { exit 1; }

test_file="$work_dir/installer"
printf '%s' 'verified installer content' >"$test_file"
expected_sha=$(openssl dgst -sha256 "$test_file" | awk '{ print $NF }')
verify_sha256 "$test_file" "$expected_sha" ||
fail "$script rejected a valid installer hash"

if (verify_sha256 "$test_file" '0000000000000000000000000000000000000000000000000000000000000000'); then
fail "$script accepted an invalid installer hash"
fi
[ ! -e "$test_file" ] || fail "$script retained an installer with an invalid hash"
)
}

test_hash_verification bash/install/falcon-linux-install.sh
test_hash_verification bash/migrate/falcon-linux-migrate.sh

# The arguments below are grep patterns, not shell expansions.
# shellcheck disable=SC2016
assert_no_match \
'a Bash error path exposes a credential or raw maintenance-token response' \
'Invalid Access Token:.*\$cs_falcon_oauth_token|Failed to retrieve maintenance token\. Response:' \
bash '*.sh'

# shellcheck disable=SC2016
assert_no_match \
'an EC2 metadata token is exposed in curl arguments' \
'curl .*X-aws-ec2-metadata-token:.*\$token' \
bash '*.sh'

# shellcheck disable=SC2016
assert_no_match \
'an AWS SSM Parameter Store error path prints the decrypted response body' \
'AWS SSM Parameter Store[^"]*\$response' \
bash '*.sh'

# shellcheck disable=SC2016
assert_no_match \
'a PowerShell log statement exposes an authentication or installer token' \
'(Invoke-FalconAuth|GetToken) - \$content:|Retrieved maintenance token:|Starting .*parameters.*\$(Install|Uninstall)Params' \
powershell '*.ps1'

echo 'PASS: credential handling regression checks'
177 changes: 177 additions & 0 deletions .github/scripts/test-curl-oauth-live.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
#!/bin/sh

# Confirms how the installed curl handles an OAuth 2 bearer credential that
# arrives on its configuration input. The script adapts to the curl it finds,
# so it runs on RHEL/CentOS 7 (curl 7.29.0, python2) with no extra packages.

set -eu

work_dir=$(mktemp -d)
server_pids=""

cleanup() {
for pid in $server_pids; do
kill "$pid" 2>/dev/null || true
done
rm -rf "$work_dir"
}
trap cleanup EXIT

fail() {
echo "FAIL: $*" >&2
exit 1
}

python_bin=""
for candidate in python3 python2 python; do
if command -v "$candidate" >/dev/null 2>&1; then
python_bin=$candidate
break
fi
done
[ -n "$python_bin" ] || fail 'no python interpreter is available for the capture server'

server_script="$work_dir/capture_server.py"
cat >"$server_script" <<'PY'
import sys

try:
from http.server import BaseHTTPRequestHandler, HTTPServer
except ImportError: # Python 2, which is all that RHEL/CentOS 7 provides.
from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer

output_path = sys.argv[1]
port = int(sys.argv[2])
redirect_to = sys.argv[3] if len(sys.argv) > 3 else ""


class Handler(BaseHTTPRequestHandler):
def do_GET(self):
# The marker shows that a request arrived. Without it, an empty capture
# file cannot show the difference between a credential that curl
# removed and a connection that never happened.
with open(output_path, "w") as handle:
handle.write("REQUEST_ARRIVED|auth=[%s]"
% self.headers.get("Authorization", ""))
if redirect_to:
self.send_response(302)
self.send_header("Location", redirect_to)
else:
self.send_response(204)
self.end_headers()

def log_message(self, *args):
pass


HTTPServer.allow_reuse_address = True
# Bind every interface. curl can resolve "localhost" to ::1, and a server bound
# only to 127.0.0.1 refuses that connection and gives a false failure.
server = HTTPServer(("0.0.0.0", port), Handler)

with open(output_path + ".ready", "w") as handle:
handle.write("ready")

# Serve one request, then stop. The timeout makes sure the process always ends.
server.timeout = 20
server.handle_request()
PY

start_server() {
output=$1
port=$2
redirect_to=${3:-}

rm -f "$output" "$output.ready"
"$python_bin" "$server_script" "$output" "$port" "$redirect_to" &
server_pids="$server_pids $!"
}

wait_for_server() {
output=$1
attempt=0

while [ "$attempt" -lt 15 ]; do
if [ -f "$output.ready" ]; then
return 0
fi
sleep 1
attempt=$((attempt + 1))
done
fail "the capture server for $output did not start"
}

assert_marker() {
file=$1
expected=$2
description=$3
actual=""

[ -s "$file" ] || fail "$description (no request reached the capture server)"
actual=$(cat "$file")
[ "$actual" = "$expected" ] ||
fail "$description (expected '$expected', received '$actual')"
}

auth_config_for() {
case $1 in
oauth2-bearer) printf 'oauth2-bearer = "%s"\n' "$token" ;;
header) printf 'header = "Authorization: Bearer %s"\n' "$token" ;;
*) fail "unknown credential mechanism: $1" ;;
esac
}

token=CONTAINER_LIVE_TEST_TOKEN
curl --version | head -n 1

# curl 7.33.0 added the oauth2-bearer option. Compare the version with awk
# because busybox sort has no -V option.
if curl --version | head -n 1 |
awk '{ split($2, v, "."); exit !(v[1] > 7 || (v[1] == 7 && v[2] >= 33)) }'; then
active_mode=oauth2-bearer
else
active_mode=header
fi
echo "Credential mechanism under test: $active_mode"

# 1. The mechanism this curl uses must deliver the credential to the same host.
start_server "$work_dir/direct" 28768
wait_for_server "$work_dir/direct"
auth_config_for "$active_mode" |
curl --silent --show-error -K- --url http://127.0.0.1:28768/test
assert_marker "$work_dir/direct" "REQUEST_ARRIVED|auth=[Bearer $token]" \
"the $active_mode mechanism did not transmit the credential"

# 2. curl must remove the credential when a redirect crosses to another host.
start_server "$work_dir/redirect" 28769 'http://localhost:28770/target'
start_server "$work_dir/target" 28770
wait_for_server "$work_dir/redirect"
wait_for_server "$work_dir/target"
auth_config_for "$active_mode" |
curl --silent --show-error -L -K- --url http://127.0.0.1:28769/start
assert_marker "$work_dir/redirect" "REQUEST_ARRIVED|auth=[Bearer $token]" \
'the first request of the redirect chain did not carry the credential'
assert_marker "$work_dir/target" 'REQUEST_ARRIVED|auth=[]' \
"the $active_mode credential crossed a redirect to another host"

# 3. On a modern curl, also confirm that the older fallback still works. This
# keeps the fallback path tested on the machines that do not need it.
if [ "$active_mode" = "oauth2-bearer" ]; then
start_server "$work_dir/fallback" 28771
wait_for_server "$work_dir/fallback"
auth_config_for header |
curl --silent --show-error -K- --url http://127.0.0.1:28771/test
assert_marker "$work_dir/fallback" "REQUEST_ARRIVED|auth=[Bearer $token]" \
'the raw-header fallback did not transmit the credential'
fi

# 4. A stray bare argument must not become a request. This is the guard that
# stops a leaked credential from reaching a name server.
if auth_config_for "$active_mode" |
curl --silent --show-error --proto '=https' --proto-redir '=https' -K- \
"$token" >/dev/null 2>"$work_dir/proto-error"; then
fail 'the protocol guard accepted a bare argument as a request'
fi
echo "Protocol guard rejected a bare argument: $(cat "$work_dir/proto-error")"

echo "PASS: curl credential handling ($active_mode mode)"
Loading
Loading