Skip to content
Merged
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
86 changes: 83 additions & 3 deletions .github/workflows/depot-canary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,86 @@ jobs:
(( port >= 1 && port <= 65535 ))
}

endpoint_scheme() {
local endpoint="$1"
local endpoint_lower
endpoint_lower="$(printf '%s' "$endpoint" | tr '[:upper:]' '[:lower:]')"
case "$endpoint_lower" in
https://*) printf 'https' ;;
http://*) printf 'http' ;;
*) printf 'other' ;;
esac
}

endpoint_authority_class() {
local endpoint="$1"
local endpoint_lower
endpoint_lower="$(printf '%s' "$endpoint" | tr '[:upper:]' '[:lower:]')"
local authority
authority="$(endpoint_host "$endpoint_lower")"
if [[ "$authority" == *"@"* ]]; then
printf 'other'
return
fi
case "$authority" in
localhost|localhost:*) printf 'localhost'; return ;;
127.0.0.1|127.0.0.1:*) printf '127.0.0.1'; return ;;
"[::1]"|"[::1]:"*) printf 'ipv6-loopback'; return ;;
esac
if [[ "$authority" =~ ^([a-z0-9-]+\.)*actions\.githubusercontent\.com(:[0-9]{1,5})?$ ]]; then
printf 'github'
return
fi
printf 'other'
}

endpoint_numeric_port() {
local endpoint="$1"
local endpoint_lower
endpoint_lower="$(printf '%s' "$endpoint" | tr '[:upper:]' '[:lower:]')"
local authority
authority="$(endpoint_host "$endpoint_lower")"
if [[ "$authority" != *"@"* ]] && [[ "$authority" =~ :[0-9]+$ ]]; then
printf 'present'
else
printf 'absent'
fi
}

