From 6aad73e0e72a8fd09bf85897f72fc1674e8c02e6 Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Mon, 17 Aug 2026 12:08:30 -0700 Subject: [PATCH] Fix code scanning alerts: workflow permissions and polynomial ReDoS Add explicit least-privilege `permissions:` blocks to the three reusable-workflow callers that lacked one (actions/missing-workflow-permissions, alerts #1, #3, #4). A caller's permissions become the ceiling for the called workflow, so each grant covers exactly what the shared-config workflow needs: - cd.yml -> `contents: write`: shared-config's cd.yml checks out with persisted credentials and runs publish-rubygems-action, which does a raw `git push` of the release tag, then `gh release create`. - stale.yml -> `issues: write` + `pull-requests: write`: actions/stale comments on and closes both stale issues and stale PRs. - triage.yml -> `issues: write`: the callee runs `gh issue edit --add-label triage`; the callee's own job already declares `issues: write`, and the caller must not clamp below it. ci.yml and codeql.yml already declare permissions and are untouched. Fix the polynomial ReDoS in `Utils.underscore` (rb/polynomial-redos, alert #5). The acronym-splitting step `/([A-Z]+)([A-Z][a-z])/` is quadratic on long runs of uppercase letters that never reach the required uppercase-lowercase pair, since `[A-Z]+` can start matching at any offset within the run. Replacing it with the zero-width `/(?<=[A-Z])(?=[A-Z][a-z])/` and an `_` replacement is linear: the lookahead only matches immediately before the final uppercase of a run that is followed by a lowercase letter, and the lookbehind requires at least one preceding uppercase, which is exactly what `[A-Z]+` required. Equivalence was checked exhaustively over all strings up to length 6 from the alphabet [A B a b 1 - : _], over 200k random mixed-case strings up to length 24, and against the named cases now pinned in spec/lib/code_teams/utils_spec.rb. The other gsub/tr steps are unchanged. --- .github/workflows/cd.yml | 2 ++ .github/workflows/stale.yml | 3 +++ .github/workflows/triage.yml | 2 ++ lib/code_teams/utils.rb | 2 +- spec/lib/code_teams/utils_spec.rb | 31 +++++++++++++++++++++++++++++++ 5 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 spec/lib/code_teams/utils_spec.rb diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 8cb675f..4c9ab97 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -8,5 +8,7 @@ on: jobs: call-workflow-from-shared-config: + permissions: + contents: write uses: rubyatscale/shared-config/.github/workflows/cd.yml@main secrets: inherit diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 0287d52..2696450 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -5,4 +5,7 @@ on: - cron: '0 0 * * *' jobs: call-workflow-from-shared-config: + permissions: + issues: write + pull-requests: write uses: rubyatscale/shared-config/.github/workflows/stale.yml@main diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml index 74bb1d9..7c492ee 100644 --- a/.github/workflows/triage.yml +++ b/.github/workflows/triage.yml @@ -6,4 +6,6 @@ on: - opened jobs: call-workflow-from-shared-config: + permissions: + issues: write uses: rubyatscale/shared-config/.github/workflows/triage.yml@main diff --git a/lib/code_teams/utils.rb b/lib/code_teams/utils.rb index 63756b9..3d6296a 100644 --- a/lib/code_teams/utils.rb +++ b/lib/code_teams/utils.rb @@ -11,7 +11,7 @@ module Utils sig { params(string: String).returns(String) } def underscore(string) string.gsub('::', '/') - .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + .gsub(/(?<=[A-Z])(?=[A-Z][a-z])/, '_') .gsub(/([a-z\d])([A-Z])/, '\1_\2') .tr('-', '_') .downcase diff --git a/spec/lib/code_teams/utils_spec.rb b/spec/lib/code_teams/utils_spec.rb new file mode 100644 index 0000000..91c78cc --- /dev/null +++ b/spec/lib/code_teams/utils_spec.rb @@ -0,0 +1,31 @@ +RSpec.describe CodeTeams::Utils do + describe '.underscore' do + # These cases pin the exact behavior of the acronym-splitting step so that the + # linear-time zero-width rewrite stays equivalent to the original + # `/([A-Z]+)([A-Z][a-z])/` pattern. + { + '' => '', + 'A' => 'a', + 'Ab' => 'ab', + 'AAb' => 'a_ab', + 'AABb' => 'aa_bb', + 'ABCd' => 'ab_cd', + 'HTMLParser' => 'html_parser', + 'ALLCAPS' => 'allcaps', + 'allLower' => 'all_lower', + 'aABc' => 'a_a_bc', + 'ABCdEFg' => 'ab_cd_e_fg', + 'A1b' => 'a1b', + 'ABC1de' => 'abc1de', + 'X9Yz' => 'x9_yz', + 'HTTPResponse2XX' => 'http_response2_xx', + 'Foo::BarBaz' => 'foo/bar_baz', + 'Foo::HTMLParser::XMLNode' => 'foo/html_parser/xml_node', + 'my-Team-Name' => 'my_team_name' + }.each do |input, expected| + it "converts #{input.inspect} to #{expected.inspect}" do + expect(described_class.underscore(input)).to eq(expected) + end + end + end +end