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..3266c377 100644 --- a/src/security/prerelease-check/action.yml +++ b/src/security/prerelease-check/action.yml @@ -115,7 +115,32 @@ 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 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) + # `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 + 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..a62c699d 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,135 @@ 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)) + + 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)) + + 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):