From 133097ed999d5fd103202f4e709ab1a260141c60 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Thu, 8 Oct 2026 15:42:17 -0300 Subject: [PATCH 1/4] fix(prerelease-check): skip the package's own version in package.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package.json scan grepped the whole file, so a repo shipping a beta of itself had its top-level "version" reported as an unstable dependency pin. On a blocking branch that annotates as an error and hard-fails the gate for the entire beta cycle, forcing callers to work around it by listing their own version in .prerelease-allow — a mechanism meant for dependency debt. Resolve the package's own version with jq and drop the line that declares it. Every dependency pin in the file is still scanned; an unparseable package.json leaves the exemption empty, keeping the previous behaviour. --- src/security/prerelease-check/README.md | 2 + src/security/prerelease-check/action.yml | 14 +++++ src/security/prerelease-check/test.py | 74 ++++++++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/src/security/prerelease-check/README.md b/src/security/prerelease-check/README.md index f15a62a9..99063ac0 100644 --- a/src/security/prerelease-check/README.md +++ b/src/security/prerelease-check/README.md @@ -40,6 +40,8 @@ For all file types, only known pre-release keywords (`alpha`, `beta`, `rc`, `dev | `package.json` | `"[~^<>=]*X.Y.Z-(alpha\|beta\|rc\|dev\|...)"` | `"^2.0.0-beta.1"`, `"~1.0.0-rc.3"`, `"<2.0.0-beta.1"` | `"2.0.0"` | | `Dockerfile`, `*.dockerfile`, `Dockerfile.*` | `:X.Y.Z-(alpha\|beta\|rc\|dev\|...)` | `golang:1.21.0-beta1` | `golang:1.21.0`, `python:3.12-slim`, `node:20-alpine` | +For `package.json`, the package's **own** top-level `version` is never reported: it identifies the artifact the repository produces, not a dependency it consumes, so a repo in a legitimate beta cycle is not blocked by its own version string. Every dependency pin in the file is still scanned. If the file cannot be parsed, nothing is exempted. + ## Overriding the pattern The regex is fixed in the action but overridable through the `prerelease-pattern` input, wired from an organization (or repository) variable so the policy can be tuned centrally without cutting a new release. The `pr-security-scan` reusable workflow already forwards `${{ vars.PRERELEASE_PATTERN }}`; define that variable once at the org level to apply it across every consuming repo. diff --git a/src/security/prerelease-check/action.yml b/src/security/prerelease-check/action.yml index bdabcdd8..220fd3af 100644 --- a/src/security/prerelease-check/action.yml +++ b/src/security/prerelease-check/action.yml @@ -115,7 +115,21 @@ runs: real=$(realpath "$base/package.json") if ! already_seen "$real"; then SEEN_FILES+=("$real") + # The package's own top-level "version" is not a dependency pin: + # a repo shipping a beta of itself ("version": "2.0.0-beta.1") is + # not consuming an unstable dependency, and flagging it hard-fails + # the gate on blocking branches for the whole beta cycle. Resolve + # it with jq and drop the line that declares it. An unparseable + # package.json leaves SELF_VERSION empty, which keeps the previous + # behaviour (fail towards reporting, never towards silence). + SELF_VERSION=$(jq -r '.version // empty' "$base/package.json" 2>/dev/null || true) while IFS= read -r match; do + if [ -n "$SELF_VERSION" ]; then + LINE_CONTENT=$(printf '%s' "${match#*:}" | sed 's/^[[:space:]]*//') + case "$LINE_CONTENT" in + '"version"'*:*"\"$SELF_VERSION\""*) continue ;; + esac + fi FINDINGS+=("${base}/package.json|$match") done < <(grep -nE "\"[~^<>=]*${PRERELEASE_PATTERN}" "$base/package.json" || true) fi diff --git a/src/security/prerelease-check/test.py b/src/security/prerelease-check/test.py index a2dc2b62..2c0574cd 100644 --- a/src/security/prerelease-check/test.py +++ b/src/security/prerelease-check/test.py @@ -121,6 +121,27 @@ def extract_run_body(text, step_name): GO_MOD_PIN = "github.com/emersion/go-imap/v2 v2.0.0-beta.8" +# A package shipping a beta of *itself*: the top-level "version" is the artifact +# this repo produces, not a dependency it consumes. +PACKAGE_JSON_SELF_BETA = """{ + "name": "example", + "version": "2.0.0-beta.1", + "dependencies": { + "react": "19.0.0" + } +} +""" + +PACKAGE_JSON_SELF_BETA_AND_DEP = """{ + "name": "example", + "version": "2.0.0-beta.1", + "dependencies": { + "@lerianstudio/sindarian-ui": "2.0.0-beta.6", + "react": "19.0.0" + } +} +""" + class ScanRunner: """Runs the extracted scan step against a throwaway workspace.""" @@ -228,6 +249,59 @@ def test_backslash_escaped_quotes_are_tolerated(self): result = self.runner.run() self.assertEqual(self.runner.findings_count(result), 0, self._debug(result)) + # ----------------- package.json: the package's own version ----------------- + + def test_own_top_level_version_is_not_a_finding(self): + """A repo shipping a beta of itself is not pinning an unstable dependency. + + Regression (#871): the scan grepped the whole file, so the package's own + top-level `"version"` was reported as a pre-release pin and hard-failed + the gate on blocking branches for the entire beta cycle. + """ + self.runner.write("package.json", PACKAGE_JSON_SELF_BETA) + result = self.runner.run(TARGET_BRANCH="main") + self.assertEqual(self.runner.findings_count(result), 0, self._debug(result)) + + def test_own_version_is_skipped_while_dependency_pins_still_block(self): + """Dropping the self-version must not disarm the scan around it.""" + self.runner.write("package.json", PACKAGE_JSON_SELF_BETA_AND_DEP) + result = self.runner.run(TARGET_BRANCH="main") + self.assertEqual(self.runner.findings_count(result), 1, self._debug(result)) + self.assertIn(PACKAGE_JSON_PIN.split(":")[0], result.step_summary, self._debug(result)) + self.assertNotIn('"version"', result.step_summary, self._debug(result)) + + def test_a_dependency_sharing_the_self_version_still_blocks(self): + """Only the `"version"` key is exempt — not every line carrying its value.""" + self.runner.write( + "package.json", + """{ + "name": "example", + "version": "2.0.0-beta.6", + "dependencies": { + "@lerianstudio/sindarian-ui": "2.0.0-beta.6" + } +} +""", + ) + result = self.runner.run(TARGET_BRANCH="main") + self.assertEqual(self.runner.findings_count(result), 1, self._debug(result)) + + def test_a_dependency_named_version_is_unaffected_by_a_stable_package(self): + """With no pre-release self-version, nothing is exempted.""" + self.runner.write( + "package.json", + """{ + "name": "example", + "version": "1.4.2", + "dependencies": { + "@lerianstudio/sindarian-ui": "2.0.0-beta.6" + } +} +""", + ) + result = self.runner.run() + self.assertEqual(self.runner.findings_count(result), 1, self._debug(result)) + # ----------------- go.mod: unchanged behaviour ----------------- def test_go_mod_entry_still_matches(self): From 9f8c4bbbeb27a06c736d56043da267c8c86a5bfc Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Fri, 9 Oct 2026 13:46:32 -0300 Subject: [PATCH 2/4] fix(prerelease-check): spell unparsable in the self-version comment --- src/security/prerelease-check/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/security/prerelease-check/action.yml b/src/security/prerelease-check/action.yml index 220fd3af..00b41083 100644 --- a/src/security/prerelease-check/action.yml +++ b/src/security/prerelease-check/action.yml @@ -119,7 +119,7 @@ runs: # a repo shipping a beta of itself ("version": "2.0.0-beta.1") is # not consuming an unstable dependency, and flagging it hard-fails # the gate on blocking branches for the whole beta cycle. Resolve - # it with jq and drop the line that declares it. An unparseable + # it with jq and drop the line that declares it. An unparsable # package.json leaves SELF_VERSION empty, which keeps the previous # behaviour (fail towards reporting, never towards silence). SELF_VERSION=$(jq -r '.version // empty' "$base/package.json" 2>/dev/null || true) From 75db678eab11eb380598069c7a666c137966f6b0 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Fri, 9 Oct 2026 13:52:05 -0300 Subject: [PATCH 3/4] fix(prerelease-check): drop the self-version exemption when version is a dependency --- src/security/prerelease-check/action.yml | 7 +++++++ src/security/prerelease-check/test.py | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/security/prerelease-check/action.yml b/src/security/prerelease-check/action.yml index 00b41083..55ac357b 100644 --- a/src/security/prerelease-check/action.yml +++ b/src/security/prerelease-check/action.yml @@ -123,6 +123,13 @@ runs: # package.json leaves SELF_VERSION empty, which keeps the previous # behaviour (fail towards reporting, never towards silence). SELF_VERSION=$(jq -r '.version // empty' "$base/package.json" 2>/dev/null || true) + # `version` is also a real npm package name. If it is depended on, + # a "version": "" line is ambiguous, so the exemption + # is dropped entirely rather than risk silencing a genuine pin — + # the scan reports both lines, as it did before this exemption. + if [ -n "$SELF_VERSION" ] && [ "$(jq -r '[.dependencies,.devDependencies,.peerDependencies,.optionalDependencies] | map(select(type=="object")) | add // {} | has("version")' "$base/package.json" 2>/dev/null || echo false)" = "true" ]; then + SELF_VERSION="" + fi while IFS= read -r match; do if [ -n "$SELF_VERSION" ]; then LINE_CONTENT=$(printf '%s' "${match#*:}" | sed 's/^[[:space:]]*//') diff --git a/src/security/prerelease-check/test.py b/src/security/prerelease-check/test.py index 2c0574cd..89525040 100644 --- a/src/security/prerelease-check/test.py +++ b/src/security/prerelease-check/test.py @@ -302,6 +302,27 @@ def test_a_dependency_named_version_is_unaffected_by_a_stable_package(self): result = self.runner.run() self.assertEqual(self.runner.findings_count(result), 1, self._debug(result)) + def test_a_dependency_literally_named_version_disables_the_exemption(self): + """`version` is a real npm package: an ambiguous line must not be silenced. + + When the package depends on `version` *and* pins it at the same value as + its own, the two lines are indistinguishable by content, so the exemption + is dropped and both are reported rather than risking a false negative. + """ + self.runner.write( + "package.json", + """{ + "name": "example", + "version": "2.0.0-beta.1", + "dependencies": { + "version": "2.0.0-beta.1" + } +} +""", + ) + result = self.runner.run(TARGET_BRANCH="main") + self.assertEqual(self.runner.findings_count(result), 2, self._debug(result)) + # ----------------- go.mod: unchanged behaviour ----------------- def test_go_mod_entry_still_matches(self): From a2b3978278543e3738d3e4d686738c4bb16b0c31 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Fri, 9 Oct 2026 13:58:34 -0300 Subject: [PATCH 4/4] fix(prerelease-check): detect an ambiguous version pin at any depth --- src/security/prerelease-check/action.yml | 14 +++--- src/security/prerelease-check/test.py | 55 ++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/security/prerelease-check/action.yml b/src/security/prerelease-check/action.yml index 55ac357b..3266c377 100644 --- a/src/security/prerelease-check/action.yml +++ b/src/security/prerelease-check/action.yml @@ -123,11 +123,15 @@ runs: # package.json leaves SELF_VERSION empty, which keeps the previous # behaviour (fail towards reporting, never towards silence). SELF_VERSION=$(jq -r '.version // empty' "$base/package.json" 2>/dev/null || true) - # `version` is also a real npm package name. If it is depended on, - # a "version": "" line is ambiguous, so the exemption - # is dropped entirely rather than risk silencing a genuine pin — - # the scan reports both lines, as it did before this exemption. - if [ -n "$SELF_VERSION" ] && [ "$(jq -r '[.dependencies,.devDependencies,.peerDependencies,.optionalDependencies] | map(select(type=="object")) | add // {} | has("version")' "$base/package.json" 2>/dev/null || echo false)" = "true" ]; then + # `version` is also a real npm package name. A nested + # "version": "" — a dependency block, but also + # `resolutions` or a recursive `overrides` entry — is byte-identical + # to the package's own line, so the exemption is dropped entirely + # rather than risk silencing a genuine pin; the scan then reports + # both lines, as it did before this exemption existed. The probe + # walks every path instead of enumerating sections, so a block the + # line scan reaches cannot be missed here. + if [ -n "$SELF_VERSION" ] && [ "$(jq -r --arg v "$SELF_VERSION" 'any(paths(scalars) as $p | select(($p | .[-1]) == "version" and ($p | length) > 1) | getpath($p); . == $v)' "$base/package.json" 2>/dev/null || echo true)" = "true" ]; then SELF_VERSION="" fi while IFS= read -r match; do diff --git a/src/security/prerelease-check/test.py b/src/security/prerelease-check/test.py index 89525040..a62c699d 100644 --- a/src/security/prerelease-check/test.py +++ b/src/security/prerelease-check/test.py @@ -323,6 +323,61 @@ def test_a_dependency_literally_named_version_disables_the_exemption(self): result = self.runner.run(TARGET_BRANCH="main") self.assertEqual(self.runner.findings_count(result), 2, self._debug(result)) + def test_an_ambiguous_version_in_resolutions_disables_the_exemption(self): + """`resolutions` is reached by the line scan, so it must disarm it too.""" + self.runner.write( + "package.json", + """{ + "name": "example", + "version": "2.0.0-beta.1", + "resolutions": { + "version": "2.0.0-beta.1" + } +} +""", + ) + result = self.runner.run(TARGET_BRANCH="main") + self.assertEqual(self.runner.findings_count(result), 2, self._debug(result)) + + def test_an_ambiguous_version_nested_in_overrides_disables_the_exemption(self): + """`overrides` nests arbitrarily — the guard walks paths, not a section list.""" + self.runner.write( + "package.json", + """{ + "name": "example", + "version": "2.0.0-beta.1", + "overrides": { + "some-package": { + "version": "2.0.0-beta.1" + } + } +} +""", + ) + result = self.runner.run(TARGET_BRANCH="main") + self.assertEqual(self.runner.findings_count(result), 2, self._debug(result)) + + def test_an_unrelated_nested_version_does_not_disable_the_exemption(self): + """Only a nested `version` equal to the package's own is ambiguous.""" + self.runner.write( + "package.json", + """{ + "name": "example", + "version": "2.0.0-beta.1", + "overrides": { + "some-package": { + "version": "3.1.0" + } + }, + "dependencies": { + "react": "19.0.0" + } +} +""", + ) + result = self.runner.run(TARGET_BRANCH="main") + self.assertEqual(self.runner.findings_count(result), 0, self._debug(result)) + # ----------------- go.mod: unchanged behaviour ----------------- def test_go_mod_entry_still_matches(self):