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
2 changes: 2 additions & 0 deletions src/security/prerelease-check/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions src/security/prerelease-check/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<self 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 ;;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
esac
fi
FINDINGS+=("${base}/package.json|$match")
done < <(grep -nE "\"[~^<>=]*${PRERELEASE_PATTERN}" "$base/package.json" || true)
fi
Expand Down
150 changes: 150 additions & 0 deletions src/security/prerelease-check/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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):
Expand Down
Loading