Skip to content

Commit 1ef1d05

Browse files
committed
switch to python impl
1 parent a772e33 commit 1ef1d05

2 files changed

Lines changed: 75 additions & 43 deletions

File tree

check-project-links/action.yml

Lines changed: 15 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,25 @@ inputs:
1515
runs:
1616
using: composite
1717
steps:
18-
- name: Get project links
19-
id: get_project_links
18+
- name: Check project links
19+
id: check-project-links
2020
shell: bash
2121
env:
2222
PROJECT_DIRECTORY: ${{ inputs.project-directory }}
2323
ALLOWED_DOMAINS: ${{ inputs.allowed-domains }}
24+
DOCKER_IMAGE: ${{ inputs.docker-image }}
2425
run: |
25-
set -u
26+
set -euo pipefail
2627
2728
if [ ! -d "$PROJECT_DIRECTORY" ]; then
2829
echo "::error title=Check Project Links Error::Project directory '$PROJECT_DIRECTORY' does not exist."
2930
exit 1
3031
fi
32+
33+
if ! command -v docker >/dev/null 2>&1; then
34+
echo "::error title=Check Project Links Error::docker is not available. Install Docker or add a pre-step that installs it before using this action."
35+
exit 1
36+
fi
3137
3238
link_file="$(mktemp)"
3339
cleanup() {
@@ -37,8 +43,6 @@ runs:
3743
3844
python3 "$GITHUB_ACTION_PATH/find_project_links.py" "$PROJECT_DIRECTORY" "$ALLOWED_DOMAINS" "$link_file"
3945
40-
echo "link-file=$link_file" >> "$GITHUB_OUTPUT"
41-
4246
if [ ! -s "$link_file" ]; then
4347
echo "No trusted project links found under $PROJECT_DIRECTORY."
4448
exit 0
@@ -47,34 +51,17 @@ runs:
4751
echo "Found $(wc -l < "$link_file") unique trusted links:"
4852
cat "$link_file"
4953
50-
- name: Check project links
51-
shell: bash
52-
env:
53-
LINK_FILE: ${{ steps.get_project_links.outputs.link-file }}
54-
DOCKER_IMAGE: ${{ inputs.docker-image }}
55-
run: |
56-
set -u
57-
58-
if ! command -v docker >/dev/null 2>&1; then
59-
echo "::error title=Check Project Links Error::docker is not available. Install Docker or add a pre-step that installs it before using this action."
60-
exit 1
61-
fi
62-
63-
if [ -z "${LINK_FILE:-}" ] || [ ! -f "$LINK_FILE" ]; then
64-
echo "::error title=Check Project Links Error::No project links file was generated."
65-
exit 1
66-
fi
67-
68-
if [ ! -s "$LINK_FILE" ]; then
54+
if [ ! -s "$link_file" ]; then
6955
echo "No trusted project links found."
7056
exit 0
7157
fi
7258
73-
echo "Found $(wc -l < "$LINK_FILE") unique trusted links:"
74-
cat "$LINK_FILE"
59+
echo "Found $(wc -l < "$link_file") unique trusted links:"
60+
cat "$link_file"
7561
76-
docker run --rm \
77-
-v "$LINK_FILE:/tmp/project_links.txt:ro" \
62+
unprivileged_user=100:100 # this matches the default, but we want to be explicit about it
63+
docker run -u "${unprivileged_user}" --rm \
64+
-v "$link_file:/tmp/project_links.txt:ro" \
7865
"$DOCKER_IMAGE" \
7966
sh -ec '
8067
failed=0

check-project-links/find_project_links.py

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,19 @@ def parse_args() -> argparse.Namespace:
2020
return parser.parse_args()
2121

2222

23-
def is_allowed(hostname: str, allowed: list[str]) -> bool:
23+
def is_allowed(hostname: str, allowed: set[str]) -> bool:
24+
"""
25+
Check if the given hostname is allowed based on the provided set of allowed domains.
26+
27+
>>> is_allowed('example.com', {'example.com'})
28+
True
29+
30+
>>> is_allowed('sub.example.com', {'example.com'})
31+
False
32+
33+
>>> is_allowed('sub.example.com', {'*.example.com'})
34+
True
35+
"""
2436
if not hostname:
2537
return False
2638

@@ -36,18 +48,30 @@ def is_allowed(hostname: str, allowed: list[str]) -> bool:
3648
if ip is not None:
3749
return False
3850

39-
for domain in allowed:
40-
if domain.startswith('*.'):
41-
suffix = domain[2:]
42-
if host == suffix or host.endswith(f'.{suffix}'):
43-
return True
44-
else:
45-
if host == domain:
46-
return True
51+
if host in allowed:
52+
return True
53+
54+
if any(host.endswith(f'.{domain.lstrip(".*")}') for domain in allowed if domain.startswith('*.')):
55+
return True
56+
4757
return False
4858

4959

50-
def walk_metadata(value, results, allowed):
60+
def safe_under(base_dir: str, candidate_path: str) -> str:
61+
"""Return a canonical path that stays under base_dir, or raise ValueError."""
62+
safe_base = os.path.realpath(base_dir)
63+
safe_candidate = os.path.realpath(candidate_path)
64+
65+
try:
66+
if os.path.commonpath([safe_base, safe_candidate]) != safe_base:
67+
raise ValueError(f"Path escapes base directory: {candidate_path!r}")
68+
except ValueError as exc:
69+
raise ValueError(f"Path escapes base directory: {candidate_path!r}") from exc
70+
71+
return safe_candidate
72+
73+
74+
def walk_metadata(value, results: set[str], allowed: set[str]) -> None:
5175
if isinstance(value, dict):
5276
for item in value.values():
5377
walk_metadata(item, results, allowed)
@@ -65,23 +89,25 @@ def walk_metadata(value, results, allowed):
6589
def main() -> int:
6690
args = parse_args()
6791
project_directory = args.project_directory
92+
safe_project_directory = os.path.realpath(project_directory, strict=True)
93+
safe_project_directory = safe_under(os.getcwd(), safe_project_directory)
6894
allowed_domains = args.allowed_domains
69-
output_path = args.output_path
95+
safe_output_path = safe_under("/tmp", os.path.realpath(args.output_path, strict=True))
7096

71-
allowed = {}
97+
allowed: set[str] = set()
7298
for raw_domain in allowed_domains.split(','):
7399
domain = raw_domain.strip().lower().rstrip('.')
74100
if domain:
75101
allowed.add(domain)
76102

77103
results: set[str] = set()
78104

79-
for root, _, files in os.walk(project_directory):
105+
for root, _, files in os.walk(safe_project_directory):
80106
for file_name in files:
81107
if file_name != 'pyproject.toml':
82108
continue
83109

84-
manifest_path = os.path.join(root, file_name)
110+
manifest_path = safe_under(safe_project_directory, os.path.join(root, file_name))
85111
try:
86112
with open(manifest_path, 'rb') as manifest_file:
87113
metadata = tomllib.load(manifest_file)
@@ -90,8 +116,27 @@ def main() -> int:
90116
continue
91117

92118
walk_metadata(metadata, results, allowed)
119+
# Also find any commented URLs in the pyproject.toml file
120+
try:
121+
with open(manifest_path, 'r', encoding='utf-8') as manifest_file:
122+
for line in manifest_file:
123+
line = line.strip()
124+
prefix, comment = line.split('#', maxsplit=1) if '#' in line else (line, '')
125+
if comment.strip():
126+
comment = comment.strip()
127+
if comment.startswith('https://'):
128+
host = urlsplit(comment).hostname # handles extra at the end just fine
129+
if host and is_allowed(host, allowed):
130+
results.add(comment)
131+
except OSError:
132+
print(f"Warning: Failed to read pyproject.toml at {manifest_path}", file=sys.stderr)
133+
continue
134+
135+
output_directory = os.path.dirname(safe_output_path)
136+
if output_directory:
137+
os.makedirs(output_directory, exist_ok=True)
93138

94-
with open(output_path, 'w', encoding='utf-8') as output_file:
139+
with open(safe_output_path, 'w', encoding='utf-8') as output_file:
95140
for url in sorted(results):
96141
output_file.write(f'{url}\n')
97142

0 commit comments

Comments
 (0)