Skip to content

Commit b65d14a

Browse files
committed
fix: address Copilot review round 9 findings
- Deduplicate tags in validate_tags() before counting; surface a message when duplicates are removed - Require at least one checkbox item in validate_checklist() so missing/mangled checkbox syntax fails instead of silently passing - Use packaging.version.Version for semver comparison with fallback, fixing incorrect pre-release handling (e.g. 1.0.0-alpha vs 1.0.0) - Omit version key from tools when no version is supplied instead of writing a synthetic >=0.0.0 constraint - Fail closed in _is_safe_redirect_target() on DNS resolution failure to prevent DNS rebinding bypass - Re-add 'validated' label on issue edits (remove + add) so catalog-pr.yml is retriggered to update the generated PR - Add tests for tag dedup validation and DNS-fail-closed behavior
1 parent 1c10491 commit b65d14a

3 files changed

Lines changed: 63 additions & 18 deletions

File tree

.github/scripts/catalog-validate.py

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,12 @@ def _present(value: str | None) -> bool:
157157

158158
def _parse_semver(version: str) -> tuple[int, ...]:
159159
"""Parse a semver string into a comparable tuple of ints."""
160-
# Strip pre-release/build metadata for comparison
160+
try:
161+
from packaging.version import Version
162+
return Version(version)
163+
except (ImportError, Exception):
164+
pass
165+
# Fallback: strip pre-release/build metadata for comparison
161166
base = version.split("-")[0].split("+")[0]
162167
return tuple(int(p) for p in base.split("."))
163168

@@ -256,7 +261,7 @@ def _is_safe_redirect_target(url: str) -> bool:
256261
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_unspecified or ip.is_multicast:
257262
return False
258263
except (socket.gaierror, ValueError):
259-
pass
264+
return False # fail closed: unresolvable targets are blocked
260265
return True
261266

262267

@@ -373,17 +378,23 @@ def validate_hooks_count(value: str) -> tuple[bool, str]:
373378
def validate_tags(value: str) -> tuple[bool, str]:
374379
if not _present(value):
375380
return False, "Tags are required."
376-
raw_tags = [t.strip().lower() for t in value.split(",") if t.strip()]
381+
raw_tags = list(dict.fromkeys(
382+
t.strip().lower() for t in value.split(",") if t.strip()
383+
)) # dedupe preserving order
384+
dupes_removed = len([t.strip().lower() for t in value.split(",") if t.strip()]) - len(raw_tags)
377385
if len(raw_tags) < 2:
378-
return False, "Please provide at least 2 tags."
386+
return False, "Please provide at least 2 unique tags."
379387
if len(raw_tags) > 10:
380-
return False, f"Too many tags ({len(raw_tags)}). Please provide 2-10 tags."
388+
return False, f"Too many tags ({len(raw_tags)} unique). Please provide 2-10 tags."
381389
bad = [t for t in raw_tags if not re.match(r"^[a-z0-9-]+$", t)]
382390
if bad:
383391
return False, (
384392
f"Tags must be lowercase alphanumeric with hyphens: {', '.join(bad)}"
385393
)
386-
return True, f"Tags: {', '.join(raw_tags)}."
394+
msg = f"Tags: {', '.join(raw_tags)}."
395+
if dupes_removed:
396+
msg += f" ({dupes_removed} duplicate(s) removed.)"
397+
return True, msg
387398

388399