endpoint_explicit_path() {
local endpoint="$1"
local endpoint_lower
endpoint_lower="$(printf '%s' "$endpoint" | tr '[:upper:]' '[:lower:]')"
case "$endpoint_lower" in
*://*) ;;
*) printf 'absent'; return ;;
esac
local remainder="${endpoint_lower#*://}"
local authority="${remainder%%[/?#]*}"
local suffix="${remainder#"$authority"}"
if [[ "$suffix" == /* ]]; then
printf 'present'
else
printf 'absent'
fi
}

report_endpoint_rejection() {
local endpoint_name="$1"
local endpoint="$2"
local scheme_label
scheme_label="$(endpoint_scheme "$endpoint")"
local authority_label
authority_label="$(endpoint_authority_class "$endpoint")"
local numeric_port_label
numeric_port_label="$(endpoint_numeric_port "$endpoint")"
local explicit_path_label
explicit_path_label="$(endpoint_explicit_path "$endpoint")"
printf 'GitHub Actions endpoint rejected (variable=%s scheme=%s authority=%s numeric_port=%s explicit_path=%s)\n' \
"$endpoint_name" "$scheme_label" "$authority_label" \
"$numeric_port_label" "$explicit_path_label" >&2
}

for endpoint_name in ACTIONS_CACHE_URL ACTIONS_RESULTS_URL ACTIONS_RUNTIME_URL; do
endpoint="${!endpoint_name:-}"
if [[ -z "$endpoint" ]]; then
Expand All @@ -138,16 +218,16 @@ jobs:
# lookalike host suffixes.
authority="$(endpoint_host "$endpoint_lower")"
if [[ "$endpoint_lower" == *depot.dev* ]]; then
echo "GitHub Actions cache was transparently redirected to Depot ($endpoint_name)" >&2
report_endpoint_rejection "$endpoint_name" "$endpoint"
exit 1
fi
if [[ "$authority" == *"@"* ]]; then
echo "GitHub Actions endpoint contains URL userinfo ($endpoint_name)" >&2
report_endpoint_rejection "$endpoint_name" "$endpoint"
exit 1
fi
if ! is_github_endpoint "$endpoint" &&
! is_loopback_endpoint "$endpoint"; then
echo "GitHub Actions endpoint is not GitHub-owned or loopback ($endpoint_name)" >&2
report_endpoint_rejection "$endpoint_name" "$endpoint"
exit 1
fi
done
Expand Down
154 changes: 128 additions & 26 deletions scripts/tests/test_depot_canary_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,21 +56,20 @@ def test_canary_fails_closed_on_cache_and_registry_injection(self) -> None:
self.assertIn('for endpoint_name in ACTIONS_CACHE_URL ACTIONS_RESULTS_URL ACTIONS_RUNTIME_URL; do', self.workflow)
self.assertIn('endpoint_host() {', self.workflow)
self.assertIn('is_loopback_endpoint() {', self.workflow)
self.assertIn('endpoint_scheme() {', self.workflow)
self.assertIn('endpoint_authority_class() {', self.workflow)
self.assertIn('endpoint_numeric_port() {', self.workflow)
self.assertIn('endpoint_explicit_path() {', self.workflow)
self.assertIn('report_endpoint_rejection() {', self.workflow)
self.assertIn(r"\[::1\]", self.workflow)
self.assertIn("numeric", self.workflow.lower())
self.assertIn("transparently redirected to Depot", self.workflow)
self.assertIn(
'GitHub Actions cache was transparently redirected to Depot ($endpoint_name)',
self.workflow,
)
self.assertIn(
'GitHub Actions endpoint contains URL userinfo ($endpoint_name)',
self.workflow,
)
self.assertIn(
'GitHub Actions endpoint is not GitHub-owned or loopback ($endpoint_name)',
'GitHub Actions endpoint rejected (variable=%s scheme=%s authority=%s numeric_port=%s explicit_path=%s)',
self.workflow,
)
self.assertNotIn('GitHub Actions cache was transparently redirected to Depot ($endpoint_name)', self.workflow)
self.assertNotIn('GitHub Actions endpoint contains URL userinfo ($endpoint_name)', self.workflow)
self.assertNotIn('GitHub Actions endpoint is not GitHub-owned or loopback ($endpoint_name)', self.workflow)
self.assertIn("ACTIONS_RESULTS_URL", self.workflow)
self.assertIn("actions\\.githubusercontent\\.com", self.workflow)
self.assertNotIn(",,}", self.workflow)
Expand Down Expand Up @@ -325,32 +324,135 @@ def run_probe(
(
"unsupported host",
"https://cache.example.invalid/cache",
"GitHub Actions endpoint is not GitHub-owned or loopback",
"https",
"other",
"absent",
"present",
),
(
"Depot redirect",
"https://cache.depot.dev/cache",
"GitHub Actions cache was transparently redirected to Depot",
"https",
"other",
"absent",
"present",
),
(
"URL userinfo",
"https://user@attacker.example/cache",
"https",
"other",
"absent",
"present",
),
(
"URL userinfo with numeric port",
"https://actions.githubusercontent.com:443@attacker.example/",
"GitHub Actions endpoint contains URL userinfo",
"https",
"other",
"absent",
"present",
),
(
"numeric port",
"https://cache.example.invalid:8443/cache",
"https",
"other",
"present",
"present",
),
(
"GitHub authority with HTTP",
"http://actions.githubusercontent.com/cache",
"http",
"github",
"absent",
"present",
),
(
"other scheme",
"ftp://cache.example.invalid/cache",
"other",
"other",
"absent",
"present",
),
(
"localhost authority",
"http://localhost/cache",
"http",
"localhost",
"absent",
"present",
),
(
"IPv4 loopback authority",
"http://127.0.0.1:12345",
"http",
"127.0.0.1",
"present",
"absent",
),
(
"IPv6 loopback authority",
"http://[::1]:65536/cache",
"http",
"ipv6-loopback",
"present",
"present",
),
)
for diagnostic, endpoint, message in diagnostic_cases:
with self.subTest(script=script_name, diagnostic=diagnostic):
result = run_probe(
script,
endpoint,
valid_endpoints[0][1],
)
self.assertNotEqual(result.returncode, 0)
self.assertIn(
f"{message} (ACTIONS_CACHE_URL)",
result.stderr,
)
self.assertNotIn(endpoint, result.stderr)
for endpoint_name in (
"ACTIONS_CACHE_URL",
"ACTIONS_RESULTS_URL",
"ACTIONS_RUNTIME_URL",
):
for (
diagnostic,
endpoint,
scheme,
authority,
numeric_port,
explicit_path,
) in diagnostic_cases:
with self.subTest(
script=script_name,
endpoint_name=endpoint_name,
diagnostic=diagnostic,
):
cache_url = endpoint if endpoint_name == "ACTIONS_CACHE_URL" else valid_endpoints[0][0]
results_url = endpoint if endpoint_name == "ACTIONS_RESULTS_URL" else valid_endpoints[0][1]
extra_environment = (
{"ACTIONS_RUNTIME_URL": endpoint}
if endpoint_name == "ACTIONS_RUNTIME_URL"
else {}
)
result = run_probe(
script,
cache_url,
results_url,
**extra_environment,
)
self.assertNotEqual(result.returncode, 0)
expected = (
"GitHub Actions endpoint rejected "
f"(variable={endpoint_name} scheme={scheme} "
f"authority={authority} numeric_port={numeric_port} "
f"explicit_path={explicit_path})"
)
self.assertIn(expected, result.stderr)
self.assertNotIn(endpoint, result.stderr)
for forbidden_fragment in (
"cache.example.invalid",
"cache.depot.dev",
"attacker.example",
"actions.githubusercontent.com",
"/cache",
"8443",
"65536",
"443",
):
self.assertNotIn(forbidden_fragment, result.stderr)
if script_name == "audit action":
with self.subTest(script=script_name, policy="native cache enabled"):
result = run_probe(
Expand Down
Loading