389400
def validate_license(value: str) -> tuple[bool, str]:
@@ -403,6 +414,8 @@ def validate_checklist(value: str, field_name: str) -> tuple[bool, str]:
403414
if not _present(value):
404415
return False, f"{field_name} is required."
405416
lines = [l.strip() for l in value.splitlines() if l.strip().startswith("- [")]
417+
if not lines:
418+
return False, f"{field_name} must contain at least one checkbox item."
406419
unchecked = [l for l in lines if l.startswith("- [ ]")]
407420
if unchecked:
408421
items = "\n".join(f" - {l[5:].strip()}" for l in unchecked)
@@ -642,14 +655,16 @@ def _build_extension_entry(
642655
m = _tool_re.match(line)
643656
if m:
644657
name = m.group("name").strip()
645-
version = m.group("version") or ">=0.0.0"
646-
version = version.strip()
658+
version = m.group("version")
659+
version = version.strip() if version else None
647660
req_str = (m.group("req") or "required").lower()
648-
tools_list.append({
661+
tool_entry: dict = {
649662
"name": name,
650-
"version": version,
651663
"required": req_str != "optional",
652-
})
664+
}
665+
if version:
666+
tool_entry["version"] = version
667+
tools_list.append(tool_entry)
653668
else:
654669
# Fallback: comma-separated "name>=version"
655670
for part in line.split(","):
@@ -668,7 +683,6 @@ def _build_extension_entry(
668683
else:
669684
tools_list.append({
670685
"name": part,
671-
"version": ">=0.0.0",
672686
"required": True,
673687
})
674688
if tools_list:

.github/workflows/catalog-validate.yml

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,21 @@ jobs:
9393
name: 'needs-changes',
9494
});
9595
}
96-
if (!currentLabels.includes('validated')) {
97-
await github.rest.issues.addLabels({
96+
if (currentLabels.includes('validated')) {
97+
// Remove + re-add to retrigger catalog-pr on edits
98+
await github.rest.issues.removeLabel({
9899
owner: context.repo.owner,
99100
repo: context.repo.repo,
100101
issue_number: context.issue.number,
101-
labels: ['validated'],
102+
name: 'validated',
102103
});
103104
}
105+
await github.rest.issues.addLabels({
106+
owner: context.repo.owner,
107+
repo: context.repo.repo,
108+
issue_number: context.issue.number,
109+
labels: ['validated'],
110+
});
104111
} else {
105112
if (currentLabels.includes('validated')) {
106113
await github.rest.issues.removeLabel({
@@ -201,14 +208,21 @@ jobs:
201208
name: 'needs-changes',
202209
});
203210
}
204-
if (!currentLabels.includes('validated')) {
205-
await github.rest.issues.addLabels({
211+
if (currentLabels.includes('validated')) {
212+
// Remove + re-add to retrigger catalog-pr on edits
213+
await github.rest.issues.removeLabel({
206214
owner: context.repo.owner,
207215
repo: context.repo.repo,
208216
issue_number: context.issue.number,
209-
labels: ['validated'],
217+
name: 'validated',
210218
});
211219
}
220+
await github.rest.issues.addLabels({
221+
owner: context.repo.owner,
222+
repo: context.repo.repo,
223+
issue_number: context.issue.number,
224+
labels: ['validated'],
225+
});
212226
} else {
213227
if (currentLabels.includes('validated')) {
214228
await github.rest.issues.removeLabel({

tests/test_catalog_validate.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,17 @@ def test_validate_tags_bad_chars(self, cv):
8181
ok, _ = cv.validate_tags("good, BAD CHARS!")
8282
assert not ok
8383

84+
def test_validate_tags_dedup_count(self, cv):
85+
# 3 raw tags but only 2 unique — should still pass (>= 2)
86+
ok, msg = cv.validate_tags("foo, foo, bar")
87+
assert ok
88+
assert "duplicate" in msg.lower()
89+
90+
def test_validate_tags_dedup_too_few(self, cv):
91+
# All dupes of same tag — only 1 unique
92+
ok, _ = cv.validate_tags("foo, foo, foo")
93+
assert not ok
94+
8495

8596
# ---------------------------------------------------------------------------
8697
# validate_description
@@ -181,3 +192,9 @@ def test_rejects_unspecified(self, cv):
181192
fake_addr = [(None, None, None, None, ("0.0.0.0", 0))]
182193
with patch.object(cv.socket, "getaddrinfo", return_value=fake_addr):
183194
assert not cv._is_safe_redirect_target("https://zero.test")
195+
196+
def test_rejects_unresolvable(self, cv):
197+
"""DNS failure should fail closed (block, not allow)."""
198+
import socket as _socket
199+
with patch.object(cv.socket, "getaddrinfo", side_effect=_socket.gaierror):
200+
assert not cv._is_safe_redirect_target("https://unresolvable.test")

0 commit comments

Comments
 (0)