Compare commits

..

4 Commits

Author SHA1 Message Date
jmiller 240fe1ebe5 feat: security scanning API endpoints + pre-receive hook blocking (#692)
PR RC Release / Build RC Release (pull_request) Successful in 2s
Generic: Project CI / Lint & Validate (pull_request) Successful in 37s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 9s
Universal: PR Check / Secret Scan (pull_request) Successful in 50s
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Add REST API for security alerts (list, get, update status, trigger scan)
and scanner config (get, update). Wire block_on_push into the pre-receive
hook so pushes containing detected secrets are rejected with details.

Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd
2026-06-28 02:14:46 -05:00
jmiller ecc1f20162 feat: cascade merge — auto-create PRs to downstream branches after merge (#460)
Adds configurable cascade rules per repo. When a PR merges into a
source branch, the system auto-creates PRs to each configured target
branch. Skips if a matching PR already exists.

- Model: CascadeMergeRule (repo_id, source, target, enabled, auto_merge)
- Migration v362 creates cascade_merge_rule table
- Notifier hooks into MergePullRequest/AutoMergePullRequest events
- API: CRUD at /repos/{owner}/{repo}/cascade_rules (admin only)

Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd
2026-06-28 02:04:40 -05:00
jmiller 965abb54b8 feat: add issue status presets and cross-org migration (#507)
4 built-in presets: default, software-development, support-tickets,
bug-tracking. API endpoints to list presets, apply to org, and copy
statuses between orgs. Web UI dropdown on org settings page.

Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd
2026-06-28 02:03:15 -05:00
jmiller b94f41b597 feat(orgs): auto-create default teams on org creation (#513)
New organizations now get three default teams in addition to Owners:
- Developers (write: code, issues, PRs, wiki, projects; read: releases)
- Reviewers (read: code, issues, PRs, releases, wiki)
- CI/CD (write: actions, packages, releases; read: code)

Teams are defined in DefaultOrgTeams and created inside the same
transaction as the org, so creation is atomic.

Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd
2026-06-28 02:00:17 -05:00
91 changed files with 512 additions and 2163 deletions
-3
View File
@@ -120,6 +120,3 @@ prime/
# A Makefile for custom make targets
Makefile.local
# Local clone of the MCP server (separate repo, not a submodule of this project)
/mcp-mokogitea-api/
+4 -5
View File
@@ -3,9 +3,9 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGitea.Workflow
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/auto-bump.yml
# VERSION: 09.02.00
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
@@ -34,8 +34,7 @@ jobs:
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip bump]') &&
!startsWith(github.event.head_commit.message, 'Merge pull request') &&
!startsWith(github.event.repository.name, 'Template-')
!startsWith(github.event.head_commit.message, 'Merge pull request')
steps:
- name: Checkout
@@ -53,7 +52,7 @@ jobs:
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
else
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/MokoCLI.git" \
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
/tmp/mokocli
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
+32 -62
View File
@@ -3,11 +3,11 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGitea.Workflow
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
# PATH: /.mokogitea/workflows/auto-release.yml
# VERSION: 05.01.00
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
# PATH: /templates/workflows/universal/auto-release.yml.template
# VERSION: 05.00.00
# BRIEF: Universal build & release detects platform from manifest.xml
#
# +=======================================================================+
@@ -64,14 +64,10 @@ jobs:
promote-rc:
name: Promote to RC
runs-on: release
# Skip on template repos (Template-*) — they scaffold other repos and do not release.
if: >-
!startsWith(github.event.repository.name, 'Template-') &&
(
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
(github.event.action == 'synchronize' && github.event.pull_request.merged != true) ||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
)
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
(github.event.action == 'synchronize' && github.event.pull_request.merged != true) ||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
steps:
- name: Checkout repository
@@ -79,7 +75,6 @@ jobs:
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
submodules: recursive
- name: Setup mokocli tools
env:
@@ -95,7 +90,7 @@ jobs:
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi
rm -rf /tmp/mokocli
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoCLI.git
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
cd /tmp/mokocli
composer install --no-dev --no-interaction --quiet
@@ -104,46 +99,18 @@ jobs:
- name: Rename branch to rc
run: |
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
AUTH="Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}"
FROM="${{ github.event.pull_request.head.ref || 'dev' }}"
PR="${{ github.event.pull_request.number }}"
# Resolve the source branch HEAD commit.
SRC_JSON=$(curl -sf -H "$AUTH" "${API_BASE}/branches/${FROM}") \
|| { echo "::error::Source branch ${FROM} not found"; exit 1; }
SRC_SHA=$(printf '%s' "$SRC_JSON" | python3 -c "import sys, json; print(json.load(sys.stdin)['commit']['id'])" 2>/dev/null || true)
[ -n "$SRC_SHA" ] || { echo "::error::Could not resolve HEAD of ${FROM}"; exit 1; }
# Point rc at the source commit. If rc already exists (a protected branch that
# cannot be deleted), force-update its ref in place instead of delete+recreate:
# deleting a protected branch fails, which then makes the recreate return HTTP 409.
if curl -sf -o /dev/null -H "$AUTH" "${API_BASE}/branches/rc"; then
echo "rc exists - force-updating to ${FROM} (${SRC_SHA})"
curl -sf -X PATCH -H "$AUTH" -H "Content-Type: application/json" \
"${API_BASE}/git/refs/heads/rc" -d "{\"sha\":\"${SRC_SHA}\",\"force\":true}" \
|| { echo "::error::Failed to force-update rc (CI token needs force-push on the protected rc branch)"; exit 1; }
else
echo "Creating rc from ${FROM}"
curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \
"${API_BASE}/branches" -d "{\"new_branch_name\":\"rc\",\"old_branch_name\":\"${FROM}\"}" \
|| { echo "::error::Failed to create rc from ${FROM}"; exit 1; }
fi
# Repoint the PR at rc, then delete the old source branch (non-fatal).
if [ -n "$PR" ]; then
curl -s -X PATCH -H "$AUTH" -H "Content-Type: application/json" \
"${API_BASE}/pulls/${PR}" -d '{"head":"rc"}' >/dev/null || true
fi
curl -s -X DELETE -H "$AUTH" "${API_BASE}/branches/${FROM}" >/dev/null || true
echo "Renamed ${FROM} -> rc"
php ${MOKO_CLI}/branch_rename.php \
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--api-base "${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
--pr "${{ github.event.pull_request.number }}"
- name: Checkout rc and configure git
run: |
git fetch origin rc
git checkout rc
git config --local user.email "mokogitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "mokogitea-actions[bot]"
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
- name: Publish RC release
@@ -196,13 +163,9 @@ jobs:
release:
name: Build & Release Pipeline
runs-on: release
# Skip on template repos (Template-*) — they scaffold other repos and do not release.
if: >-
!startsWith(github.event.repository.name, 'Template-') &&
(
github.event.pull_request.merged == true ||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
)
github.event.pull_request.merged == true ||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
steps:
- name: Checkout repository
@@ -210,12 +173,11 @@ jobs:
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 0
submodules: recursive
- name: Configure git for bot pushes
run: |
git config --local user.email "mokogitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "mokogitea-actions[bot]"
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
- name: Check for merge conflict markers
@@ -246,7 +208,7 @@ jobs:
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi
rm -rf /tmp/mokocli
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoCLI.git
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
cd /tmp/mokocli
composer install --no-dev --no-interaction --quiet
@@ -426,7 +388,7 @@ jobs:
&& echo "main branch pushed to GitHub mirror" \
|| echo "WARNING: GitHub mirror push failed"
- name: "Step 11: Delete rc branch (dev reset moved to cascade-dev.yml)"
- name: "Step 11: Delete rc branch and recreate dev from main"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
@@ -438,9 +400,17 @@ jobs:
"${API_BASE}/branches/rc" 2>/dev/null \
&& echo "Deleted rc branch" || echo "rc branch not found"
# dev is reset from main by the dedicated "Cascade Main -> Dev" workflow
# (cascade-dev.yml), which runs after this release completes.
echo "rc cleaned; dev reset handled by cascade-dev.yml" >> $GITHUB_STEP_SUMMARY
# Delete dev branch
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
# Recreate dev from main (now includes version bump + changelog promotion)
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/branches" \
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
- name: "Step 12: Create version branch from main"
if: steps.version.outputs.skip != 'true'
+3 -4
View File
@@ -3,9 +3,9 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGitea.Workflow
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/branch-cleanup.yml
# VERSION: 01.00.00
# BRIEF: Delete feature branches after PR merge
@@ -33,8 +33,7 @@ jobs:
run: |
BRANCH="${{ github.event.pull_request.head.ref }}"
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
# URL-encode the branch name's slashes (no PHP dependency on the runner)
ENCODED=$(printf '%s' "${BRANCH}" | sed 's|/|%2F|g')
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
-106
View File
@@ -1,106 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGitea.Workflow
# INGROUP: MokoStandards.Cascade
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
# PATH: /.mokogitea/workflows/cascade-dev.yml
# VERSION: 02.00.00
# BRIEF: Cascade main -> dev via PR; auto-merge only if conflict-free, else notify
name: "Cascade Main -> Dev"
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: write
pull-requests: write
env:
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
# ntfy destination is configured via repo or org variables (org vars are inherited).
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
NTFY_TOPIC: ${{ vars.CASCADE_NTFY_TOPIC || vars.NTFY_TOPIC || 'gitea-releases' }}
jobs:
cascade:
name: Cascade main -> dev
runs-on: ubuntu-latest
steps:
- name: Open main -> dev PR (auto-merge if clean, else notify)
env:
TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -uo pipefail
API="${MOKOGITEA_URL}/api/v1/repos/${REPO}"
AUTH="Authorization: token ${TOKEN}"
jqnum() { python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('$1',''))" 2>/dev/null; }
# 0. dev must exist
if ! curl -sf -H "$AUTH" "${API}/branches/dev" >/dev/null 2>&1; then
echo "No dev branch - nothing to cascade."; exit 0
fi
# 1. is main ahead of dev?
AHEAD=$(curl -sf -H "$AUTH" "${API}/compare/dev...main" \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('total_commits',0))" 2>/dev/null || echo 0)
if [ "${AHEAD:-0}" -eq 0 ]; then
echo "dev already up to date with main."; exit 0
fi
echo "main is ${AHEAD} commit(s) ahead of dev."
# 2. reuse an open main->dev PR, else create one
PR=$(curl -sf -H "$AUTH" "${API}/pulls?state=open&base=dev" \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(next((str(p['number']) for p in d if p.get('head',{}).get('ref')=='main'), ''))" 2>/dev/null || echo "")
if [ -z "$PR" ]; then
RESP=$(curl -s -H "$AUTH" -H "Content-Type: application/json" -X POST "${API}/pulls" \
-d '{"head":"main","base":"dev","title":"chore(sync): cascade main -> dev","body":"Automated cascade of main into dev. Auto-merges only if conflict-free; otherwise left open for manual resolution."}')
PR=$(printf '%s' "$RESP" | jqnum number)
if [ -z "$PR" ]; then
echo "::warning::Could not open cascade PR: $RESP"; exit 0
fi
echo "Opened cascade PR #${PR}"
else
echo "Reusing open cascade PR #${PR}"
fi
# 3. wait for MokoGitea to compute mergeability (conflict detection)
MERGEABLE=""
for _ in 1 2 3 4 5 6; do
MERGEABLE=$(curl -sf -H "$AUTH" "${API}/pulls/${PR}" | jqnum mergeable)
case "$MERGEABLE" in True|False) break ;; esac
sleep 3
done
echo "mergeable=${MERGEABLE}"
notify() {
curl -sS \
-H "Title: ${REPO}: dev cascade needs manual merge" \
-H "Tags: warning,twisted_rightwards_arrows" \
-H "Priority: high" \
-H "Click: ${MOKOGITEA_URL}/${REPO}/pulls/${PR}" \
-d "main -> dev cascade PR #${PR} $1 It was NOT auto-merged; resolve it manually." \
"${NTFY_URL}/${NTFY_TOPIC}" || true
}
# 4. auto-merge only if conflict-free; otherwise notify
if [ "$MERGEABLE" = "True" ]; then
CODE=$(curl -s -o /tmp/merge.json -w "%{http_code}" -H "$AUTH" -H "Content-Type: application/json" \
-X POST "${API}/pulls/${PR}/merge" -d '{"Do":"merge","merge_when_checks_succeed":true}')
if [ "$CODE" -ge 200 ] && [ "$CODE" -lt 300 ]; then
echo "Cascade PR #${PR} merged (or scheduled to merge when checks pass)."
else
echo "::warning::Auto-merge returned HTTP ${CODE}: $(cat /tmp/merge.json)"
notify "could not be auto-merged (HTTP ${CODE})."
fi
else
echo "::warning::Cascade PR #${PR} has conflicts (mergeable=${MERGEABLE}); sending notification."
notify "has conflicts and cannot be merged automatically."
fi
+1 -6
View File
@@ -6,7 +6,7 @@
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
# PATH: /.mokogitea/workflows/ci-generic.yml
# PATH: /.gitea/workflows/ci-generic.yml
# VERSION: 01.00.00
# BRIEF: CI pipeline — lint, validate, and test for generic projects (PHP + Node.js)
@@ -32,8 +32,6 @@ jobs:
lint:
name: Lint & Validate
runs-on: ubuntu-latest
# Skip on template repos (Template-*) — they hold placeholder scaffolding, not buildable source.
if: ${{ !startsWith(github.event.repository.name, 'Template-') }}
steps:
- name: Checkout
@@ -132,9 +130,6 @@ jobs:
name: Tests
runs-on: ubuntu-latest
needs: lint
# Run only when lint succeeded; always() forces evaluation so a skipped
# lint (e.g. template repos) skips this job cleanly instead of hanging.
if: ${{ always() && needs.lint.result == 'success' }}
steps:
- name: Checkout
+3 -3
View File
@@ -3,12 +3,12 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGitea.Workflow
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/ci-issue-reporter.yml
# VERSION: 01.00.00
# BRIEF: Reusable workflow — creates/updates a MokoGitea issue when a CI gate fails.
# BRIEF: Reusable workflow — creates/updates a Gitea issue when a CI gate fails.
# Clones MokoCLI and runs cli/ci_issue_reporter.sh.
name: "Universal: CI Issue Reporter"
+3 -3
View File
@@ -3,10 +3,10 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGitea.Workflow
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.mokogitea/workflows/cleanup.yml
# PATH: /.gitea/workflows/cleanup.yml
# VERSION: 01.00.00
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
@@ -50,7 +50,7 @@ jobs:
for BRANCH in $BRANCHES; do
# Skip protected branches
case "$BRANCH" in
main|master|dev|develop|rc|beta|alpha|release|release/*|production|stable|staging|hotfix/*|version/*) continue ;;
main|master|develop|release/*|hotfix/*) continue ;;
esac
# Check if branch is merged into main
+3 -12
View File
@@ -47,15 +47,6 @@ jobs:
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
# This RC flow drives a Joomla-style update stream (updates.xml). Repos that don't ship
# one (e.g. generic Go/TS) have nothing to package here, so no-op cleanly instead of
# aborting under `set -e` when the file is absent.
if [ ! -f updates.xml ]; then
echo "has_updates=false" >> "$GITHUB_OUTPUT"
echo "No updates.xml in this repo — skipping RC update-stream packaging"
exit 0
fi
echo "has_updates=true" >> "$GITHUB_OUTPUT"
BASE_VERSION=$(sed -n 's/.*<version>\(.*\)<\/version>.*/\1/p' updates.xml | head -1)
[ -z "$BASE_VERSION" ] && BASE_VERSION="04.00.00"
RC_VERSION="${BASE_VERSION}-rc.${PR_NUMBER}"
@@ -65,7 +56,7 @@ jobs:
echo "RC version: $RC_VERSION (tag: $RC_TAG)"
- name: Update updates.xml RC channel
if: steps.guard.outputs.skip != 'true' && steps.version.outputs.has_updates == 'true'
if: steps.guard.outputs.skip != 'true'
env:
RC_VERSION: ${{ steps.version.outputs.version }}
RC_TAG: ${{ steps.version.outputs.tag }}
@@ -115,7 +106,7 @@ jobs:
PYEOF
- name: Create RC release
if: steps.guard.outputs.skip != 'true' && steps.version.outputs.has_updates == 'true'
if: steps.guard.outputs.skip != 'true'
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
RC_TAG: ${{ steps.version.outputs.tag }}
@@ -162,7 +153,7 @@ jobs:
PYEOF
- name: Commit updates.xml
if: steps.guard.outputs.skip != 'true' && steps.version.outputs.has_updates == 'true'
if: steps.guard.outputs.skip != 'true'
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
+126
View File
@@ -0,0 +1,126 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Deploy
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
# VERSION: 04.07.00
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
name: "Universal: Deploy to Dev (Manual)"
on:
workflow_dispatch:
inputs:
clear_remote:
description: 'Delete all remote files before uploading'
required: false
default: 'false'
type: boolean
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions:
contents: read
jobs:
deploy:
name: SFTP Deploy to Dev
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP
run: |
php -v && composer --version
- name: Setup MokoStandards tools
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
run: |
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
/tmp/mokostandards-api 2>/dev/null || true
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
fi
- name: Check FTP configuration
id: check
env:
HOST: ${{ vars.DEV_FTP_HOST }}
PATH_VAR: ${{ vars.DEV_FTP_PATH }}
PORT: ${{ vars.DEV_FTP_PORT }}
run: |
if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "host=$HOST" >> "$GITHUB_OUTPUT"
REMOTE="${PATH_VAR%/}"
echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
[ -z "$PORT" ] && PORT="22"
echo "port=$PORT" >> "$GITHUB_OUTPUT"
- name: Deploy via SFTP
if: steps.check.outputs.skip != 'true'
env:
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
"${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
> /tmp/sftp-config.json
if [ -n "$SFTP_KEY" ]; then
echo "$SFTP_KEY" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
else
printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json
fi
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
[ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
else
php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
fi
rm -f /tmp/deploy_key /tmp/sftp-config.json
- name: Summary
if: always()
run: |
if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
else
echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY
fi
+3 -3
View File
@@ -3,10 +3,10 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGitea.Workflow
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
# PATH: /.mokogitea/workflows/gitleaks.yml
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/gitleaks.yml.template
# VERSION: 01.00.00
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
#
+1 -1
View File
@@ -3,7 +3,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGitea.Workflow
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 01.00.00
# BRIEF: Auto-create feature branch when an issue is opened
+5 -5
View File
@@ -3,10 +3,10 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGitea.Workflow
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.mokogitea/workflows/notify.yml
# PATH: /.gitea/workflows/notify.yml
# VERSION: 01.00.00
# BRIEF: Push notifications via ntfy on release success or workflow failure
@@ -15,9 +15,9 @@ name: "Universal: Notifications"
on:
workflow_run:
workflows:
- "Universal: Build & Release"
- "Joomla: Extension CI"
- "Generic: Project CI"
- "Joomla Build & Release"
- "Joomla Extension CI"
- "Deploy"
types:
- completed
+14 -95
View File
@@ -3,10 +3,10 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGitea.Workflow
# INGROUP: mokocli.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
# PATH: /.mokogitea/workflows/pr-check.yml
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/pr-check.yml.template
# VERSION: 09.23.00
# BRIEF: PR gate — branch policy + code validation before merge
@@ -47,15 +47,15 @@ jobs:
fi
;;
fix/*|bugfix/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev' or 'main', not '${BASE}'"
REASON="Fix branches must target 'dev', not '${BASE}'"
fi
;;
patch/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ] && [ "$BASE" != "main" ]; then
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
ALLOWED=false
REASON="Patch branches must target 'dev', 'rc', or 'main', not '${BASE}'"
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
fi
;;
hotfix/*)
@@ -86,8 +86,7 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`patch/*\` → \`dev\`, \`rc\`, or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
@@ -97,80 +96,6 @@ jobs:
echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
# ── Docs Update Gate (main PRs) ─────────────────────────────────────────
require-docs:
name: Require Docs Update
runs-on: ubuntu-latest
# Enforce only on PRs merging into main: README.md + CHANGELOG.md must both be updated.
if: ${{ github.base_ref == 'main' }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Require README.md and CHANGELOG.md in the PR diff
run: |
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
CHANGED="$(git diff --name-only "$BASE" "$HEAD" 2>/dev/null || true)"
if [ -z "$CHANGED" ]; then
git fetch -q origin "${{ github.base_ref }}" 2>/dev/null || true
CHANGED="$(git diff --name-only "origin/${{ github.base_ref }}...HEAD" 2>/dev/null || true)"
fi
echo "Changed files in PR:"
echo "$CHANGED"
MISSING=""
echo "$CHANGED" | grep -qxE 'README\.md' || MISSING="README.md"
echo "$CHANGED" | grep -qxE 'CHANGELOG\.md' || MISSING="${MISSING:+$MISSING, }CHANGELOG.md"
if [ -n "$MISSING" ]; then
echo "::error::PRs into main must update: ${MISSING}"
{
echo "## Docs Update Required"
echo ""
echo "PRs merging into \`main\` must update both **README.md** and **CHANGELOG.md**."
echo ""
echo "Not updated in this PR: **${MISSING}**"
} >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
echo "Docs update present (README.md + CHANGELOG.md)"
echo "## Docs Update: Passed" >> "$GITHUB_STEP_SUMMARY"
# ── Wiki Update Reminder (main PRs, non-blocking) ───────────────────────
wiki-reminder:
name: Wiki Update Reminder
runs-on: ubuntu-latest
if: ${{ github.base_ref == 'main' }}
steps:
- name: Remind to update the wiki
env:
TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
SERVER: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
run: |
set -uo pipefail
{
echo "## Wiki Update Reminder"
echo ""
echo "Docs are **wiki-first** at MokoConsulting. If this change affects behavior, usage, configuration, or standards, update the repo wiki:"
echo ""
echo "- ${SERVER}/${REPO}/wiki"
echo ""
echo "_Non-blocking reminder._"
} >> "$GITHUB_STEP_SUMMARY"
# Post a single PR comment (idempotent via hidden marker); best-effort, never fails.
API="${SERVER}/api/v1/repos/${REPO}/issues/${PR}/comments"
if [ -n "${TOKEN:-}" ] && [ -n "${PR:-}" ]; then
existing="$(curl -sf -H "Authorization: token ${TOKEN}" "$API" 2>/dev/null | grep -c 'wiki-reminder' || true)"
if [ "${existing:-0}" -eq 0 ]; then
curl -sf -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" -X POST "$API" \
-d '{"body":"<!-- wiki-reminder -->\n\n**Wiki reminder:** docs are wiki-first -- if this PR changes behavior, usage, config, or standards, please update the repo wiki before/after merge. _(non-blocking)_"}' >/dev/null 2>&1 || true
fi
fi
echo "Wiki reminder emitted (non-blocking)."
# ── Secret Scanning ──────────────────────────────────────────────────
gitleaks:
name: Secret Scan
@@ -201,8 +126,6 @@ jobs:
validate:
name: Validate PR
runs-on: ubuntu-latest
# Skip on template repos (Template-*) — no real manifest/source/changelog to validate.
if: ${{ !startsWith(github.event.repository.name, 'Template-') }}
steps:
- name: Checkout
@@ -224,12 +147,11 @@ jobs:
- name: Detect platform
id: platform
run: |
# Platform comes from the MokoGitea metadata API (public GET); manifest.xml is no longer used.
API="${GITHUB_SERVER_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${GITHUB_REPOSITORY}/metadata"
PLATFORM="$(curl -sf "$API" 2>/dev/null | python3 -c "import sys, json; print(json.load(sys.stdin).get('platform') or '')" 2>/dev/null || true)"
# Read platform from XML manifest (<platform> tag) or plain text fallback
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
echo "Detected platform: $PLATFORM"
- name: Setup PHP
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
@@ -350,7 +272,7 @@ jobs:
joomla)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "::warning::No Joomla manifest found (MokoSuite site)"
echo "::warning::No Joomla manifest found (WaaS site)"
exit 0
fi
echo "Manifest: ${MANIFEST}"
@@ -363,7 +285,7 @@ jobs:
# Block legacy raw/branch update server URLs on MokoGitea
RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true)
if [ -n "$RAW_URLS" ]; then
echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the MokoGitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)"
echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)"
echo "$RAW_URLS"
exit 1
fi
@@ -570,9 +492,6 @@ jobs:
name: Build RC Package
runs-on: ubuntu-latest
needs: [branch-policy, validate]
# Run only when both gates succeeded; always() forces evaluation so a skipped
# validate (e.g. template repos) skips this job cleanly instead of hanging.
if: ${{ always() && needs.branch-policy.result == 'success' && needs.validate.result == 'success' }}
steps:
- name: Trigger RC pre-release
+8 -12
View File
@@ -3,10 +3,10 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGitea.Workflow
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
# PATH: /.mokogitea/workflows/pre-release.yml
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 05.02.00
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
@@ -48,13 +48,9 @@ jobs:
build:
name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})"
runs-on: release
# Skip on template repos (Template-*) — they scaffold other repos and do not release.
if: >-
!startsWith(github.event.repository.name, 'Template-') &&
(
github.event_name == 'workflow_dispatch' ||
github.event_name == 'push'
)
github.event_name == 'workflow_dispatch' ||
github.event_name == 'push'
steps:
- name: Checkout
@@ -84,7 +80,7 @@ jobs:
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi
rm -rf /tmp/mokocli
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoCLI.git
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
@@ -156,8 +152,8 @@ jobs:
fi
# Commit version bump
git config --local user.email "mokogitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "mokogitea-actions[bot]"
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
git add -A
git diff --cached --quiet || {
+3 -4
View File
@@ -3,9 +3,9 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGitea.Workflow
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/rc-revert.yml
# VERSION: 09.23.00
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
@@ -25,8 +25,7 @@ jobs:
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == false &&
startsWith(github.event.pull_request.head.ref, 'rc/') &&
!startsWith(github.event.repository.name, 'Template-')
startsWith(github.event.pull_request.head.ref, 'rc/')
steps:
- name: Rename branch
+4 -4
View File
@@ -6,10 +6,10 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGitea.Workflow
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Validation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
# PATH: /.mokogitea/workflows/repo-health.yml
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
# PATH: /templates/workflows/joomla/repo_health.yml.template
# VERSION: 09.23.00
# BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts.
# ============================================================================
@@ -88,7 +88,7 @@ jobs:
# Hardcoded authorized users — always allowed
case "$ACTOR" in
jmiller|mokogitea-actions[bot])
jmiller|gitea-actions[bot])
ALLOWED=true
PERMISSION=admin
METHOD="hardcoded allowlist"
-32
View File
@@ -1,32 +0,0 @@
name: Sync Workflows to Repos
on:
push:
branches:
- main
paths:
- '.mokogitea/workflows/**'
jobs:
sync:
runs-on: ubuntu-latest
if: ${{ startsWith(github.event.repository.name, 'Template-') }}
steps:
- name: Checkout mokocli
uses: actions/checkout@v4
with:
repository: MokoConsulting/MokoCLI
token: ${{ secrets.MOKOGITEA_TOKEN }}
- name: Setup PHP
uses: https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/raw/branch/main/actions/setup-php@v1
with:
php-version: '8.1'
- name: Install dependencies
run: composer install --no-dev --no-interaction
- name: Sync workflows to generic repos
run: php automation/bulk_sync.php --platform generic --org MokoConsulting --workflows-only --auto-merge --token "${{ secrets.MOKOGITEA_TOKEN }}"
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
+1 -2
View File
@@ -3,7 +3,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGitea.Workflow.Template
# DEFGROUP: Gitea.Workflow.Template
# INGROUP: MokoStandards.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
# PATH: /.mokogitea/workflows/version-set.yml
@@ -34,7 +34,6 @@ jobs:
set-version:
name: Set Version to ${{ inputs.version }}
runs-on: ubuntu-latest
if: ${{ !startsWith(github.event.repository.name, 'Template-') }}
steps:
- name: Validate version format
@@ -3,9 +3,9 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: MokoGitea.Workflow
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/workflow-sync-trigger.yml
# VERSION: 01.01.00
# BRIEF: Trigger workflow sync to live repos when a PR is merged to main
@@ -27,10 +27,9 @@ jobs:
name: Sync workflows to live repos
runs-on: ubuntu-latest
if: >-
startsWith(github.event.repository.name, 'Template-') &&
(github.event_name == 'workflow_dispatch' ||
github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.merged == true &&
!contains(github.event.pull_request.title, '[skip sync]')))
!contains(github.event.pull_request.title, '[skip sync]'))
steps:
- name: Determine platform from repo name
@@ -53,7 +52,7 @@ jobs:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
MOKOGITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
git clone --depth 1 "${MOKOGITEA_URL}/MokoConsulting/MokoCLI.git" /tmp/mokocli
git clone --depth 1 "${MOKOGITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
- name: Install PHP
run: |
-24
View File
@@ -3,7 +3,6 @@
## [Unreleased]
### Added
- Code security scanner: pattern-based detection of SQL injection, XSS, command injection, path traversal, insecure deserialization, hardcoded credentials, and weak cryptography across Go/PHP/Python/JS/TS (#552)
- Cascade merge: auto-create PRs to downstream branches after merge with configurable rules per repo (#460)
- Issue status presets: 4 built-in templates (default, software-development, support-tickets, bug-tracking) with API + web UI (#507)
- Cross-org status migration: copy status definitions from one org to another via API (#507)
@@ -41,30 +40,7 @@
- Wiki recent changes page: cross-page edit activity with pagination (#670)
- Wiki page rename with automatic redirects via YAML frontmatter (#672)
### Security
- Cherry-pick upstream v1.26.3: LFS reject unknown SSH sub-verbs to prevent auth bypass (#38015)
- Cherry-pick upstream v1.26.3: bound CODEOWNERS regex match time — ReDoS prevention (#38025)
- Cherry-pick upstream v1.26.3: require merged PR to bypass fork PR approval gate (#38041)
- Cherry-pick upstream v1.26.3: LFS require Code-unit access for cross-repo object reuse (#38050)
- Cherry-pick upstream v1.26.3: hostmatcher block reserved IP ranges — SSRF prevention (#38059)
- Cherry-pick upstream v1.26.3: bound debian ParseControlFile — DoS prevention (#38055)
- Cherry-pick upstream v1.26.3: feed token scope, migration SSRF, notification redaction (#38147)
- Cherry-pick upstream v1.26.3: OIDC ignore stale external login links to organizations (#38141)
- Cherry-pick upstream v1.26.3: 2FA timing, branch delete auth, org labels visibility, merge upstream auth (#38151)
- Cherry-pick upstream v1.26.3: allow git clone of private repos with anonymous code access (#38146)
- Cherry-pick upstream v1.26.3: hostmatcher patch incorrect private IP list (#38173)
- Cherry-pick upstream v1.26.4: do not auto-reactivate disabled users on OAuth2 callback (#38183)
- Cherry-pick upstream v1.26.4: walk git log context error handling — regression fix (#38185)
### Fixed
- Org Teams page: list now renders — the handler wrote `ctx.Data["OrgListTeams"]` but the template reads `.Teams`, so the page showed header/nav but no teams (#720)
- Issue type: now editable after creation for users with issue write permission — the sidebar gated editing on a `FieldEditFlags` data key that was never populated (always read-only); now uses `HasIssuesOrPullsWritePermission` like the priority field (#721)
- Admin config form: radio inputs (e.g. instance landing page Mode) no longer throw "Unsupported config form value mapping", which had aborted all JS init on the admin settings page
- PR check branch policy: allow `fix/*``main` and `patch/*``main` to match documented policy (was rejecting fix/patch PRs to main)
- PR check platform detection: guard for missing `.mokogitea/manifest.xml` so the Validate PR job no longer aborts under `set -e` (manifest replaced by metadata API)
- Remove dangling `mcp-mokogitea-api` submodule gitlink (no `.gitmodules` entry) that broke `submodule update --init` at checkout, failing all PR build/release jobs; ignore the local clone path
- PR RC Release workflow: no-op cleanly when `updates.xml` is absent (generic repos) instead of aborting the "Determine RC version" step under `set -e`
- PR check: platform detection now queries metadata API instead of removed manifest.xml
- Cherry-pick upstream v1.26.2: handle empty pull request files view to allow reviews (#37783)
- Cherry-pick upstream v1.26.2: fix "run as root" check with snap container detection (#37622)
- Cherry-pick upstream: ack re-sent UpdateLog finalize idempotently (#37885)
+2 -6
View File
@@ -1,6 +1,6 @@
# MokoGitea
Custom Gitea fork with enhanced wiki system, DLID licensing, issue statuses, cascade merge, security scanning, org metadata, CI standardization, and project board API.
Custom Gitea fork with enhanced wiki system, DLID licensing, issue statuses, org metadata, CI standardization, and project board API.
![Language](https://img.shields.io/badge/Go-00ADD8?style=flat-square&logo=go&logoColor=white) ![License](https://img.shields.io/badge/license-GPL--3.0--or--later-green?style=flat-square)
@@ -11,12 +11,8 @@ Custom Gitea fork with enhanced wiki system, DLID licensing, issue statuses, cas
- **Wiki System** -- wikilinks, categories, backlinks, template transclusion, revision diffs, rename redirects, folder ACL, enhanced ToC, print view, ZIP export ([details](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/standards/Wiki-Features))
- **DLID Licensing** -- license management, entitlements, domain activations, ed25519-signed downloads
- **API Token Scope Editing** -- edit token scopes via API (PATCH) or web UI after creation
- **Issue Statuses** -- custom workflow statuses per org with required baseline protection, presets, cross-org migration
- **Cascade Merge** -- auto-create PRs to downstream branches after merge with configurable rules per repo
- **Security Scanning** -- secret detection (pre-receive blocking) + code analysis (SQL injection, XSS, command injection, path traversal, and more) with REST API for alerts, config, and on-demand scans
- **Default Org Teams** -- auto-create Developers, Reviewers, and CI/CD teams on org creation
- **Issue Statuses** -- custom workflow statuses per org with required baseline protection
- **Org Metadata** -- per-repo metadata API (public GET, admin PUT), platform detection for versioning
- **Branch Protection** -- delete allowlist for protected branches (per-user/team/deploy-key)
- **Project Board API** -- REST endpoints for project columns and cards
- **CI Infrastructure** -- reusable workflows, centralized ci-issue-reporter, standardized MOKOGITEA_TOKEN naming
- **Dev Deploy Gate** -- builds deploy to dev environment first, production checks dev health
+9 -14
View File
@@ -113,25 +113,23 @@ func handleCliResponseExtra(extra private.ResponseExtra) error {
return nil
}
// getAccessMode maps an SSH git/LFS verb to the access mode it requires, with
// ok=false for an unrecognised verb. Callers MUST reject the request when ok is
// false: AccessModeNone would otherwise pass the `userMode < mode` permission
// check in routers/private/serv.go and grant access.
func getAccessMode(verb, lfsVerb string) (mode perm.AccessMode, ok bool) {
func getAccessMode(verb, lfsVerb string) perm.AccessMode {
switch verb {
case git.CmdVerbUploadPack, git.CmdVerbUploadArchive:
return perm.AccessModeRead, true
return perm.AccessModeRead
case git.CmdVerbReceivePack:
return perm.AccessModeWrite, true
return perm.AccessModeWrite
case git.CmdVerbLfsAuthenticate, git.CmdVerbLfsTransfer:
switch lfsVerb {
case git.CmdSubVerbLfsUpload:
return perm.AccessModeWrite, true
return perm.AccessModeWrite
case git.CmdSubVerbLfsDownload:
return perm.AccessModeRead, true
return perm.AccessModeRead
}
}
return perm.AccessModeNone, false
// should be unreachable
setting.PanicInDevOrTesting("unknown verb: %s %s", verb, lfsVerb)
return perm.AccessModeNone
}
func runServ(ctx context.Context, c *cli.Command) error {
@@ -249,10 +247,7 @@ func runServ(ctx context.Context, c *cli.Command) error {
}
}
requestedMode, ok := getAccessMode(verb, lfsVerb)
if !ok {
return fail(ctx, "Unknown git command", "Unknown git command %s %s", verb, lfsVerb)
}
requestedMode := getAccessMode(verb, lfsVerb)
results, extra := private.ServCommand(ctx, keyID, username, reponame, requestedMode, verb, lfsVerb)
if extra.HasError() {
-56
View File
@@ -1,56 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"testing"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
"github.com/stretchr/testify/assert"
)
func TestGetAccessMode(t *testing.T) {
cases := []struct {
verb, lfsVerb string
expected perm.AccessMode
}{
{git.CmdVerbUploadPack, "", perm.AccessModeRead},
{git.CmdVerbUploadArchive, "", perm.AccessModeRead},
{git.CmdVerbReceivePack, "", perm.AccessModeWrite},
{git.CmdVerbLfsAuthenticate, git.CmdSubVerbLfsUpload, perm.AccessModeWrite},
{git.CmdVerbLfsAuthenticate, git.CmdSubVerbLfsDownload, perm.AccessModeRead},
{git.CmdVerbLfsTransfer, git.CmdSubVerbLfsUpload, perm.AccessModeWrite},
{git.CmdVerbLfsTransfer, git.CmdSubVerbLfsDownload, perm.AccessModeRead},
}
for _, tc := range cases {
t.Run(tc.verb+"/"+tc.lfsVerb, func(t *testing.T) {
mode, ok := getAccessMode(tc.verb, tc.lfsVerb)
assert.True(t, ok)
assert.Equal(t, tc.expected, mode)
})
}
}
// TestGetAccessModeUnknownVerb locks in the invariant that getAccessMode reports
// ok=false for unrecognised verbs and LFS sub-verbs, so runServ rejects them. An
// unknown verb has no valid access mode; if it were treated as AccessModeNone (0)
// it would pass the `userMode < mode` permission check in routers/private/serv.go
// and hand out valid LFS JWTs for any private repository.
func TestGetAccessModeUnknownVerb(t *testing.T) {
cases := []struct{ verb, lfsVerb string }{
{git.CmdVerbLfsAuthenticate, ""},
{git.CmdVerbLfsAuthenticate, "badverb"},
{git.CmdVerbLfsTransfer, "badverb"},
{"git-unknown-verb", ""},
}
for _, tc := range cases {
t.Run(tc.verb+"/"+tc.lfsVerb, func(t *testing.T) {
mode, ok := getAccessMode(tc.verb, tc.lfsVerb)
assert.False(t, ok)
assert.Equal(t, perm.AccessModeNone, mode)
})
}
}
+2
View File
@@ -51,6 +51,8 @@ ROOT_PATH = /data/gitea/log
[security]
INSTALL_LOCK = $INSTALL_LOCK
SECRET_KEY = $SECRET_KEY
REVERSE_PROXY_LIMIT = 1
REVERSE_PROXY_TRUSTED_PROXIES = *
[service]
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
+2
View File
@@ -48,6 +48,8 @@ ROOT_PATH = $GITEA_WORK_DIR/data/log
[security]
INSTALL_LOCK = $INSTALL_LOCK
SECRET_KEY = $SECRET_KEY
REVERSE_PROXY_LIMIT = 1
REVERSE_PROXY_TRUSTED_PROXIES = *
[service]
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
+1
Submodule mcp-mokogitea-api added at dbaf91546e
+4
View File
@@ -64,6 +64,7 @@ type FindRunOptions struct {
Ref string // the commit/tag/… that caused this workflow
TriggerUserID int64
TriggerEvent webhook_module.HookEventType
Approved bool // not util.OptionalBool, it works only when it's true
Status []Status
ConcurrencyGroup string
CommitSHA string
@@ -80,6 +81,9 @@ func (opts FindRunOptions) ToConds() builder.Cond {
if opts.TriggerUserID > 0 {
cond = cond.And(builder.Eq{"`action_run`.trigger_user_id": opts.TriggerUserID})
}
if opts.Approved {
cond = cond.And(builder.Gt{"`action_run`.approved_by": 0})
}
if len(opts.Status) > 0 {
cond = cond.And(builder.In("`action_run`.status", opts.Status))
}
+4 -28
View File
@@ -21,7 +21,6 @@ import (
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/pbkdf2"
"xorm.io/builder"
)
//
@@ -105,43 +104,20 @@ func (t *TwoFactor) SetSecret(secretString string) error {
return nil
}
// validateTOTP validates the provided passcode. It does not consume the passcode; all login
// surfaces must go through ValidateAndConsumeTOTP so that a passcode cannot be redeemed twice.
func (t *TwoFactor) validateTOTP(passcode string) (bool, error) {
// ValidateTOTP validates the provided passcode.
func (t *TwoFactor) ValidateTOTP(passcode string) (bool, error) {
decodedStoredSecret, err := base64.StdEncoding.DecodeString(t.Secret)
if err != nil {
return false, fmt.Errorf("validateTOTP invalid base64: %w", err)
return false, fmt.Errorf("ValidateTOTP invalid base64: %w", err)
}
secretBytes, err := secret.AesDecrypt(t.getEncryptionKey(), decodedStoredSecret)
if err != nil {
return false, fmt.Errorf("validateTOTP unable to decrypt (maybe SECRET_KEY is wrong): %w", err)
return false, fmt.Errorf("ValidateTOTP unable to decrypt (maybe SECRET_KEY is wrong): %w", err)
}
secretStr := string(secretBytes)
return totp.Validate(passcode, secretStr), nil
}
// ValidateAndConsumeTOTP validates the passcode and atomically records it as used so that the
// same passcode cannot be redeemed more than once (RFC 6238 §5.2). It returns false for an
// invalid passcode as well as for a replay, including the case where a concurrent request with
// the same passcode won the race first. All TOTP login surfaces must go through this helper.
func (t *TwoFactor) ValidateAndConsumeTOTP(ctx context.Context, passcode string) (bool, error) {
ok, err := t.validateTOTP(passcode)
if err != nil || !ok {
return false, err
}
// Conditional update: only a row whose stored passcode differs from this one is updated, so a
// replay (or a concurrent duplicate) matches zero rows and is rejected. The row lock taken by
// the UPDATE serializes racing requests, closing the read-validate-write TOCTOU window.
t.LastUsedPasscode = passcode
n, err := db.GetEngine(ctx).ID(t.ID).
Where(builder.Or(builder.IsNull{"last_used_passcode"}, builder.Neq{"last_used_passcode": passcode})).
Cols("last_used_passcode").Update(t)
if err != nil {
return false, err
}
return n == 1, nil
}
// NewTwoFactor creates a new two-factor authentication token.
func NewTwoFactor(ctx context.Context, t *TwoFactor) error {
_, err := db.GetEngine(ctx).Insert(t)
-47
View File
@@ -1,47 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth_test
import (
"testing"
"time"
auth_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/auth"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unittest"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTwoFactorValidateAndConsumeTOTP(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
key, err := totp.Generate(totp.GenerateOpts{SecretSize: 40, Issuer: "gitea-test", AccountName: "consume"})
require.NoError(t, err)
tfa := &auth_model.TwoFactor{UID: 1}
require.NoError(t, tfa.SetSecret(key.Secret()))
require.NoError(t, auth_model.NewTwoFactor(t.Context(), tfa))
passcode, err := totp.GenerateCode(key.Secret(), time.Now())
require.NoError(t, err)
// first use of a valid passcode succeeds
ok, err := tfa.ValidateAndConsumeTOTP(t.Context(), passcode)
require.NoError(t, err)
assert.True(t, ok)
// replaying the same passcode is refused, even when still inside the TOTP validity window
reloaded, err := auth_model.GetTwoFactorByUID(t.Context(), tfa.UID)
require.NoError(t, err)
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), passcode)
require.NoError(t, err)
assert.False(t, ok)
// an invalid passcode is rejected without consuming anything
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), "000000")
require.NoError(t, err)
assert.False(t, ok)
}
+2 -5
View File
@@ -196,10 +196,7 @@ func LFSObjectAccessible(ctx context.Context, user *user_model.User, oid string)
count, err := db.GetEngine(ctx).Count(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
return count > 0, err
}
// LFS objects are repository code content, so authorization must require
// Code-unit access; other unit accesses (e.g. Issues) must not authorize
// reuse of an existing LFS object across repositories.
cond := repo_model.AccessibleRepositoryCondition(user, unit.TypeCode)
cond := repo_model.AccessibleRepositoryCondition(user, unit.TypeInvalid)
count, err := db.GetEngine(ctx).Where(cond).Join("INNER", "repository", "`lfs_meta_object`.repository_id = `repository`.id").Count(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
return count > 0, err
}
@@ -223,7 +220,7 @@ func LFSAutoAssociate(ctx context.Context, metas []*LFSMetaObject, user *user_mo
newMetas := make([]*LFSMetaObject, 0, len(metas))
cond := builder.In(
"`lfs_meta_object`.repository_id",
builder.Select("`repository`.id").From("repository").Where(repo_model.AccessibleRepositoryCondition(user, unit.TypeCode)),
builder.Select("`repository`.id").From("repository").Where(repo_model.AccessibleRepositoryCondition(user, unit.TypeInvalid)),
)
if err := db.GetEngine(ctx).Cols("oid").Where(cond).In("oid", oids...).GroupBy("oid").Find(&newMetas); err != nil {
return err
-8
View File
@@ -10,7 +10,6 @@ import (
"fmt"
"io"
"strings"
"time"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
@@ -861,11 +860,6 @@ func GetCodeOwnersFromContent(ctx context.Context, data string) ([]*CodeOwnerRul
return rules, warnings
}
// codeOwnerMatchTimeout bounds a single pattern match so a crafted pattern
// cannot stall via catastrophic backtracking. See also the aggregate budget
// enforced by the caller across the whole rules×files match loop.
const codeOwnerMatchTimeout = 150 * time.Millisecond
type CodeOwnerRule struct {
Rule *regexp2.Regexp // it supports negative lookahead, does better for end users
Negative bool
@@ -894,8 +888,6 @@ func ParseCodeOwnersLine(ctx context.Context, tokens []string) (*CodeOwnerRule,
warnings = append(warnings, fmt.Sprintf("incorrect codeowner regexp: %s", err))
return nil, warnings
}
// Bound matching time so user-supplied patterns cannot stall PR creation via catastrophic backtracking.
rule.Rule.MatchTimeout = codeOwnerMatchTimeout
for _, user := range tokens[1:] {
user = strings.TrimPrefix(user, "@")
-19
View File
@@ -4,9 +4,7 @@
package issues_test
import (
"strings"
"testing"
"time"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
@@ -41,7 +39,6 @@ func TestPullRequest(t *testing.T) {
t.Run("DeleteOrphanedObjects", testDeleteOrphanedObjects)
t.Run("ParseCodeOwnersLine", testParseCodeOwnersLine)
t.Run("CodeOwnerAbsolutePathPatterns", testCodeOwnerAbsolutePathPatterns)
t.Run("CodeOwnerPatternMatchTimeout", testCodeOwnerPatternMatchTimeout)
t.Run("GetApprovers", testGetApprovers)
t.Run("GetPullRequestByMergedCommit", testGetPullRequestByMergedCommit)
t.Run("Migrate_InsertPullRequests", testMigrateInsertPullRequests)
@@ -373,22 +370,6 @@ func testCodeOwnerAbsolutePathPatterns(t *testing.T) {
}
}
// testCodeOwnerPatternMatchTimeout ensures user-supplied CODEOWNERS patterns
// cannot stall pull request processing through catastrophic regex backtracking:
// each compiled rule must enforce a bounded match time.
func testCodeOwnerPatternMatchTimeout(t *testing.T) {
rules, _ := issues_model.GetCodeOwnersFromContent(t.Context(), "(a+)+ @user5\n")
require.Len(t, rules, 1)
maliciousInput := strings.Repeat("a", 30) + "X"
start := time.Now()
_, err := rules[0].Rule.MatchString(maliciousInput)
elapsed := time.Since(start)
require.Error(t, err, "expected MatchTimeout error on pathological input")
assert.Less(t, elapsed, time.Second, "match timeout did not bound regex evaluation; took %s", elapsed)
}
func testGetApprovers(t *testing.T) {
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 5})
// Official reviews are already deduplicated. Allow unofficial reviews
+1 -1
View File
@@ -47,7 +47,7 @@ func OrderBy(orderBy string) any {
}
func whereOrderConditions(e db.Engine, conditions []any) db.Engine {
orderBy := "id" // query must have the "ORDER BY", otherwise the result is not deterministic. FIXME: some tables do not have "id" column
orderBy := "id" // query must have the "ORDER BY", otherwise the result is not deterministic
for _, condition := range conditions {
switch cond := condition.(type) {
case *testCond:
+2 -11
View File
@@ -80,11 +80,8 @@ func init() {
}
// GetExternalLogin checks if a externalID in loginSourceID scope already exists
func GetExternalLogin(ctx context.Context, loginSourceID int64, externalID string) (*ExternalLoginUser, bool, error) {
return db.Get[ExternalLoginUser](ctx, builder.Eq{
"external_id": externalID,
"login_source_id": loginSourceID,
})
func GetExternalLogin(ctx context.Context, externalLoginUser *ExternalLoginUser) (bool, error) {
return db.GetEngine(ctx).Get(externalLoginUser)
}
// LinkExternalToUser link the external user to the user
@@ -121,12 +118,6 @@ func RemoveAllAccountLinks(ctx context.Context, user *User) error {
return err
}
// RemoveExternalLoginByExternalID removes a specific external login link by its provider-side identifier.
func RemoveExternalLoginByExternalID(ctx context.Context, loginSourceID int64, externalID string) error {
_, err := db.GetEngine(ctx).Where("external_id=? AND login_source_id=?", externalID, loginSourceID).Delete(new(ExternalLoginUser))
return err
}
// GetUserIDByExternalUserID get user id according to provider and userID
func GetUserIDByExternalUserID(ctx context.Context, provider, userID string) (int64, error) {
var id int64
+1 -1
View File
@@ -106,7 +106,7 @@ func getLastCommitForPathsByCache(commitID, treePath string, paths []string, cac
// GetLastCommitForPaths returns last commit information
func GetLastCommitForPaths(ctx context.Context, commit *Commit, treePath string, paths []string) (map[string]*Commit, error) {
// We read backwards from the commit to obtain all of the commits
revs, err := walkGitLog(ctx, commit.repo, commit, treePath, paths...)
revs, err := WalkGitLog(ctx, commit.repo, commit, treePath, paths...)
if err != nil {
return nil, err
}
-54
View File
@@ -1,54 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !gogit
package git
import (
"context"
"path/filepath"
"testing"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/test"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEntries_GetCommitsInfo_ContextErr(t *testing.T) {
repo, err := OpenRepository(t.Context(), filepath.Join(testReposDir, "repo1_bare"))
require.NoError(t, err)
defer repo.Close()
commit, err := repo.GetCommit("feaf4ba6bc635fec442f46ddd4512416ec43c2c2")
require.NoError(t, err)
entries, err := commit.Tree.ListEntries()
require.NoError(t, err)
countCommitInfosCommit := func(infos []CommitInfo) (nilCommits, nonNilCommits int) {
for _, info := range infos {
nilCommits += util.Iif(info.Commit == nil, 1, 0)
nonNilCommits += util.Iif(info.Commit != nil, 1, 0)
}
return nilCommits, nonNilCommits
}
ctx, cancel := context.WithCancel(t.Context())
defer test.MockVariableValue(&walkGitLogDebugBeforeNext)()
walkGitLogDebugBeforeNext = cancel
commitInfos, _, err := entries.GetCommitsInfo(ctx, "/any/repo-link", commit, "")
assert.NoError(t, err)
nilCommits, nonNilCommits := countCommitInfosCommit(commitInfos)
assert.Equal(t, 0, nonNilCommits) // no commit info due to canceled (or deadline-exceeded) context
assert.Equal(t, 3, nilCommits)
walkGitLogDebugBeforeNext = nil
commitInfos, _, err = entries.GetCommitsInfo(t.Context(), "/any/repo-link", commit, "")
assert.NoError(t, err)
nilCommits, nonNilCommits = countCommitInfosCommit(commitInfos)
assert.Equal(t, 3, nonNilCommits)
assert.Equal(t, 0, nilCommits)
}
+1 -1
View File
@@ -32,7 +32,7 @@ func (c *Commit) recursiveCache(ctx context.Context, tree *Tree, treePath string
entryPaths[i] = entry.Name()
}
_, err = walkGitLog(ctx, c.repo, c, treePath, entryPaths...)
_, err = WalkGitLog(ctx, c.repo, c, treePath, entryPaths...)
if err != nil {
return err
}
@@ -1,8 +1,6 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !gogit
package git
import (
@@ -20,8 +18,10 @@ import (
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
)
// logNameStatusRepo opens git log --raw in the provided repo and returns a parser
func logNameStatusRepo(ctx context.Context, repository, head, treepath string, paths ...string) *logNameStatusRepoParser {
// LogNameStatusRepo opens git log --raw in the provided repo and returns a stdin pipe, a stdout reader and cancel function
func LogNameStatusRepo(ctx context.Context, repository, head, treepath string, paths ...string) (*bufio.Reader, func()) {
// Lets also create a context so that we can absolutely ensure that the command should die when we're done
cmd := gitcmd.NewCommand()
cmd.AddArguments("log", "--name-status", "-c", "--format=commit%x00%H %P%x00", "--parents", "--no-renames", "-t", "-z").AddDynamicArguments(head)
@@ -54,62 +54,77 @@ func logNameStatusRepo(ctx context.Context, repository, head, treepath string, p
ctx, ctxCancel := context.WithCancel(ctx)
go func() {
err := cmd.WithDir(repository).RunWithStderr(ctx)
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
if err != nil && !errors.Is(err, context.Canceled) {
log.Error("Unable to run git command %v: %v", cmd.LogString(), err)
}
}()
bufReader := bufio.NewReaderSize(stdoutReader, 32*1024)
return &logNameStatusRepoParser{
treepath: treepath,
paths: paths,
rd: bufReader,
close: func() {
ctxCancel()
stdoutReaderClose()
},
return bufReader, func() {
ctxCancel()
stdoutReaderClose()
}
}
// logNameStatusRepoParser parses a git log raw output from LogRawRepo
type logNameStatusRepoParser struct {
// LogNameStatusRepoParser parses a git log raw output from LogRawRepo
type LogNameStatusRepoParser struct {
treepath string
paths []string
next []byte
buffull bool
rd *bufio.Reader
close func()
cancel func()
}
// logNameStatusCommitData represents a commit artifact from git log raw
type logNameStatusCommitData struct {
// NewLogNameStatusRepoParser returns a new parser for a git log raw output
func NewLogNameStatusRepoParser(ctx context.Context, repository, head, treepath string, paths ...string) *LogNameStatusRepoParser {
rd, cancel := LogNameStatusRepo(ctx, repository, head, treepath, paths...)
return &LogNameStatusRepoParser{
treepath: treepath,
paths: paths,
rd: rd,
cancel: cancel,
}
}
// LogNameStatusCommitData represents a commit artefact from git log raw
type LogNameStatusCommitData struct {
CommitID string
ParentIDs []string
Paths []bool
}
// walkNext returns the next LogStatusCommitData
func (g *logNameStatusRepoParser) walkNext(treepath string, paths2ids map[string]int, changed []bool, maxpathlen int) (*logNameStatusCommitData, error) {
// Next returns the next LogStatusCommitData
func (g *LogNameStatusRepoParser) Next(treepath string, paths2ids map[string]int, changed []bool, maxpathlen int) (*LogNameStatusCommitData, error) {
var err error
if len(g.next) == 0 {
g.buffull = false
g.next, err = g.rd.ReadSlice('\x00')
switch {
case errors.Is(err, bufio.ErrBufferFull):
g.buffull = true
case err != nil:
return nil, err
if err != nil {
switch err {
case bufio.ErrBufferFull:
g.buffull = true
case io.EOF:
return nil, nil //nolint:nilnil // return nil to signal EOF
default:
return nil, err
}
}
}
ret := logNameStatusCommitData{}
ret := LogNameStatusCommitData{}
if bytes.Equal(g.next, []byte("commit\000")) {
g.next, err = g.rd.ReadSlice('\x00')
switch {
case errors.Is(err, bufio.ErrBufferFull):
g.buffull = true
case err != nil:
return nil, err
if err != nil {
switch err {
case bufio.ErrBufferFull:
g.buffull = true
case io.EOF:
return nil, nil //nolint:nilnil // return nil to signal EOF
default:
return nil, err
}
}
}
@@ -258,10 +273,13 @@ diffloop:
}
}
var walkGitLogDebugBeforeNext func() // is used to simulate various edge git process cases
// Close closes the parser
func (g *LogNameStatusRepoParser) Close() {
g.cancel()
}
// walkGitLog walks the git log --name-status for the head commit in the provided treepath and files
func walkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath string, paths ...string) (map[string]string, error) {
// WalkGitLog walks the git log --name-status for the head commit in the provided treepath and files
func WalkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath string, paths ...string) (map[string]string, error) {
headRef := head.ID.String()
tree, err := head.SubTree(treepath)
@@ -304,9 +322,11 @@ func walkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath st
}
}
g := logNameStatusRepo(ctx, repo.Path, head.ID.String(), treepath, paths...)
// don't use defer g.cancel() here as g may change its value - instead wrap in a func
defer func() { g.close() }()
g := NewLogNameStatusRepoParser(ctx, repo.Path, head.ID.String(), treepath, paths...)
// don't use defer g.Close() here as g may change its value - instead wrap in a func
defer func() {
g.Close()
}()
results := make([]string, len(paths))
remaining := len(paths)
@@ -320,16 +340,25 @@ func walkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath st
heaploop:
for {
if walkGitLogDebugBeforeNext != nil {
walkGitLogDebugBeforeNext()
select {
case <-ctx.Done():
if ctx.Err() == context.DeadlineExceeded {
break heaploop
}
g.Close()
return nil, ctx.Err()
default:
}
current, err := g.walkNext(treepath, path2idx, changed, maxpathlen)
if ctx.Err() != nil {
break heaploop // context is either canceled or deadline exceeded - break the loop and return what we have so far
} else if errors.Is(err, io.EOF) {
break heaploop // reached to the end of log output
} else if err != nil {
return nil, err // other unknown errors
current, err := g.Next(treepath, path2idx, changed, maxpathlen)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
break heaploop
}
g.Close()
return nil, err
}
if current == nil {
break heaploop
}
parentRemaining.Remove(current.CommitID)
for i, found := range current.Paths {
@@ -366,14 +395,14 @@ heaploop:
if remaining <= nextRestart {
commitSinceNextRestart++
if 4*commitSinceNextRestart > 3*commitSinceLastEmptyParent {
g.Close()
remainingPaths := make([]string, 0, len(paths))
for i, pth := range paths {
if results[i] == "" {
remainingPaths = append(remainingPaths, pth)
}
}
g.close()
g = logNameStatusRepo(ctx, repo.Path, lastEmptyParent, treepath, remainingPaths...)
g = NewLogNameStatusRepoParser(ctx, repo.Path, lastEmptyParent, treepath, remainingPaths...)
parentRemaining = make(container.Set[string])
nextRestart = (remaining * 3) / 4
continue heaploop
@@ -381,6 +410,7 @@ heaploop:
}
parentRemaining.AddMultiple(current.ParentIDs...)
}
g.Close()
resultsMap := map[string]string{}
for i, pth := range paths {
-3
View File
@@ -121,9 +121,6 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
}
cmd := gitcmd.NewCommand().AddArguments("clone")
// Never follow HTTP redirects: no clone caller needs them, and a remote redirecting to an
// otherwise-blocked address would be an SSRF vector (e.g. migrating from an attacker URL).
cmd.AddArguments("-c", "http.followRedirects=false")
if opts.SkipTLSVerify {
cmd.AddArguments("-c", "http.sslVerify=false")
}
-23
View File
@@ -4,10 +4,7 @@
package git
import (
"net/http"
"net/http/httptest"
"path/filepath"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
@@ -22,23 +19,3 @@ func TestRepoIsEmpty(t *testing.T) {
assert.NoError(t, err)
assert.True(t, isEmpty)
}
// TestCloneRefusesRedirects ensures Clone never follows HTTP redirects, so a remote
// cannot redirect to an otherwise-blocked address (SSRF, e.g. during migration).
func TestCloneRefusesRedirects(t *testing.T) {
var targetHit atomic.Bool
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
targetHit.Store(true)
w.WriteHeader(http.StatusNotFound)
}))
defer target.Close()
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+r.URL.Path, http.StatusFound)
}))
defer redirect.Close()
err := Clone(t.Context(), redirect.URL, filepath.Join(t.TempDir(), "dst"), CloneRepoOptions{})
assert.Error(t, err)
assert.False(t, targetHit.Load(), "git must not follow the redirect to the target")
}
+7 -63
View File
@@ -8,7 +8,6 @@ import (
"path/filepath"
"slices"
"strings"
"sync"
)
// HostMatchList is used to check if a host or IP is in a list.
@@ -24,61 +23,10 @@ type HostMatchList struct {
ipNets []*net.IPNet
}
// MatchBuiltinExternal A valid global-unicast IP that is neither private (see MatchBuiltinPrivate)
// nor a reserved special-purpose range (see reservedIPNets); i.e. a routable host on the public internet.
// MatchBuiltinExternal A valid non-private unicast IP, all hosts on public internet are matched
const MatchBuiltinExternal = "external"
// reservedIPNets are special-purpose ranges that net.IP.IsPrivate omits but that must not be
// treated as public/external destinations (CGNAT, cloud metadata, IPv6 transition, etc.). We layer
// these on top of net.IP.IsPrivate (RFC 1918 / RFC 4193) so future additions to Go's IsPrivate are
// picked up automatically, while still covering the ranges it leaves out; otherwise the default
// allow-list would let authenticated users reach cloud metadata, internal, and IPv6 transition
// endpoints (SSRF), and a "private" block-list would fail to catch them.
var reservedIPNets = sync.OnceValue(func() []*net.IPNet {
var nets []*net.IPNet
for _, cidr := range []string{
// IPv4
"100.64.0.0/10", // RFC 6598 Carrier-Grade NAT
"168.63.129.16/32", // Azure WireServer metadata endpoint
"192.0.0.0/24", // RFC 6890 IETF protocol assignments
"192.0.2.0/24", // RFC 5737 TEST-NET-1
"192.88.99.0/24", // RFC 7526 6to4 relay anycast (deprecated)
"198.18.0.0/15", // RFC 2544 benchmarking
"198.51.100.0/24", // RFC 5737 TEST-NET-2
"203.0.113.0/24", // RFC 5737 TEST-NET-3
// IPv6
"100::/64", // RFC 6666 discard-only
"64:ff9b::/96", // RFC 6052 NAT64 (can embed IPv4 such as 169.254.169.254)
"64:ff9b:1::/48", // RFC 8215 local-use NAT64
"2001::/32", // RFC 4380 Teredo tunneling (embeds IPv4)
"2001:10::/28", // RFC 4843 ORCHID (deprecated)
"2001:20::/28", // RFC 7343 ORCHIDv2
"2001:db8::/32", // RFC 3849 documentation
"2002::/16", // RFC 3056 6to4 (embeds IPv4)
} {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
panic("hostmatcher: invalid reserved CIDR " + cidr + ": " + err.Error())
}
nets = append(nets, ipNet)
}
return nets
})
// isReservedIP reports whether ip falls in reserved special-purpose
// range (see reservedIPNets) that must not be considered a public/external destination.
func isReservedIP(ip net.IP) bool {
for _, ipNet := range reservedIPNets() {
if ipNet.Contains(ip) {
return true
}
}
return false
}
// MatchBuiltinPrivate RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7),
// plus the reserved special-purpose ranges in reservedIPNets (CGNAT, NAT64, cloud metadata, etc.).
// Also called LAN/Intranet.
// MatchBuiltinPrivate RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7). Also called LAN/Intranet.
const MatchBuiltinPrivate = "private"
// MatchBuiltinLoopback 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.
@@ -145,22 +93,18 @@ func (hl *HostMatchList) checkPattern(host string) bool {
return false
}
// matchesIP determines if the given IP matches any of the configured rules
func (hl *HostMatchList) matchesIP(ip net.IP) bool {
func (hl *HostMatchList) checkIP(ip net.IP) bool {
if slices.Contains(hl.patterns, "*") {
return true
}
for _, builtin := range hl.builtins {
switch builtin {
case MatchBuiltinExternal:
// External address must be a global unicast, must not be in reserved range and must not be in private range
if ip.IsGlobalUnicast() && !isReservedIP(ip) && !ip.IsPrivate() {
if ip.IsGlobalUnicast() && !ip.IsPrivate() {
return true
}
case MatchBuiltinPrivate:
// Private address must be global unicast, must not be in range we explicitly exclude for security reasons
// and must be in private range
if ip.IsGlobalUnicast() && !isReservedIP(ip) && ip.IsPrivate() {
if ip.IsPrivate() {
return true
}
case MatchBuiltinLoopback:
@@ -191,7 +135,7 @@ func (hl *HostMatchList) MatchHostName(host string) bool {
return true
}
if ip := net.ParseIP(hostname); ip != nil {
return hl.matchesIP(ip)
return hl.checkIP(ip)
}
return false
}
@@ -202,7 +146,7 @@ func (hl *HostMatchList) MatchIPAddr(ip net.IP) bool {
return false
}
host := ip.String() // nil-safe, we will get "<nil>" if ip is nil
return hl.checkPattern(host) || hl.matchesIP(ip)
return hl.checkPattern(host) || hl.checkIP(ip)
}
// MatchHostOrIP checks if the host or IP matches an allow/deny(block) list
-57
View File
@@ -159,60 +159,3 @@ func TestHostOrIPMatchesList(t *testing.T) {
}
test(cases)
}
// TestReservedRanges ensures special-purpose ranges that net.IP.IsPrivate misses are kept out of the
// "external" allow-list (the default for webhook delivery and repository migrations) and folded into
// the "private" block-list, so they cannot be used for SSRF to metadata/internal endpoints.
func TestReservedRanges(t *testing.T) {
external := ParseHostMatchList("", "external")
private := ParseHostMatchList("", "private")
// legitimate public destinations: external, not private
for _, ip := range []string{"8.8.8.8", "1.1.1.1", "2001:4860:4860::8888", "1000::1"} {
addr := net.ParseIP(ip)
assert.Truef(t, external.MatchIPAddr(addr), "public ip %s should be external", ip)
assert.Falsef(t, private.MatchIPAddr(addr), "public ip %s should not be private", ip)
}
// RFC 1918 / RFC 4193 private ranges (now folded into privateIPNets instead of net.IP.IsPrivate):
// not external, blockable as private. Includes range edges to guard the CIDR boundaries.
for _, ip := range []string{
"10.0.0.0", "10.255.255.255", // 10.0.0.0/8
"172.16.0.0", "172.31.255.255", // 172.16.0.0/12
"192.168.0.0", "192.168.255.255", // 192.168.0.0/16
"fc00::", "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", // fc00::/7
} {
addr := net.ParseIP(ip)
assert.Falsef(t, external.MatchIPAddr(addr), "private ip %s must not be external", ip)
assert.Truef(t, private.MatchIPAddr(addr), "private ip %s should match private block-list", ip)
}
// 172.32.0.0 is just outside 172.16.0.0/12: a public destination, not private
if addr := net.ParseIP("172.32.0.0"); assert.NotNil(t, addr) {
assert.True(t, external.MatchIPAddr(addr), "172.32.0.0 should be external")
assert.False(t, private.MatchIPAddr(addr), "172.32.0.0 should not be private")
}
// reserved ranges that IsPrivate does not cover: not external, but blockable as private
for _, ip := range []string{
"100.64.0.1", // CGNAT
"100.127.255.254", // CGNAT
"168.63.129.16", // Azure WireServer
"192.0.2.1", // TEST-NET-1
"198.18.0.1", // benchmarking
"198.51.100.1", // TEST-NET-2
"203.0.113.1", // TEST-NET-3
"169.254.169.254", // Cloud metadata
"192.88.99.1", // 6to4 relay anycast
"64:ff9b::1", // NAT64
"64:ff9b::a9fe:a9fe", // NAT64 embedding 169.254.169.254
"2001::1", // Teredo
"2002::1", // 6to4
"2001:db8::1", // documentation
"fe80::1", // link local address
} {
addr := net.ParseIP(ip)
assert.Falsef(t, external.MatchIPAddr(addr), "reserved ip %s must not be external", ip)
assert.Falsef(t, private.MatchIPAddr(addr), "reserved ip %s should match private block-list", ip)
}
}
+2 -13
View File
@@ -146,26 +146,15 @@ func ParseControlFile(r io.Reader) (*Package, error) {
var depends strings.Builder
var control strings.Builder
// https://www.debian.org/doc/debian-policy/ch-controlfields.html#syntax-of-control-files
s := bufio.NewScanner(r)
s := bufio.NewScanner(io.TeeReader(r, &control))
for s.Scan() {
line := s.Text()
trimmed := strings.TrimSpace(line)
if trimmed == "" {
// A binary package control file holds exactly one stanza. Stop at the
// blank line that terminates it, otherwise a crafted control file could
// smuggle additional stanzas (with attacker-chosen Filename/Package
// fields) into the generated repository "Packages" index.
if control.Len() == 0 {
continue
}
break
continue
}
control.WriteString(line)
control.WriteByte('\n')
if line[0] == ' ' || line[0] == '\t' {
switch key {
case "Description":
-15
View File
@@ -184,19 +184,4 @@ func TestParseControlFile(t *testing.T) {
assert.NotNil(t, p)
}
})
t.Run("SingleStanzaOnly", func(t *testing.T) {
// A control file with a trailing stanza must not leak the extra fields into
// p.Control, otherwise buildPackagesIndices would emit a second package entry
// with an attacker-chosen Filename into the repository "Packages" index.
content := bytes.NewBufferString("Package: realpkg\nVersion: 1.0.0\nArchitecture: amd64\nMaintainer: a <a@b.c>\nDescription: real\n\nPackage: openssl\nVersion: 99.0\nArchitecture: amd64\nFilename: pool/main/o/openssl/evil.deb\nDescription: spoofed\n")
p, err := ParseControlFile(content)
assert.NoError(t, err)
assert.NotNil(t, p)
assert.Equal(t, "realpkg", p.Name)
assert.Equal(t, "1.0.0", p.Version)
assert.NotContains(t, p.Control, "openssl")
assert.NotContains(t, p.Control, "evil.deb")
})
}
+35 -73
View File
@@ -508,79 +508,41 @@ func reqOrgOwnership() func(ctx *context.APIContext) {
}
}
// reqOrgVisible requires the organization to be visible to the doer, or a site admin
func reqOrgVisible() func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
if ctx.Org.Organization == nil {
setting.PanicInDevOrTesting("reqOrgVisible: unprepared context")
ctx.APIErrorInternal(errors.New("reqOrgVisible: unprepared context"))
return
}
if !organization.HasOrgOrUserVisible(ctx, ctx.Org.Organization.AsUser(), ctx.Doer) {
ctx.APIErrorNotFound()
return
}
}
}
func teamAccessPrivileged(ctx *context.APIContext) (orgID int64, privileged, ok bool) {
if ctx.IsUserSiteAdmin() {
return 0, true, true
}
if ctx.Org.Team == nil {
setting.PanicInDevOrTesting("teamAccess: unprepared context")
ctx.APIErrorInternal(errors.New("teamAccess: unprepared context"))
return 0, false, false
}
orgID = ctx.Org.Team.OrgID
isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
return 0, false, false
} else if isOwner {
return orgID, true, true
}
isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
return 0, false, false
}
return orgID, isTeamMember, true
}
func denyNonTeamMember(ctx *context.APIContext, orgID int64) {
isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
} else if isOrgMember {
ctx.APIError(http.StatusForbidden, "Must be a team member")
} else {
ctx.APIErrorNotFound()
}
}
// reqTeamReadAccess allows callers who can list the team to read its metadata.
// Not sufficient for mutations — use reqOrgOwnership() or reqTeamMembership() for those.
func reqTeamReadAccess() func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
orgID, privileged, ok := teamAccessPrivileged(ctx)
if !ok || privileged {
return
}
denyNonTeamMember(ctx, orgID)
}
}
// reqTeamMembership user should be a team member, or a site admin
// reqTeamMembership user should be an team member, or a site admin
func reqTeamMembership() func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
orgID, privileged, ok := teamAccessPrivileged(ctx)
if !ok || privileged {
if ctx.IsUserSiteAdmin() {
return
}
if ctx.Org.Team == nil {
setting.PanicInDevOrTesting("reqTeamMembership: unprepared context")
ctx.APIErrorInternal(errors.New("reqTeamMembership: unprepared context"))
return
}
orgID := ctx.Org.Team.OrgID
isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
} else if isOwner {
return
}
if isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID); err != nil {
ctx.APIErrorInternal(err)
return
} else if !isTeamMember {
isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
} else if isOrgMember {
ctx.APIError(http.StatusForbidden, "Must be a team member")
} else {
ctx.APIErrorNotFound()
}
return
}
denyNonTeamMember(ctx, orgID)
}
}
@@ -1800,7 +1762,7 @@ func Routes() *web.Router {
m.Combo("/{id}").Get(reqToken(), org.GetLabel).
Patch(reqToken(), reqOrgOwnership(), bind(api.EditLabelOption{}), org.EditLabel).
Delete(reqToken(), reqOrgOwnership(), org.DeleteLabel)
}, reqOrgVisible())
})
m.Group("/hooks", func() {
m.Combo("").Get(org.ListHooks).
Post(bind(api.CreateHookOption{}), org.CreateHook)
@@ -1859,12 +1821,12 @@ func Routes() *web.Router {
m.Group("/repos", func() {
m.Get("", reqToken(), org.GetTeamRepos)
m.Combo("/{org}/{reponame}").
Put(reqToken(), reqTeamMembership(), org.AddTeamRepository).
Delete(reqToken(), reqTeamMembership(), org.RemoveTeamRepository).
Put(reqToken(), org.AddTeamRepository).
Delete(reqToken(), org.RemoveTeamRepository).
Get(reqToken(), org.GetTeamRepo)
})
m.Get("/activities/feeds", org.ListTeamActivityFeeds)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamReadAccess(), checkTokenPublicOnly())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamMembership(), checkTokenPublicOnly())
m.Group("/admin", func() {
m.Group("/cron", func() {
+1 -19
View File
@@ -6,7 +6,6 @@ package org
import (
"net/http"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
org_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization"
api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
@@ -228,11 +227,7 @@ func ApplyIssueStatusPreset(ctx *context.APIContext) {
presetName := ctx.PathParam("preset")
if err := issues_model.ApplyStatusPreset(ctx, ctx.Org.Organization.ID, presetName); err != nil {
if db.IsErrNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
ctx.APIErrorNotFound()
return
}
ctx.Status(http.StatusNoContent)
@@ -266,19 +261,6 @@ func CopyIssueStatusesFromOrg(ctx *context.APIContext) {
ctx.APIErrorNotFound()
return
}
if sourceOrg.Visibility != api.VisibleTypePublic && !ctx.Doer.IsAdmin {
isMember, err := org_model.IsOrganizationMember(ctx, sourceOrg.ID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
if !isMember {
ctx.APIErrorNotFound()
return
}
}
if err := issues_model.CopyStatusesFromOrg(ctx, sourceOrg.ID, ctx.Org.Organization.ID); err != nil {
ctx.APIErrorInternal(err)
return
-3
View File
@@ -1365,9 +1365,6 @@ func MergeUpstream(ctx *context.APIContext) {
} else if errors.Is(err, util.ErrNotExist) {
ctx.APIError(http.StatusNotFound, err)
return
} else if errors.Is(err, util.ErrPermissionDenied) {
ctx.APIError(http.StatusForbidden, err.Error())
return
}
ctx.APIErrorInternal(err)
return
-6
View File
@@ -34,7 +34,6 @@ type apiSecurityConfig struct {
BlockOnPush bool `json:"block_on_push"`
SecretScanner bool `json:"secret_scanner"`
DependScanner bool `json:"depend_scanner"`
CodeScanner bool `json:"code_scanner"`
}
// ListSecurityAlerts returns all security alerts for a repo.
@@ -154,7 +153,6 @@ func UpdateSecurityConfig(ctx *context.APIContext) {
BlockOnPush *bool `json:"block_on_push"`
SecretScanner *bool `json:"secret_scanner"`
DependScanner *bool `json:"depend_scanner"`
CodeScanner *bool `json:"code_scanner"`
}
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
ctx.APIError(http.StatusUnprocessableEntity, err)
@@ -179,9 +177,6 @@ func UpdateSecurityConfig(ctx *context.APIContext) {
if req.DependScanner != nil {
cfg.DependScanner = *req.DependScanner
}
if req.CodeScanner != nil {
cfg.CodeScanner = *req.CodeScanner
}
if err := security_model.SaveScannerConfig(ctx, cfg); err != nil {
ctx.APIErrorInternal(err)
@@ -215,6 +210,5 @@ func toAPISecurityConfig(cfg *security_model.SecurityScannerConfig) *apiSecurity
BlockOnPush: cfg.BlockOnPush,
SecretScanner: cfg.SecretScanner,
DependScanner: cfg.DependScanner,
CodeScanner: cfg.CodeScanner,
}
}
+20 -30
View File
@@ -41,6 +41,9 @@ type preReceiveContext struct {
canCreatePullRequest bool
checkedCanCreatePullRequest bool
canWriteCode bool
checkedCanWriteCode bool
protectedTags []*git_model.ProtectedTag
gotProtectedTags bool
@@ -48,36 +51,24 @@ type preReceiveContext struct {
opts *private.HookOptions
// this context should only contain shared variables, mutable variables like "current branch name" shouldn't be put here
canWriteCodeUnitCached *bool
branchName string
}
func (ctx *preReceiveContext) canWriteCodeUnit() bool {
if ctx.canWriteCodeUnitCached == nil {
var canWrite bool
if ctx.loadPusherAndPermission() {
canWrite = ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
// CanWriteCode returns true if pusher can write code
func (ctx *preReceiveContext) CanWriteCode() bool {
if !ctx.checkedCanWriteCode {
if !ctx.loadPusherAndPermission() {
return false
}
ctx.canWriteCodeUnitCached = &canWrite
ctx.canWriteCode = issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, ctx.branchName, ctx.user) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
ctx.checkedCanWriteCode = true
}
return *ctx.canWriteCodeUnitCached
return ctx.canWriteCode
}
// canWriteCodeRef returns true if pusher can write to the code ref (branch/tag/commit)
func (ctx *preReceiveContext) canWriteCodeRef(refFullName git.RefName) bool {
if ctx.canWriteCodeUnit() {
return true
}
// then check whether if the pusher is a maintainer who can write the PR author's head repo branch
if !refFullName.IsBranch() {
return false
}
return issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, refFullName.BranchName(), ctx.user)
}
// assertCanWriteRef returns true if pusher can write to the code ref, otherwise it responds with 403 Forbidden and returns false
func (ctx *preReceiveContext) assertCanWriteRef(refFullName git.RefName) bool {
if !ctx.canWriteCodeRef(refFullName) {
// AssertCanWriteCode returns true if pusher can write code
func (ctx *preReceiveContext) AssertCanWriteCode() bool {
if !ctx.CanWriteCode() {
if ctx.Written() {
return false
}
@@ -139,7 +130,7 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
case git.DefaultFeatures().SupportProcReceive && refFullName.IsFor():
preReceiveFor(ourCtx, refFullName)
default:
ourCtx.assertCanWriteRef(refFullName)
ourCtx.AssertCanWriteCode()
}
if ctx.Written() {
return
@@ -151,8 +142,9 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, refFullName git.RefName) {
branchName := refFullName.BranchName()
ctx.branchName = branchName
if !ctx.assertCanWriteRef(refFullName) {
if !ctx.AssertCanWriteCode() {
return
}
@@ -162,9 +154,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
if newCommitID != objectFormat.EmptyObjectID().String() {
newCommit, err := gitRepo.GetCommit(newCommitID)
if err != nil {
log.Error("Secret scan: failed to get commit %s in %-v: %v", newCommitID[:12], repo, err)
} else {
if err == nil {
if findings := security_service.ScanPushForSecrets(ctx, repo.ID, newCommit); len(findings) > 0 {
msg := fmt.Sprintf("Push rejected: %d secret(s) detected in commit %s", len(findings), newCommitID[:12])
for _, f := range findings {
@@ -449,7 +439,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
}
func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) {
if !ctx.assertCanWriteRef(refFullName) {
if !ctx.AssertCanWriteCode() {
return
}
-70
View File
@@ -1,70 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package private
import (
"testing"
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm/access"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unittest"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/contexttest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestPreReceiveCanWriteCodePerBranch ensures the maintainer-edit write grant is evaluated against
// the exact ref being pushed on every call, derived from that ref rather than shared mutable state.
// Otherwise a per-branch grant (an open PR with "allow edits from maintainers") could be batched
// together with a protected branch or a tag to escalate into full repository write.
func TestPreReceiveCanWriteCodePerBranch(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
headRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 11})
require.NoError(t, baseRepo.LoadOwner(t.Context()))
require.NoError(t, headRepo.LoadOwner(t.Context()))
// An open PR from the head repo owner, with maintainer edits allowed: this grants the base
// repo owner write access to exactly this head branch and nothing else.
pr := &issues_model.PullRequest{
Issue: &issues_model.Issue{
RepoID: baseRepo.ID,
PosterID: headRepo.OwnerID,
},
HeadRepoID: headRepo.ID,
BaseRepoID: baseRepo.ID,
HeadBranch: "granted-branch",
BaseBranch: "master",
AllowMaintainerEdit: true,
}
require.NoError(t, issues_model.NewPullRequest(t.Context(), baseRepo, pr.Issue, nil, nil, pr))
// The pusher is the base repo owner (the maintainer) with only read access on the head repo.
maintainer := baseRepo.Owner
headPerm, err := access.GetIndividualUserRepoPermission(t.Context(), headRepo, maintainer)
require.NoError(t, err)
mockCtx, _ := contexttest.MockPrivateContext(t, "/")
ctx := &preReceiveContext{
PrivateContext: mockCtx,
loadedPusher: true,
user: maintainer,
userPerm: headPerm,
}
// The granted branch must be writable...
assert.True(t, ctx.canWriteCodeRef(git.RefNameFromBranch("granted-branch")))
// ...but another branch in the same push must NOT inherit that grant.
assert.False(t, ctx.canWriteCodeRef(git.RefNameFromBranch("master")))
// ...and a tag sharing the granted branch's name must NOT inherit it either: the grant is
// scoped to PR head branches, so a non-branch ref can never match it. (A tag ref already
// yields an empty branch name, so this guards the per-ref evaluation, not the IsBranch check.)
assert.False(t, ctx.canWriteCodeRef(git.RefNameFromTag("granted-branch")))
}
+9 -3
View File
@@ -58,14 +58,14 @@ func TwoFactorPost(ctx *context.Context) {
return
}
// Validate the passcode and atomically consume it to prevent reuse/replay.
ok, err := twofa.ValidateAndConsumeTOTP(ctx, form.Passcode)
// Validate the passcode with the stored TOTP secret.
ok, err := twofa.ValidateTOTP(form.Passcode)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
if ok {
if ok && twofa.LastUsedPasscode != form.Passcode {
remember := ctx.Session.Get("twofaRemember").(bool)
u, err := user_model.GetUserByID(ctx, id)
if err != nil {
@@ -81,6 +81,12 @@ func TwoFactorPost(ctx *context.Context) {
}
}
twofa.LastUsedPasscode = form.Passcode
if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
ctx.ServerError("UserSignIn", err)
return
}
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
handleSignIn(ctx, u, remember)
return
+9 -37
View File
@@ -368,21 +368,9 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
opts := &user_service.UpdateOptions{}
// HINT: OAUTH-AUTO-SYNC-USER-ACTIVATION: see services/auth/source/oauth2/source_sync.go
// Reactivate user only if they were disabled by the OAuth2 auto sync cron (invalid_grant),
// which clears AccessToken/RefreshToken/ExpiresAt on the ExternalLoginUser row
// An admin-disabled user has no such signature, so we leave IsActive alone
// and let verifyAuthWithOptions route them through the prohibit-login / activate page.
// Reactivate user if they are deactivated
if !u.IsActive {
extLogin, hasExt, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID)
if err != nil {
ctx.ServerError("GetExternalLogin", err)
return
}
isDisabledByAutoSync := hasExt && extLogin.RefreshToken == ""
if isDisabledByAutoSync {
opts.IsActive = optional.Some(true)
}
opts.IsActive = optional.Some(true)
}
// Update GroupClaims
@@ -526,33 +514,17 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ
}
// search in external linked users
externalLoginUser, hasUser, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID)
externalLoginUser := &user_model.ExternalLoginUser{
ExternalID: gothUser.UserID,
LoginSourceID: authSource.ID,
}
hasUser, err = user_model.GetExternalLogin(request.Context(), externalLoginUser)
if err != nil {
return nil, goth.User{}, err
}
if hasUser {
user, err = user_model.GetUserByID(ctx, externalLoginUser.UserID)
if err != nil && !user_model.IsErrUserNotExist(err) {
return nil, goth.User{}, err
}
if err == nil && user.IsIndividual() {
return user, gothUser, nil
}
// The external login record is stale: the linked user no longer exists, or it exists but is
// not an individual user (only individual users can sign in, so a link pointing at an
// organization, bot or remote user can never resolve). Remove it so the next sign-in can
// relink the external account to the correct user. Nothing is lost, because the link is
// recreated automatically on the next sign-in.
reason := "linked user does not exist"
if err == nil {
reason = fmt.Sprintf("linked user type is %d", user.Type)
}
log.Warn("Ignoring stale external login link [external-id=%s login-source-id=%d user-id=%d]: %s", externalLoginUser.ExternalID, externalLoginUser.LoginSourceID, externalLoginUser.UserID, reason)
if err := user_model.RemoveExternalLoginByExternalID(ctx, externalLoginUser.LoginSourceID, externalLoginUser.ExternalID); err != nil {
return nil, goth.User{}, err
}
user, err = user_model.GetUserByID(request.Context(), externalLoginUser.UserID)
return user, gothUser, err
}
// no user found to login
+9 -3
View File
@@ -177,17 +177,23 @@ func ResetPasswdPost(ctx *context.Context) {
regenerateScratchToken = true
} else {
passcode := ctx.FormString("passcode")
ok, err := twofa.ValidateAndConsumeTOTP(ctx, passcode)
ok, err := twofa.ValidateTOTP(passcode)
if err != nil {
ctx.HTTPError(http.StatusInternalServerError, "ValidateAndConsumeTOTP", err.Error())
ctx.HTTPError(http.StatusInternalServerError, "ValidateTOTP", err.Error())
return
}
if !ok {
if !ok || twofa.LastUsedPasscode == passcode {
ctx.Data["IsResetForm"] = true
ctx.Data["Err_Passcode"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_passcode_incorrect"), tplResetPassword, nil)
return
}
twofa.LastUsedPasscode = passcode
if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
ctx.ServerError("ResetPasswdPost: UpdateTwoFactor", err)
return
}
}
}
-3
View File
@@ -15,9 +15,6 @@ import (
// ShowBranchFeed shows tags and/or releases on the repo as RSS / Atom feed
func ShowBranchFeed(ctx *context.Context, repo *repo.Repository, formatType string) {
if !checkRepoFeedTokenScope(ctx) {
return
}
var commits []*git.Commit
var err error
if ctx.Repo.Commit != nil {
-3
View File
@@ -16,9 +16,6 @@ import (
// ShowFileFeed shows tags and/or releases on the repo as RSS / Atom feed
func ShowFileFeed(ctx *context.Context, repo *repo.Repository, formatType string) {
if !checkRepoFeedTokenScope(ctx) {
return
}
fileName := ctx.Repo.TreePath
if len(fileName) == 0 {
return
-3
View File
@@ -15,9 +15,6 @@ import (
// shows tags and/or releases on the repo as RSS / Atom feed
func ShowReleaseFeed(ctx *context.Context, repo *repo_model.Repository, isReleasesOnly bool, formatType string) {
if !checkRepoFeedTokenScope(ctx) {
return
}
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
IncludeTags: !isReleasesOnly,
RepoID: ctx.Repo.Repository.ID,
-9
View File
@@ -4,18 +4,9 @@
package feed
import (
auth_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/auth"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
)
// checkRepoFeedTokenScope ensures an API token has repository read scope before a
// feed serves private repository content, mirroring checkDownloadTokenScope for
// downloads. Returns false (and writes the response) when the token is denied.
func checkRepoFeedTokenScope(ctx *context.Context) bool {
context.CheckRepoScopedToken(ctx, ctx.Repo.Repository, auth_model.Read)
return !ctx.Written()
}
// RenderBranchFeed render format for branch or file
func RenderBranchFeed(ctx *context.Context, feedType string) {
if ctx.Repo.TreePath == "" {
-3
View File
@@ -16,9 +16,6 @@ import (
// ShowRepoFeed shows user activity on the repo as RSS / Atom feed
func ShowRepoFeed(ctx *context.Context, repo *repo_model.Repository, formatType string) {
if !checkRepoFeedTokenScope(ctx) {
return
}
actions, _, err := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{
RequestedRepo: repo,
Actor: ctx.Doer,
+1 -1
View File
@@ -98,7 +98,7 @@ func Teams(ctx *context.Context) {
}
}
ctx.Data["Teams"] = teams
ctx.Data["OrgListTeams"] = teams
ctx.Data["Keyword"] = keyword
pager := context.NewPagination(count, setting.UI.MembersPagingNum, page, 5)
pager.AddParamFromRequest(ctx.Req)
+1 -1
View File
@@ -261,7 +261,7 @@ func MergeUpstream(ctx *context.Context) {
branchName := ctx.FormString("branch")
_, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName, false)
if err != nil {
if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrPermissionDenied) {
if errors.Is(err, util.ErrNotExist) {
ctx.JSONErrorNotFound()
return
} else if pull_service.IsErrMergeConflicts(err) {
+28 -19
View File
@@ -58,6 +58,8 @@ func CorsHandler() func(next http.Handler) http.Handler {
// httpBase does the common work for git http services,
// including early response, authentication, repository lookup and permission check.
func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
reponame := strings.TrimSuffix(ctx.PathParam("reponame"), ".git")
if ctx.FormString("go-get") == "1" {
context.EarlyResponseForGoGetMeta(ctx)
return nil
@@ -91,11 +93,11 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
isWiki := false
unitType := unit.TypeCode
repoName := strings.TrimSuffix(ctx.PathParam("reponame"), ".git")
if strings.HasSuffix(repoName, ".wiki") {
if strings.HasSuffix(reponame, ".wiki") {
isWiki = true
unitType = unit.TypeWiki
repoName = repoName[:len(repoName)-5]
reponame = reponame[:len(reponame)-5]
}
owner := ctx.ContextUser
@@ -105,14 +107,14 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
}
repoExist := true
repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, repoName)
repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, reponame)
if err != nil {
if !repo_model.IsErrRepoNotExist(err) {
ctx.ServerError("GetRepositoryByName", err)
return nil
}
if redirectRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, repoName); err == nil {
if redirectRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, reponame); err == nil {
context.RedirectToRepo(ctx.Base, redirectRepoID)
return nil
}
@@ -125,24 +127,31 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
return nil
}
// Only public pulls don't need auth: repo must exist, not require-sign-in
canAnonymousPull := false
if isPull && repoExist && !setting.Service.RequireSignInViewStrict {
if owner.Visibility == structs.VisibleTypePublic && !repo.IsPrivate {
canAnonymousPull = true
// Only public pull don't need auth.
// For private repos, also allow anonymous pull if the specific unit
// (code or wiki) has AnonymousAccessMode >= Read.
isPublicPull := repoExist && isPull && !repo.IsPrivate
if repoExist && isPull && repo.IsPrivate {
repoUnit := repo.MustGetUnit(ctx, unitType)
if repoUnit.AnonymousAccessMode >= perm.AccessModeRead {
isPublicPull = true
}
if !canAnonymousPull && ctx.Doer == nil {
anonPerm, err := access_model.GetDoerRepoPermission(ctx, repo, nil)
if err != nil {
ctx.ServerError("GetDoerRepoPermission", err)
return nil
}
canAnonymousPull = anonPerm.CanAccess(accessMode, unitType)
}
askAuth := !isPublicPull || setting.Service.RequireSignInViewStrict
// don't allow anonymous pulls if organization is not public
if isPublicPull {
if err := repo.LoadOwner(ctx); err != nil {
ctx.ServerError("LoadOwner", err)
return nil
}
askAuth = askAuth || (repo.Owner.Visibility != structs.VisibleTypePublic)
}
// check access
if !canAnonymousPull { // not public pull, then either the pull needs auth, or the push needs "write" permission, so ask auth
if askAuth {
// rely on the results of Contexter
if !ctx.IsSigned {
// TODO: support digit auth - which would be Authorization header with digit
if setting.OAuth2.Enabled {
@@ -228,7 +237,7 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
return nil
}
repo, err = repo_service.PushCreateRepo(ctx, ctx.Doer, owner, repoName)
repo, err = repo_service.PushCreateRepo(ctx, ctx.Doer, owner, reponame)
if err != nil {
log.Error("pushCreateRepo: %v", err)
ctx.Status(http.StatusNotFound)
+12 -27
View File
@@ -448,24 +448,6 @@ func notifyPackage(ctx context.Context, sender *user_model.User, pd *packages_mo
}
func ifNeedApproval(ctx context.Context, run *actions_model.ActionRun, repo *repo_model.Repository, user *user_model.User) (bool, error) {
canWrite := func(ctx context.Context, repo *repo_model.Repository, user *user_model.User) (bool, error) {
perm, err := access_model.GetDoerRepoPermission(ctx, repo, user)
if err != nil {
return false, err
}
return perm.CanWrite(unit_model.TypeActions), nil
}
return ifNeedApprovalWith(ctx, run, repo, user, canWrite, issues_model.HasMergedPullRequestInRepo)
}
func ifNeedApprovalWith(
ctx context.Context,
run *actions_model.ActionRun,
repo *repo_model.Repository,
user *user_model.User,
canWriteActions func(context.Context, *repo_model.Repository, *user_model.User) (bool, error),
hasMergedPR func(context.Context, int64, int64) (bool, error),
) (bool, error) {
// 1. don't need approval if it's not a fork PR
// 2. don't need approval if the event is `pull_request_target` since the workflow will run in the context of base branch
// see https://docs.github.com/en/actions/managing-workflow-runs/approving-workflow-runs-from-public-forks#about-workflow-runs-from-public-forks
@@ -480,24 +462,27 @@ func ifNeedApprovalWith(
}
// don't need approval if the user can write
if ok, err := canWriteActions(ctx, repo, user); err != nil {
if perm, err := access_model.GetDoerRepoPermission(ctx, repo, user); err != nil {
return false, fmt.Errorf("GetDoerRepoPermission: %w", err)
} else if ok {
} else if perm.CanWrite(unit_model.TypeActions) {
log.Trace("do not need approval because user %d can write", user.ID)
return false, nil
}
// trust the user only after a merged PR — matching GitHub Actions. Approving one
// fork PR's run must not implicitly trust later fork PRs that replace the workflow.
if merged, err := hasMergedPR(ctx, repo.ID, user.ID); err != nil {
return false, fmt.Errorf("HasMergedPullRequestInRepo: %w", err)
} else if merged {
log.Trace("do not need approval because user %d has a merged pull request in repo %d", user.ID, repo.ID)
// don't need approval if the user has been approved before
if count, err := db.Count[actions_model.ActionRun](ctx, actions_model.FindRunOptions{
RepoID: repo.ID,
TriggerUserID: user.ID,
Approved: true,
}); err != nil {
return false, fmt.Errorf("CountRuns: %w", err)
} else if count > 0 {
log.Trace("do not need approval because user %d has been approved before", user.ID)
return false, nil
}
// otherwise, need approval
log.Trace("need approval because user %d has no merged pull request in repo %d", user.ID, repo.ID)
log.Trace("need approval because it's the first time user %d triggered actions", user.ID)
return true, nil
}
-102
View File
@@ -1,102 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"errors"
"testing"
actions_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/actions"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
actions_module "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/actions"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIfNeedApproval(t *testing.T) {
alwaysWrite := func(_ context.Context, _ *repo_model.Repository, _ *user_model.User) (bool, error) {
return true, nil
}
neverWrite := func(_ context.Context, _ *repo_model.Repository, _ *user_model.User) (bool, error) {
return false, nil
}
hasMerged := func(_ context.Context, _, _ int64) (bool, error) { return true, nil }
noMerged := func(_ context.Context, _, _ int64) (bool, error) { return false, nil }
errPerm := errors.New("perm error")
errMerge := errors.New("merge error")
forkRun := &actions_model.ActionRun{IsForkPullRequest: true, TriggerEvent: actions_module.GithubEventPullRequest}
nonForkRun := &actions_model.ActionRun{IsForkPullRequest: false, TriggerEvent: actions_module.GithubEventPullRequest}
prTargetRun := &actions_model.ActionRun{IsForkPullRequest: true, TriggerEvent: actions_module.GithubEventPullRequestTarget}
repo := &repo_model.Repository{ID: 1}
normalUser := &user_model.User{ID: 10}
restrictedUser := &user_model.User{ID: 11, IsRestricted: true}
t.Run("not a fork PR never needs approval", func(t *testing.T) {
need, err := ifNeedApprovalWith(t.Context(), nonForkRun, repo, normalUser, alwaysWrite, hasMerged)
require.NoError(t, err)
assert.False(t, need)
})
t.Run("pull_request_target never needs approval even when fork", func(t *testing.T) {
need, err := ifNeedApprovalWith(t.Context(), prTargetRun, repo, normalUser, alwaysWrite, hasMerged)
require.NoError(t, err)
assert.False(t, need)
})
t.Run("restricted user always needs approval", func(t *testing.T) {
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, restrictedUser, alwaysWrite, hasMerged)
require.NoError(t, err)
assert.True(t, need)
})
t.Run("fork PR with write permission does not need approval", func(t *testing.T) {
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, alwaysWrite, noMerged)
require.NoError(t, err)
assert.False(t, need)
})
t.Run("fork PR with merged PR but no write permission does not need approval", func(t *testing.T) {
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, neverWrite, hasMerged)
require.NoError(t, err)
assert.False(t, need)
})
t.Run("fork PR with no write and no merged PR needs approval", func(t *testing.T) {
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, neverWrite, noMerged)
require.NoError(t, err)
assert.True(t, need)
})
t.Run("canWriteActions error is propagated", func(t *testing.T) {
failWrite := func(_ context.Context, _ *repo_model.Repository, _ *user_model.User) (bool, error) {
return false, errPerm
}
_, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, failWrite, noMerged)
require.ErrorIs(t, err, errPerm)
})
t.Run("hasMergedPR error is propagated", func(t *testing.T) {
failMerge := func(_ context.Context, _, _ int64) (bool, error) { return false, errMerge }
_, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, neverWrite, failMerge)
require.ErrorIs(t, err, errMerge)
})
t.Run("restricted user skips permission check entirely", func(t *testing.T) {
// The perm and merge functions must not be called for a restricted user.
called := false
trackWrite := func(_ context.Context, _ *repo_model.Repository, _ *user_model.User) (bool, error) {
called = true
return true, nil
}
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, restrictedUser, trackWrite, noMerged)
require.NoError(t, err)
assert.True(t, need)
assert.False(t, called, "permission check must not run for restricted user")
})
}
+1 -2
View File
@@ -176,8 +176,7 @@ func validateTOTP(req *http.Request, u *user_model.User) error {
}
return err
}
// Consume the passcode atomically so a captured OTP cannot be replayed within its validity window.
if ok, err := twofa.ValidateAndConsumeTOTP(req.Context(), req.Header.Get("X-Gitea-OTP")); err != nil {
if ok, err := twofa.ValidateTOTP(req.Header.Get("X-Gitea-OTP")); err != nil {
return err
} else if !ok {
return util.NewInvalidArgumentErrorf("invalid provided OTP")
+2 -2
View File
@@ -88,8 +88,8 @@ func (source *Source) refresh(ctx context.Context, provider goth.Provider, u *us
}
}
// HINT: OAUTH-AUTO-SYNC-USER-ACTIVATION
// Delete stored tokens, since they are invalid. This also prevents us from checking this in subsequent runs.
// Delete stored tokens, since they are invalid. This
// also provents us from checking this in subsequent runs.
u.AccessToken = ""
u.RefreshToken = ""
u.ExpiresAt = time.Time{}
@@ -57,7 +57,12 @@ func TestSource(t *testing.T) {
err := source.refresh(t.Context(), provider, e)
assert.NoError(t, err)
e, ok, err := user_model.GetExternalLogin(t.Context(), e.LoginSourceID, e.ExternalID)
e := &user_model.ExternalLoginUser{
ExternalID: e.ExternalID,
LoginSourceID: e.LoginSourceID,
}
ok, err := user_model.GetExternalLogin(t.Context(), e)
assert.NoError(t, err)
assert.True(t, ok)
assert.Equal(t, "refresh", e.RefreshToken)
@@ -77,7 +82,12 @@ func TestSource(t *testing.T) {
})
assert.NoError(t, err)
e, ok, err := user_model.GetExternalLogin(t.Context(), e.LoginSourceID, e.ExternalID)
e := &user_model.ExternalLoginUser{
ExternalID: e.ExternalID,
LoginSourceID: e.LoginSourceID,
}
ok, err := user_model.GetExternalLogin(t.Context(), e)
assert.NoError(t, err)
assert.True(t, ok)
assert.Empty(t, e.RefreshToken)
+13 -16
View File
@@ -24,22 +24,19 @@ func ToNotificationThread(ctx context.Context, n *activities_model.Notification)
}
// since user only get notifications when he has access to use minimal access mode
if n.Repository == nil {
return result
}
perm, err := access_model.GetIndividualUserRepoPermission(ctx, n.Repository, n.User)
if err != nil {
log.Error("GetIndividualUserRepoPermission failed: %v", err)
return result
}
// if the user has been revoked access to the repo, do not leak repo or subject info
if !perm.HasAnyUnitAccessOrPublicAccess() {
return result
}
result.Repository = ToRepo(ctx, n.Repository, perm)
// This permission is not correct and we should not be reporting it
for repository := result.Repository; repository != nil; repository = repository.Parent {
repository.Permissions = nil
if n.Repository != nil {
perm, err := access_model.GetIndividualUserRepoPermission(ctx, n.Repository, n.User)
if err != nil {
log.Error("GetIndividualUserRepoPermission failed: %v", err)
return result
}
if perm.HasAnyUnitAccessOrPublicAccess() { // if user has been revoked access to repo, do not show repo info
result.Repository = ToRepo(ctx, n.Repository, perm)
// This permission is not correct and we should not be reporting it
for repository := result.Repository; repository != nil; repository = repository.Parent {
repository.Permissions = nil
}
}
}
// handle Subject
-30
View File
@@ -39,36 +39,6 @@ func TestToNotificationThreadOmitsRepoWhenAccessRevoked(t *testing.T) {
assert.Nil(t, thread.Repository)
}
func TestToNotificationThreadOmitsSubjectWhenAccessRevoked(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
// repo 2 is private; user 4 has no access to it
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
assert.NoError(t, repo.LoadOwner(ctx))
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 4, RepoID: repo.ID})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
n := &activities_model.Notification{
ID: 12345,
UserID: user.ID,
RepoID: repo.ID,
Status: activities_model.NotificationStatusUnread,
Source: activities_model.NotificationSourceIssue,
IssueID: issue.ID,
UpdatedUnix: timeutil.TimeStampNow(),
Issue: issue,
Repository: repo,
User: user,
}
thread := ToNotificationThread(ctx, n)
// must not leak private issue metadata once access is revoked
assert.Nil(t, thread.Repository)
assert.Nil(t, thread.Subject)
}
func TestToNotificationThread(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
-14
View File
@@ -7,7 +7,6 @@ import (
"context"
"fmt"
"slices"
"time"
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
org_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization"
@@ -27,10 +26,6 @@ type ReviewRequestNotifier struct {
var codeOwnerFiles = []string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"}
// codeOwnerMatchBudget caps the total wall-clock time spent evaluating all
// CODEOWNERS rules against all changed files for a single PR.
const codeOwnerMatchBudget = 2 * time.Second
func IsCodeOwnerFile(f string) bool {
return slices.Contains(codeOwnerFiles, f)
}
@@ -98,17 +93,8 @@ func PullRequestCodeOwnersReview(ctx context.Context, pr *issues_model.PullReque
uniqUsers := make(map[int64]*user_model.User)
uniqTeams := make(map[string]*org_model.Team)
// Bound the total time spent matching rules×files. The per-rule MatchTimeout
// only caps a single match; without an aggregate budget a crafted CODEOWNERS
// plus a PR touching many files could still exhaust CPU inside this loop.
matchDeadline := time.Now().Add(codeOwnerMatchBudget)
ruleLoop:
for _, rule := range rules {
for _, f := range changedFiles {
if time.Now().After(matchDeadline) {
log.Warn("CODEOWNERS matching for PR %s#%d exceeded its time budget; some rules were not evaluated", pr.BaseRepo.FullName(), pr.ID)
break ruleLoop
}
shouldMatch := !rule.Negative
matched, _ := rule.Rule.MatchString(f) // err only happens when timeouts, any error can be considered as not matched
if matched == shouldMatch {
-13
View File
@@ -8,9 +8,7 @@ import (
"fmt"
issue_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
access_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm/access"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unit"
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/gitrepo"
@@ -28,17 +26,6 @@ func MergeUpstream(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_
if err = repo.GetBaseRepo(ctx); err != nil {
return "", err
}
// The doer must still be able to read the base repository's code. Otherwise a fork created
// while the base repo was public could keep pulling commits after it turned private.
basePerm, err := access_model.GetDoerRepoPermission(ctx, repo.BaseRepo, doer)
if err != nil {
return "", err
}
if !basePerm.CanRead(unit.TypeCode) {
return "", util.NewPermissionDeniedErrorf("permission denied to read base repo %d", repo.BaseRepo.ID)
}
divergingInfo, err := GetUpstreamDivergingInfo(ctx, repo, branch)
if err != nil {
return "", err
-335
View File
@@ -1,335 +0,0 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package security
import (
"bufio"
"crypto/sha256"
"fmt"
"io"
"regexp"
"strings"
security_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/security"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
)
// CodeRule defines a pattern to detect insecure code patterns.
type CodeRule struct {
ID string
Title string
Pattern *regexp.Regexp
Severity security_model.AlertSeverity
Description string
CWE string
Languages []string // file extensions this rule applies to (empty = all)
}
// DefaultCodeRules contains the built-in code security patterns.
var DefaultCodeRules = []CodeRule{
// ── SQL Injection (CWE-89) ──────────────────────────────────────────
{
ID: "sqli-string-concat-go", Title: "SQL Injection: String Concatenation (Go)",
Severity: security_model.SeverityHigh, CWE: "CWE-89",
Pattern: regexp.MustCompile(`(?i)(?:db|tx|sess|engine|orm)\.\s*(?:Exec|Query|QueryRow|Raw|Where|And|Or|Select|SQL)\s*\(\s*(?:"[^"]*\s*\+|fmt\.Sprintf\s*\(\s*"[^"]*(?:%s|%v|%d))`),
Description: "SQL query built with string concatenation or fmt.Sprintf — use parameterized queries instead",
Languages: []string{".go"},
},
{
ID: "sqli-string-concat-php", Title: "SQL Injection: String Concatenation (PHP)",
Severity: security_model.SeverityHigh, CWE: "CWE-89",
Pattern: regexp.MustCompile(`(?i)(?:mysql_query|mysqli_query|pg_query|\$(?:db|pdo|conn|dbo|mysqli)->(?:query|exec|prepare|execute))\s*\(\s*(?:["'][^"']*\.\s*\$|["'][^"']*\{\$)`),
Description: "SQL query built with variable interpolation — use prepared statements instead",
Languages: []string{".php"},
},
{
ID: "sqli-string-concat-py", Title: "SQL Injection: String Concatenation (Python)",
Severity: security_model.SeverityHigh, CWE: "CWE-89",
Pattern: regexp.MustCompile(`(?i)(?:cursor|conn|db|engine)\.(?:execute|executemany|raw)\s*\(\s*(?:f["']|["'][^"']*%s|["'][^"']*\s*\+|["'][^"']*\.format\()`),
Description: "SQL query built with f-strings, %-formatting, or concatenation — use parameterized queries",
Languages: []string{".py"},
},
{
ID: "sqli-string-concat-js", Title: "SQL Injection: String Concatenation (JS)",
Severity: security_model.SeverityHigh, CWE: "CWE-89",
Pattern: regexp.MustCompile("(?i)(?:query|execute|raw|knex\\.raw|sequelize\\.query)\\s*\\(\\s*(?:`[^`]*\\$\\{|[\"'][^\"']*\\s*\\+)"),
Description: "SQL query built with template literals or concatenation — use parameterized queries",
Languages: []string{".js", ".ts", ".mjs", ".cjs"},
},
// ── Cross-Site Scripting / XSS (CWE-79) ─────────────────────────────
{
ID: "xss-innerhtml", Title: "XSS: innerHTML Assignment",
Severity: security_model.SeverityMedium, CWE: "CWE-79",
Pattern: regexp.MustCompile(`(?i)\.innerHTML\s*[+]?=\s*(?:[^"'` + "`" + `]|$)`),
Description: "Direct innerHTML assignment with non-literal value — use textContent or sanitize first",
Languages: []string{".js", ".ts", ".jsx", ".tsx", ".vue", ".svelte"},
},
{
ID: "xss-document-write", Title: "XSS: document.write()",
Severity: security_model.SeverityMedium, CWE: "CWE-79",
Pattern: regexp.MustCompile(`(?i)document\.write(?:ln)?\s*\(`),
Description: "document.write() can execute injected scripts — use DOM APIs instead",
Languages: []string{".js", ".ts", ".jsx", ".tsx"},
},
{
ID: "xss-echo-unescaped-php", Title: "XSS: Unescaped Output (PHP)",
Severity: security_model.SeverityMedium, CWE: "CWE-79",
Pattern: regexp.MustCompile(`(?:echo|print)\s+\$_(?:GET|POST|REQUEST|COOKIE|SERVER)\s*\[`),
Description: "Direct output of superglobal without escaping — use htmlspecialchars()",
Languages: []string{".php"},
},
{
ID: "xss-dangerously-set-html", Title: "XSS: dangerouslySetInnerHTML (React)",
Severity: security_model.SeverityMedium, CWE: "CWE-79",
Pattern: regexp.MustCompile(`dangerouslySetInnerHTML\s*=\s*\{\s*\{\s*__html\s*:`),
Description: "dangerouslySetInnerHTML bypasses React's XSS protection — ensure input is sanitized",
Languages: []string{".jsx", ".tsx", ".js", ".ts"},
},
// ── Command Injection (CWE-78) ──────────────────────────────────────
{
ID: "cmdi-exec-go", Title: "Command Injection: exec.Command with Variable (Go)",
Severity: security_model.SeverityHigh, CWE: "CWE-78",
Pattern: regexp.MustCompile(`exec\.Command\s*\(\s*(?:fmt\.Sprintf|[a-zA-Z_]+\s*\+|[a-zA-Z_]+\s*,)`),
Description: "exec.Command with dynamic arguments — validate and sanitize inputs",
Languages: []string{".go"},
},
{
ID: "cmdi-shell-exec-php", Title: "Command Injection: Shell Execution (PHP)",
Severity: security_model.SeverityCritical, CWE: "CWE-78",
Pattern: regexp.MustCompile(`(?i)(?:system|exec|passthru|shell_exec|popen|proc_open)\s*\(\s*\$`),
Description: "Shell command with variable input — use escapeshellarg() or avoid shell execution",
Languages: []string{".php"},
},
{
ID: "cmdi-child-process-js", Title: "Command Injection: child_process (Node.js)",
Severity: security_model.SeverityHigh, CWE: "CWE-78",
Pattern: regexp.MustCompile("(?i)(?:child_process|exec|execSync|spawn|spawnSync)\\s*\\(\\s*(?:`[^`]*\\$\\{|[\"'][^\"']*\\s*\\+)"),
Description: "Shell command built with dynamic input — use array arguments or sanitize",
Languages: []string{".js", ".ts", ".mjs", ".cjs"},
},
{
ID: "cmdi-subprocess-py", Title: "Command Injection: subprocess with shell=True (Python)",
Severity: security_model.SeverityHigh, CWE: "CWE-78",
Pattern: regexp.MustCompile(`(?i)subprocess\.(?:call|run|Popen|check_output|check_call)\s*\([^)]*shell\s*=\s*True`),
Description: "subprocess with shell=True is vulnerable to injection — use shell=False with list args",
Languages: []string{".py"},
},
{
ID: "cmdi-os-system-py", Title: "Command Injection: os.system() (Python)",
Severity: security_model.SeverityHigh, CWE: "CWE-78",
Pattern: regexp.MustCompile(`(?i)os\.(?:system|popen)\s*\(\s*(?:f["']|[a-zA-Z_])`),
Description: "os.system/popen with dynamic input — use subprocess with shell=False instead",
Languages: []string{".py"},
},
// ── Path Traversal (CWE-22) ─────────────────────────────────────────
{
ID: "path-traversal-join", Title: "Path Traversal: Unsanitized Path Join",
Severity: security_model.SeverityMedium, CWE: "CWE-22",
Pattern: regexp.MustCompile(`(?i)(?:filepath\.Join|path\.join|os\.path\.join)\s*\([^)]*(?:ctx\.|req\.|r\.|request\.|params\.|query\.)`),
Description: "User input in file path join without sanitization — validate against directory traversal",
Languages: []string{".go", ".js", ".ts", ".py"},
},
{
ID: "path-traversal-open", Title: "Path Traversal: File Open with User Input",
Severity: security_model.SeverityMedium, CWE: "CWE-22",
Pattern: regexp.MustCompile(`(?i)(?:os\.Open|ioutil\.ReadFile|os\.ReadFile)\s*\(\s*(?:fmt\.Sprintf|[a-zA-Z_]+\s*\+)`),
Description: "File operation with dynamic path — sanitize and restrict to safe directories",
Languages: []string{".go"},
},
{
ID: "path-traversal-php", Title: "Path Traversal: File Include/Open (PHP)",
Severity: security_model.SeverityHigh, CWE: "CWE-22",
Pattern: regexp.MustCompile(`(?i)(?:include|require|include_once|require_once|file_get_contents|fopen|readfile)\s*\(\s*\$`),
Description: "File operation with variable path — validate path against traversal",
Languages: []string{".php"},
},
// ── Insecure Deserialization (CWE-502) ──────────────────────────────
{
ID: "deserialize-php", Title: "Insecure Deserialization: unserialize() (PHP)",
Severity: security_model.SeverityHigh, CWE: "CWE-502",
Pattern: regexp.MustCompile(`(?i)unserialize\s*\(\s*\$`),
Description: "unserialize() with untrusted data can lead to remote code execution",
Languages: []string{".php"},
},
{
ID: "deserialize-yaml-py", Title: "Insecure Deserialization: yaml.load() (Python)",
Severity: security_model.SeverityHigh, CWE: "CWE-502",
Pattern: regexp.MustCompile(`yaml\.load\s*\([^)]*(?:Loader\s*=\s*yaml\.(?:Unsafe|Full)Loader|[^)]*\)(?!\s*#))`),
Description: "yaml.load() without SafeLoader allows arbitrary code execution — use yaml.safe_load()",
Languages: []string{".py"},
},
// ── Hardcoded Credentials in Code (CWE-798) ─────────────────────────
{
ID: "hardcoded-password-assignment", Title: "Hardcoded Password in Source",
Severity: security_model.SeverityHigh, CWE: "CWE-798",
Pattern: regexp.MustCompile(`(?i)(?:password|passwd|pwd)\s*(?::=|=)\s*["'][^"']{8,}["']`),
Description: "Hardcoded password in source code — use environment variables or config",
Languages: []string{".go", ".py", ".js", ".ts", ".php", ".rb", ".java"},
},
// ── Weak Cryptography (CWE-327) ─────────────────────────────────────
{
ID: "weak-crypto-md5", Title: "Weak Cryptography: MD5",
Severity: security_model.SeverityLow, CWE: "CWE-327",
Pattern: regexp.MustCompile(`(?i)(?:md5\.New|hashlib\.md5|MD5\.Create|MessageDigest\.getInstance\s*\(\s*["']MD5)`),
Description: "MD5 is cryptographically broken — use SHA-256 or stronger for security purposes",
Languages: []string{".go", ".py", ".cs", ".java"},
},
{
ID: "weak-crypto-sha1", Title: "Weak Cryptography: SHA-1 for Security",
Severity: security_model.SeverityLow, CWE: "CWE-327",
Pattern: regexp.MustCompile(`(?i)(?:sha1\.New|hashlib\.sha1|SHA1\.Create|MessageDigest\.getInstance\s*\(\s*["']SHA-?1)`),
Description: "SHA-1 is deprecated for security — use SHA-256 or stronger",
Languages: []string{".go", ".py", ".cs", ".java"},
},
}
// Language extensions for file filtering.
var codeFileExtensions = map[string]bool{
".go": true, ".py": true, ".js": true, ".ts": true, ".jsx": true, ".tsx": true,
".php": true, ".rb": true, ".java": true, ".cs": true, ".rs": true,
".vue": true, ".svelte": true, ".mjs": true, ".cjs": true,
}
// CodeScanner implements the Scanner interface for code pattern analysis.
type CodeScanner struct {
Rules []CodeRule
}
// NewCodeScanner creates a scanner with default code analysis rules.
func NewCodeScanner() *CodeScanner {
return &CodeScanner{Rules: DefaultCodeRules}
}
func (s *CodeScanner) Type() security_model.ScannerType {
return security_model.ScannerCode
}
func (s *CodeScanner) ScanCommit(commit *git.Commit) ([]Finding, error) {
return s.ScanTree(commit)
}
func (s *CodeScanner) ScanTree(commit *git.Commit) ([]Finding, error) {
if commit == nil {
return nil, nil
}
entries, err := commit.ListEntriesRecursiveFast()
if err != nil {
return nil, fmt.Errorf("ListEntriesRecursiveFast: %w", err)
}
var findings []Finding
for _, entry := range entries {
if !entry.IsRegular() {
continue
}
path := entry.Name()
if shouldSkipFile(path) {
continue
}
ext := fileExtension(path)
if !codeFileExtensions[ext] {
continue
}
if entry.Blob().Size() > 1024*1024 {
continue
}
applicableRules := s.rulesForExtension(ext)
if len(applicableRules) == 0 {
continue
}
reader, err := entry.Blob().DataAsync()
if err != nil {
log.Trace("CodeScanner: skip %s: %v", path, err)
continue
}
fileFindings := s.scanReader(reader, path, commit.ID.String(), applicableRules)
reader.Close()
findings = append(findings, fileFindings...)
}
return findings, nil
}
func (s *CodeScanner) rulesForExtension(ext string) []CodeRule {
var rules []CodeRule
for _, rule := range s.Rules {
if len(rule.Languages) == 0 {
rules = append(rules, rule)
continue
}
for _, lang := range rule.Languages {
if lang == ext {
rules = append(rules, rule)
break
}
}
}
return rules
}
func (s *CodeScanner) scanReader(r io.Reader, filePath, commitSHA string, rules []CodeRule) []Finding {
var findings []Finding
scanner := bufio.NewScanner(r)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
trimmed := strings.TrimSpace(line)
if isCommentLine(trimmed) {
continue
}
for _, rule := range rules {
if rule.Pattern.MatchString(line) {
fingerprint := fmt.Sprintf("%x", sha256.Sum256([]byte(rule.ID+":"+filePath+":"+line)))
findings = append(findings, Finding{
Scanner: security_model.ScannerCode,
Severity: rule.Severity,
RuleID: rule.ID,
Title: rule.Title,
Description: rule.Description + " [" + rule.CWE + "]",
FilePath: filePath,
LineNumber: lineNum,
CommitSHA: commitSHA,
Fingerprint: fingerprint[:32],
Metadata: fmt.Sprintf(`{"cwe":"%s"}`, rule.CWE),
})
break
}
}
}
return findings
}
func fileExtension(path string) string {
lower := strings.ToLower(path)
if i := strings.LastIndex(lower, "."); i >= 0 {
return lower[i:]
}
return ""
}
func isCommentLine(line string) bool {
return strings.HasPrefix(line, "//") ||
strings.HasPrefix(line, "#") ||
strings.HasPrefix(line, "*") ||
strings.HasPrefix(line, "/*") ||
strings.HasPrefix(line, "<!--")
}
+2 -3
View File
@@ -56,9 +56,8 @@ func ScanOnPush(ctx context.Context, repo *repo_model.Repository, commit *git.Co
if cfg.DependScanner {
scanners = append(scanners, NewDependencyScanner())
}
if cfg.CodeScanner {
scanners = append(scanners, NewCodeScanner())
}
// Future scanners added here:
// if cfg.CodeScanner { scanners = append(scanners, NewCodeScanner()) }
if len(scanners) == 0 {
return
+1 -1
View File
@@ -2,7 +2,7 @@
<div class="divider"></div>
<div class="tw-flex tw-items-center tw-justify-between tw-gap-2">
<span class="text grey tw-text-sm">{{ctx.Locale.Tr "repo.issues.type"}}</span>
{{$canModify := .HasIssuesOrPullsWritePermission}}
{{$canModify := and .FieldEditFlags .FieldEditFlags.CustomFields}}
{{if $canModify}}
<form method="post" action="{{.RepoLink}}/issues/{{.Issue.ID}}/custom-type" class="tw-inline">
{{$.CsrfTokenHtml}}
-104
View File
@@ -139,107 +139,3 @@ jobs:
assert.Equal(t, actions_model.StatusWaiting, run2.Status)
})
}
// TestForkPullRequestApprovalNotBypassedByPriorApproval verifies that a single
// approval on a fork PR does not permanently trust the contributor: a subsequent
// fork PR from the same user must still be gated (Blocked / NeedApproval=true)
// until that user has had a pull request merged in the repo.
func TestForkPullRequestApprovalNotBypassedByPriorApproval(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
user2Session := loginUser(t, user2.Name)
user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
user4Session := loginUser(t, user4.Name)
user4Token := getTokenForLoggedInUser(t, user4Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiBaseRepo := createActionsTestRepo(t, user2Token, "fork-approval-regression", false)
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiBaseRepo.ID})
user2APICtx := NewAPITestContext(t, baseRepo.OwnerName, baseRepo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(user2APICtx)(t)
wfTreePath := ".gitea/workflows/ci.yml"
wfContent := `name: CI
on: pull_request
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo ok
`
createWorkflowFile(t, user2Token, baseRepo.OwnerName, baseRepo.Name, wfTreePath,
getWorkflowCreateFileOptions(user2, baseRepo.DefaultBranch, "add ci", wfContent))
// user4 forks the repo
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseRepo.OwnerName, baseRepo.Name),
&api.CreateForkOption{Name: new("fork-approval-regression-fork")}).AddTokenAuth(user4Token)
resp := MakeRequest(t, req, http.StatusAccepted)
apiForkRepo := DecodeJSON(t, resp, &api.Repository{})
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID})
user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(user4APICtx)(t)
// PR #1: a benign change from user4's fork — first-time contributor, gate engages.
doAPICreateFile(user4APICtx, "first.txt", &api.CreateFileOptions{
FileOptions: api.FileOptions{
NewBranchName: "first",
Message: "first",
Author: api.Identity{Name: user4.Name, Email: user4.Email},
Committer: api.Identity{Name: user4.Name, Email: user4.Email},
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("first")),
})(t)
pr1, err := doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":first")(t)
assert.NoError(t, err)
run1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID, Ref: fmt.Sprintf("refs/pull/%d/head", pr1.Index)})
assert.True(t, run1.NeedApproval, "first fork PR must require approval")
assert.Equal(t, actions_model.StatusBlocked, run1.Status)
// user2 approves run1.
req = NewRequest(t, "POST", fmt.Sprintf("%s/actions/approve-all-checks?commit_id=%s", baseRepo.Link(), pr1.Head.Sha))
user2Session.MakeRequest(t, req, http.StatusOK)
run1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run1.ID})
assert.False(t, run1.NeedApproval)
assert.Equal(t, user2.ID, run1.ApprovedBy)
// PR #2: same user, fresh branch. Pre-fix, this run was created with
// NeedApproval=false and dispatched immediately — the bypass path.
doAPICreateFile(user4APICtx, "second.txt", &api.CreateFileOptions{
FileOptions: api.FileOptions{
NewBranchName: "second",
Message: "second",
Author: api.Identity{Name: user4.Name, Email: user4.Email},
Committer: api.Identity{Name: user4.Name, Email: user4.Email},
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("second")),
})(t)
pr2, err := doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":second")(t)
assert.NoError(t, err)
run2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID, Ref: fmt.Sprintf("refs/pull/%d/head", pr2.Index)})
assert.True(t, run2.NeedApproval, "second fork PR must still require approval — prior approval-to-run does not grant trust")
assert.Equal(t, actions_model.StatusBlocked, run2.Status)
assert.EqualValues(t, 0, run2.ApprovedBy)
// After merging PR #1, user4 becomes a known contributor and the gate lifts for a new PR.
doAPIMergePullRequest(user2APICtx, baseRepo.OwnerName, baseRepo.Name, pr1.Index)(t)
doAPICreateFile(user4APICtx, "third.txt", &api.CreateFileOptions{
FileOptions: api.FileOptions{
NewBranchName: "third",
Message: "third",
Author: api.Identity{Name: user4.Name, Email: user4.Email},
Committer: api.Identity{Name: user4.Name, Email: user4.Email},
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("third")),
})(t)
pr3, err := doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":third")(t)
assert.NoError(t, err)
run3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID, Ref: fmt.Sprintf("refs/pull/%d/head", pr3.Index)})
assert.False(t, run3.NeedApproval, "fork PR from a user with a prior merged PR should not require approval")
})
}
@@ -486,18 +486,14 @@ jobs:
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("user4-fix2")),
})(t)
pr3, _ := doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":do-not-cancel/ccc")(t)
// cannot fetch the task: approval still required (user4 has no merged PR) and cancel-in-progress is false
doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":do-not-cancel/ccc")(t)
// cannot fetch the task because cancel-in-progress is false
runner.fetchNoTask(t)
runner.execTask(t, pr2Task1, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
pr2Run1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: pr2Run1.ID})
assert.Equal(t, actions_model.StatusSuccess, pr2Run1.Status)
// user2 approves the third PR's run (user4 still has no merged PR, approval still required)
pr3Run1Pending := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID, Ref: fmt.Sprintf("refs/pull/%d/head", pr3.Index)})
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/approve", baseRepo.OwnerName, baseRepo.Name, pr3Run1Pending.ID))
user2Session.MakeRequest(t, req, http.StatusOK)
// fetch the task
pr3Task1 := runner.fetchTask(t)
_, _, pr3Run1 := getTaskAndJobAndRunByTaskID(t, pr3Task1.Id)
+1 -43
View File
@@ -11,7 +11,6 @@ import (
"time"
auth_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/auth"
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
org_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
@@ -290,47 +289,6 @@ func testAPIDeleteOrgRepos(t *testing.T) {
}, 2*time.Second, 50*time.Millisecond)
req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/%s/repos", org3.Name)).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
})
}
func TestAPIOrgLabelsVisibility(t *testing.T) {
defer tests.PrepareTestEnv(t)()
privateOrg := unittest.AssertExistsAndLoadBean(t, &org_model.Organization{ID: 23})
label := &issues_model.Label{OrgID: privateOrg.ID, Name: "internal-label", Color: "#aabbcc", Description: "private organization label"}
require.NoError(t, issues_model.NewLabel(t.Context(), label))
listURL := fmt.Sprintf("/api/v1/orgs/%s/labels", privateOrg.Name)
getURL := fmt.Sprintf("/api/v1/orgs/%s/labels/%d", privateOrg.Name, label.ID)
t.Run("NonMemberDenied", func(t *testing.T) {
token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization)
MakeRequest(t, NewRequest(t, "GET", listURL).AddTokenAuth(token), http.StatusNotFound)
MakeRequest(t, NewRequest(t, "GET", getURL).AddTokenAuth(token), http.StatusNotFound)
})
t.Run("AnonymousDenied", func(t *testing.T) {
MakeRequest(t, NewRequest(t, "GET", listURL), http.StatusNotFound)
MakeRequest(t, NewRequest(t, "GET", getURL), http.StatusNotFound)
})
t.Run("MemberAllowed", func(t *testing.T) {
token := getUserToken(t, "user5", auth_model.AccessTokenScopeReadOrganization)
resp := MakeRequest(t, NewRequest(t, "GET", listURL).AddTokenAuth(token), http.StatusOK)
labels := DecodeJSON(t, resp, &[]*api.Label{})
assert.Len(t, *labels, 1)
MakeRequest(t, NewRequest(t, "GET", getURL).AddTokenAuth(token), http.StatusOK)
})
t.Run("SiteAdminAllowed", func(t *testing.T) {
token := getUserToken(t, "user1", auth_model.AccessTokenScopeReadOrganization)
MakeRequest(t, NewRequest(t, "GET", listURL).AddTokenAuth(token), http.StatusOK)
MakeRequest(t, NewRequest(t, "GET", getURL).AddTokenAuth(token), http.StatusOK)
})
t.Run("PublicOrgStillReadable", func(t *testing.T) {
token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization)
MakeRequest(t, NewRequest(t, "GET", "/api/v1/orgs/org3/labels").AddTokenAuth(token), http.StatusOK)
MakeRequest(t, req, http.StatusNoContent) // The org contains no repositories, so the API should return StatusNoContent
})
}
-6
View File
@@ -51,12 +51,6 @@ func TestAPITwoFactor(t *testing.T) {
AddBasicAuth(user.Name)
req.Header.Set("X-Gitea-OTP", passcode)
MakeRequest(t, req, http.StatusOK)
// the same passcode must not be replayable on the basic-auth surface (RFC 6238 single-use)
req = NewRequest(t, "GET", "/api/v1/user").
AddBasicAuth(user.Name)
req.Header.Set("X-Gitea-OTP", passcode)
MakeRequest(t, req, http.StatusUnauthorized)
}
func TestBasicAuthWithWebAuthn(t *testing.T) {
+3 -180
View File
@@ -13,7 +13,6 @@ import (
"time"
auth_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/auth"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unittest"
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
@@ -24,7 +23,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"xorm.io/builder"
)
// TestMigrateAzureADV2ToOIDC simulates a login source migration from the Azure AD V2 OAuth2 provider to the OpenID Connect provider,
@@ -132,72 +130,8 @@ func TestMigrateAzureADV2ToOIDC(t *testing.T) {
})
}
// newFakeOIDCServer starts an httptest.Server that implements the minimum OIDC endpoints needed to complete a sign-in flow:
func newFakeOIDCServer(t *testing.T, sub, oid string) *httptest.Server {
return newFakeOIDCServerWithProfile(t, sub, oid, sub+"@example.com", "OIDC Test User")
}
func TestOIDCIgnoresStaleExternalLoginLinks(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
defer test.MockVariableValue(&setting.OAuth2Client.AccountLinking, setting.OAuth2AccountLinkingAuto)()
defer test.MockVariableValue(&setting.OAuth2Client.Username, setting.OAuth2UsernameEmail)()
setup := func(t *testing.T, sourceName, sub, userName, email string) (*auth_model.Source, *user_model.User) {
t.Helper()
srv := newFakeOIDCServerWithProfile(t, sub, sub+"-oid", email, "OIDC Test User")
addOAuth2Source(t, sourceName, oauth2.Source{
Provider: "openidConnect",
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
OpenIDConnectAutoDiscoveryURL: srv.URL + "/.well-known/openid-configuration",
})
authSource, err := auth_model.GetActiveOAuth2SourceByAuthName(t.Context(), sourceName)
require.NoError(t, err)
correctUser := &user_model.User{Name: userName, Email: email}
require.NoError(t, user_model.CreateUser(t.Context(), correctUser, &user_model.Meta{}))
return authSource, correctUser
}
// assertRelinked signs in via OIDC and asserts the stale link now points at the correct individual user.
assertRelinked := func(t *testing.T, authSource *auth_model.Source, sub string, correctUser *user_model.User) {
t.Helper()
doOIDCSignIn(t, authSource.Name)
// external_login_user has no "id" column, so order by the primary key instead
externalLink := unittest.AssertExistsAndLoadBean(t, &user_model.ExternalLoginUser{ExternalID: sub, LoginSourceID: authSource.ID}, unittest.OrderBy("external_id ASC"))
assert.Equal(t, correctUser.ID, externalLink.UserID)
assert.Equal(t, correctUser.Email, externalLink.Email)
assert.Equal(t, "OIDC Test User", externalLink.Name)
}
t.Run("organization", func(t *testing.T) {
const sub, userName, email = "oidc-stale-org-link-sub", "guizar_m", "guizar_m@example.com"
authSource, correctUser := setup(t, "test-oidc-stale-org-link", sub, userName, email)
org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3, Type: user_model.UserTypeOrganization})
require.NoError(t, user_model.LinkExternalToUser(t.Context(), org, &user_model.ExternalLoginUser{
ExternalID: sub,
UserID: org.ID,
LoginSourceID: authSource.ID,
Provider: authSource.Name,
}))
assertRelinked(t, authSource, sub, correctUser)
})
t.Run("deleted user", func(t *testing.T) {
const sub, userName, email = "oidc-stale-deleted-link-sub", "guizar_d", "guizar_d@example.com"
const deletedUserID = 999999
authSource, correctUser := setup(t, "test-oidc-stale-deleted", sub, userName, email)
// link the external account to a user id that does not exist, simulating a deleted user
require.NoError(t, user_model.LinkExternalToUser(t.Context(), &user_model.User{ID: deletedUserID}, &user_model.ExternalLoginUser{
ExternalID: sub,
UserID: deletedUserID,
LoginSourceID: authSource.ID,
Provider: authSource.Name,
}))
assertRelinked(t, authSource, sub, correctUser)
})
}
func newFakeOIDCServerWithProfile(t *testing.T, sub, oid, email, name string) *httptest.Server {
t.Helper()
var srv *httptest.Server
@@ -235,119 +169,8 @@ func newFakeOIDCServerWithProfile(t *testing.T, sub, oid, email, name string) *h
// sub MUST match the id_token sub; goth rejects mismatches.
_ = json.NewEncoder(w).Encode(map[string]any{
"sub": sub,
"email": email,
"name": name,
})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
return srv
}
// TestOAuth2CallbackReactivationGating exercises the gate in handleOAuth2SignIn:
// an inactive user can only be reactivated when who was disabled by auto-sync
func TestOAuth2CallbackReactivationGating(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
defer test.MockVariableValue(&setting.OAuth2Client.Username, setting.OAuth2UsernameUserid)()
srv := newFakeOIDCServer(t, FakeOIDCConfig{Sub: "test-sub", Email: "test@example.com", Name: "Test User"})
addOAuth2Source(t, "test-oauth-source", oauth2.Source{
Provider: "openidConnect",
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
OpenIDConnectAutoDiscoveryURL: srv.URL + "/.well-known/openid-configuration",
})
authSource, err := auth_model.GetActiveOAuth2SourceByAuthName(t.Context(), "test-oauth-source")
require.NoError(t, err)
u := &user_model.User{Name: "test-user", Email: "test@example.com"}
require.NoError(t, user_model.CreateUser(t.Context(), u, &user_model.Meta{}))
extLink := &user_model.ExternalLoginUser{
UserID: u.ID,
LoginSourceID: authSource.ID,
Provider: authSource.Name,
ExternalID: "test-sub",
}
require.NoError(t, user_model.LinkExternalToUser(t.Context(), u, extLink))
prepareUserExternalLink := func(t *testing.T, refreshToken string) {
err := user_model.UpdateUserCols(t.Context(), &user_model.User{ID: u.ID, IsActive: false}, "is_active")
require.NoError(t, err)
_, err = db.GetEngine(t.Context()).Where(builder.Eq{"user_id": u.ID}).Cols("refresh_token").
Update(&user_model.ExternalLoginUser{RefreshToken: refreshToken})
require.NoError(t, err)
require.False(t, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: u.ID}).IsActive)
}
t.Run("admin-disabled user is not reactivated", func(t *testing.T) {
prepareUserExternalLink(t, "non-empty-refresh-token")
doOIDCSignIn(t, authSource.Name)
after := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: u.ID})
assert.False(t, after.IsActive, "OAuth callback must not re-enable an administrator-disabled account")
})
t.Run("auto-sync-disabled user is reactivated", func(t *testing.T) {
prepareUserExternalLink(t, "" /* empty refresh token */)
doOIDCSignIn(t, authSource.Name)
after := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: u.ID})
assert.True(t, after.IsActive, "OAuth callback must reactivate a sync-disabled account on successful login")
})
}
// FakeOIDCConfig holds configuration for the fake OIDC server used in tests.
type FakeOIDCConfig struct {
Sub string
OID string
Email string
Name string
Groups []string
}
// newFakeOIDCServer starts a httptest.Server that implements the minimum OIDC endpoints needed to complete a sign-in flow
func newFakeOIDCServer(t *testing.T, cfg FakeOIDCConfig) *httptest.Server {
t.Helper()
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/.well-known/openid-configuration": // discovery document
_ = json.NewEncoder(w).Encode(map[string]string{
"issuer": srv.URL,
"authorization_endpoint": srv.URL + "/authorize",
"token_endpoint": srv.URL + "/token",
"userinfo_endpoint": srv.URL + "/userinfo",
})
case "/token": // returns an ID token with both "sub" and "oid" claims so tests can verify which one ends up as ExternalID
claims := map[string]any{
"iss": srv.URL,
"aud": "test-client-id",
"exp": time.Now().Add(time.Hour).Unix(),
"sub": cfg.Sub,
"oid": cfg.OID,
}
payload, _ := json.Marshal(claims)
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`))
// build a JWT-shaped string whose payload encodes claims.
// goth's decodeJWT only base64-decodes the payload without verifying the signature, so no real signing infrastructure is needed.
idToken := header + "." + base64.RawURLEncoding.EncodeToString(payload) + ".fakesig"
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "fake-access-token",
"token_type": "Bearer",
"id_token": idToken,
})
case "/userinfo":
// sub MUST match the id_token sub; goth rejects mismatches.
_ = json.NewEncoder(w).Encode(map[string]any{
"sub": cfg.Sub,
"email": cfg.Email,
"name": cfg.Name,
"email": sub + "@example.com",
"name": "OIDC Test User",
})
default:
http.NotFound(w, r)
-39
View File
@@ -8,7 +8,6 @@ import (
"net/http"
"testing"
auth_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/auth"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/tests"
"github.com/stretchr/testify/assert"
@@ -34,41 +33,3 @@ func TestFeedRepo(t *testing.T) {
assert.NotEmpty(t, rss.Channel.Items[0].PubDate)
})
}
// TestFeedRepoContentTokenScopes ensures repository feed endpoints enforce the
// repository token scope, so a PAT without repository scope cannot read private
// repository commit/activity data through RSS/Atom feeds.
func TestFeedRepoContentTokenScopes(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// user2/repo2 is a private repository owned by user2
ownerReadToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository)
miscToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadMisc)
urls := []string{
"/user2/repo2.rss",
"/user2/repo2.atom",
"/user2/repo2/rss/branch/master",
"/user2/repo2/atom/branch/master",
"/user2/repo2/rss/branch/master/README.md",
"/user2/repo2/tags.rss",
"/user2/repo2/tags.atom",
"/user2/repo2/releases.rss",
"/user2/repo2/releases.atom",
}
for _, url := range urls {
t.Run(url, func(t *testing.T) {
// feed routes only accept basic auth, so authenticate as the advisory PoC does (user:token)
reqDenied := NewRequest(t, "GET", url)
reqDenied.SetBasicAuth("user2", miscToken)
// a token without repository scope must be denied
MakeRequest(t, reqDenied, http.StatusForbidden)
reqAllowed := NewRequest(t, "GET", url)
reqAllowed.SetBasicAuth("user2", ownerReadToken)
// a token with repository read scope is allowed
MakeRequest(t, reqAllowed, http.StatusOK)
})
}
}
-34
View File
@@ -10,9 +10,7 @@ import (
"testing"
auth_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/auth"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unit"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unittest"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/test"
@@ -28,8 +26,6 @@ func TestGitSmartHTTP(t *testing.T) {
testGitSmartHTTPTokenScopes(t)
testRenamedRepoRedirect(t)
testGitArchiveRemote(t, u)
t.Run("AnonymousAccess-Repo", func(t *testing.T) { testGitSmartHTTPPrivateRepoAnonymousAccess(t, false) })
t.Run("AnonymousAccess-Wiki", func(t *testing.T) { testGitSmartHTTPPrivateRepoAnonymousAccess(t, true) })
})
}
@@ -148,33 +144,3 @@ func testGitArchiveRemote(t *testing.T, u *url.URL) {
t.Run("Fetch HEAD archive subpath", doGitRemoteArchive(u.String(), "HEAD", "test"))
t.Run("list compression options", doGitRemoteArchive(u.String(), "--list"))
}
// testGitSmartHTTPPrivateRepoAnonymousAccess tests that a private repo with
// anonymous code access enabled can be cloned without credentials.
func testGitSmartHTTPPrivateRepoAnonymousAccess(t *testing.T, isWiki bool) {
// repo1 (ID=1) belongs to user2 and is public by default in fixtures
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerName: "user2", Name: "repo1"})
unitType := util.Iif(isWiki, unit.TypeWiki, unit.TypeCode)
repoLink := "/" + repo.FullName() + util.Iif(isWiki, ".wiki", "")
gitPullPath := repoLink + "/info/refs?service=git-upload-pack"
gitPushPath := repoLink + "/info/refs?service=git-receive-pack"
// make the repo private
require.NoError(t, repo_model.UpdateRepositoryColsNoAutoTime(t.Context(), &repo_model.Repository{ID: repo.ID, IsPrivate: true}, "is_private"))
// without anonymous access: anonymous pull must require auth
MakeRequest(t, NewRequest(t, "GET", gitPullPath), http.StatusUnauthorized)
// enable anonymous read access on the unit
require.NoError(t, repo_model.UpdateRepoUnitPublicAccess(t.Context(), &repo_model.RepoUnit{RepoID: repo.ID, Type: unitType, AnonymousAccessMode: perm.AccessModeRead}))
// with anonymous code access: anonymous pull must succeed without credentials
MakeRequest(t, NewRequest(t, "GET", gitPullPath), http.StatusOK)
// push (receive-pack) must still require auth even with anonymous code access
MakeRequest(t, NewRequest(t, "GET", gitPushPath), http.StatusUnauthorized)
// RequireSignInViewStrict must override anonymous access
defer test.MockVariableValue(&setting.Service.RequireSignInViewStrict, true)()
MakeRequest(t, NewRequest(t, "GET", gitPullPath), http.StatusUnauthorized)
}
@@ -171,18 +171,5 @@ func TestRepoMergeUpstream(t *testing.T) {
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusBadRequest)
})
t.Run("BasePrivateBlocksSync", func(t *testing.T) {
// add a new commit to the base repo, then make the base repo private
require.NoError(t, createOrReplaceFileInBranch(baseUser, baseRepo, "secret.txt", "master", "private-content"))
baseRepo.IsPrivate = true
_, err := db.GetEngine(t.Context()).ID(baseRepo.ID).Cols("is_private").Update(baseRepo)
require.NoError(t, err)
// the fork owner can no longer read the base repo, so syncing must be refused
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/test-repo-fork/merge-upstream", forkUser.Name), &api.MergeUpstreamRequest{
Branch: "fork-branch",
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusForbidden)
})
})
}
-13
View File
@@ -21,17 +21,6 @@ test('ConfigFormValueMapper', () => {
<input name="struct.SubBoolean" type="checkbox" data-config-value-type="boolean">
<input name="struct.SubTimestamp" type="datetime-local" data-config-value-type="timestamp">
<textarea name="struct.NewKey">new-value</textarea>
<!-- radio group (sub key): only the option matching the config value should be checked and collected -->
<input type="hidden" data-config-dyn-key="landing" data-config-value-json='{"Mode": "explore"}'>
<input name="landing.Mode" type="radio" value="home">
<input name="landing.Mode" type="radio" value="explore">
<input name="landing.Mode" type="radio" value="login">
<!-- radio group with empty value: the server-rendered default (home) must be preserved, not unchecked -->
<input type="hidden" data-config-dyn-key="landingDefault" data-config-value-json='{"Mode": ""}'>
<input name="landingDefault.Mode" type="radio" value="home" checked>
<input name="landingDefault.Mode" type="radio" value="explore">
</form>
`;
@@ -55,7 +44,5 @@ test('ConfigFormValueMapper', () => {
'k-flipped-true': 'true',
'repository.open-with.editor-apps': '[{"DisplayName":"a","OpenURL":"b"}]', // TODO: OPEN-WITH-EDITOR-APP-JSON: it must match backend
'struct': '{"SubBoolean":true,"SubTimestamp":123456780,"OtherKey":"other-value","NewKey":"new-value"}',
'landing': '{"Mode":"explore"}',
'landingDefault': '{"Mode":"home"}',
});
});
-14
View File
@@ -99,10 +99,6 @@ export class ConfigFormValueMapper {
if (el.matches('[type="checkbox"]')) {
if (valType !== 'boolean') requireExplicitValueType(el);
el.checked = Boolean(val ?? el.checked);
} else if (el.matches('[type="radio"]')) {
// a radio group shares one name; check only the option whose value equals the config value.
// when the value is empty (unset), leave the server-rendered default selection untouched.
if (String(val) !== '') el.checked = el.value === String(val);
} else if (el.matches('[type="datetime-local"]')) {
if (valType !== 'timestamp') requireExplicitValueType(el);
if (val) el.value = toDatetimeLocalValue(val);
@@ -124,9 +120,6 @@ export class ConfigFormValueMapper {
// it needs to iterate the "namedElems" to find all the checkboxes with the same name and collect values accordingly,
// and set the namedElems[matchedIdx] to null to avoid duplicate processing.
val = collectCheckboxBooleanValue(el);
} else if (el.matches('[type="radio"]')) {
// only the checked radio of a group reaches here (callers skip unchecked ones); its value is the selection
val = el.value;
} else if (el.matches('[type="datetime-local"]')) {
if (valType !== 'timestamp') requireExplicitValueType(el);
val = Math.floor(new Date(el.value).getTime() / 1000) ?? 0; // NaN is fine to JSON.stringify, it becomes null.
@@ -146,12 +139,6 @@ export class ConfigFormValueMapper {
if (!el) continue;
const subKey = extractElemConfigSubKey(el, dynKey);
if (!subKey) continue; // if not match, skip
// a radio group has N elements sharing the same name; only the checked one carries the value.
// drop the unchecked ones so they neither overwrite the selection here nor leak into the fallback loop.
if (el.matches('[type="radio"]') && !el.checked) {
namedElems[idx] = null;
continue;
}
cfgVal[subKey] = this.collectConfigValueFromElement(el);
namedElems[idx] = null;
}
@@ -207,7 +194,6 @@ export class ConfigFormValueMapper {
// "foo.enabled" => "true"
for (const el of namedElems) {
if (!el) continue;
if (el.matches('[type="radio"]') && !el.checked) continue; // skip unchecked radios of a top-level group
const dynKey = el.name;
const newVal = this.collectConfigValueFromElement(el);
formData.append('key', dynKey);
+1 -1
View File
@@ -25,7 +25,7 @@ Custom CSS themes at `/var/lib/gitea/custom/public/assets/css/`:
---
*Repo: [MokoGitea](https://git.mokoconsulting.tech/MokoConsulting/MokoGitea) · [mokocli](https://git.mokoconsulting.tech/MokoConsulting/mokocli/wiki/Home)*
*Repo: [MokoGitea](https://git.mokoconsulting.tech/MokoConsulting/MokoGitea) · [moko-platform](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)*
| Revision | Date | Author | Description |
|---|---|---|---|
+1 -1
View File
@@ -126,7 +126,7 @@ When a field definition is deleted, all associated values in `custom_field_value
| System | Relationship |
|--------|-------------|
| Update Server | Repo-scoped custom fields with specific names (Extension Name, Display Name, etc.) are read by the update feed generators as the highest-priority metadata source. |
| Manifest Settings | Manifest fields follow the mokocli schema and are separate from custom fields. Custom fields are user-defined; manifest fields are standardized. |
| Manifest Settings | Manifest fields follow the moko-platform schema and are separate from custom fields. Custom fields are user-defined; manifest fields are standardized. |
| Issue Statuses | Custom statuses are a separate feature with their own dedicated table and UI, not implemented as custom fields. |
---
+1 -1
View File
@@ -41,7 +41,7 @@ Located at `/var/lib/gitea/custom/`:
---
*Repo: [MokoGitea](https://git.mokoconsulting.tech/MokoConsulting/MokoGitea) · [mokocli](https://git.mokoconsulting.tech/MokoConsulting/mokocli/wiki/Home)*
*Repo: [MokoGitea](https://git.mokoconsulting.tech/MokoConsulting/MokoGitea) · [moko-platform](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)*
| Revision | Date | Author | Description |
|---|---|---|---|
+2 -2
View File
@@ -309,8 +309,8 @@ Packages can be **archived** instead of permanently deleted. This is the recomme
In **Organization Settings → Licensing & Update Streams**, under **Extension Metadata**:
1. Set the **Platform** (Joomla, Dolibarr, WordPress, etc.)
2. Set the **Extension Name** (e.g., `pkg_mokosuite`) — this becomes the `<element>` in the XML feed
3. Set the **Display Name** (e.g., "Package - MokoSuite") — shown in Joomla update manager
2. Set the **Extension Name** (e.g., `pkg_mokowaas`) — this becomes the `<element>` in the XML feed
3. Set the **Display Name** (e.g., "Package - MokoWaaS") — shown in Joomla update manager
4. Set the **Extension Type** (component, module, plugin, package, template, library)
5. Set the **Target Version** regex (e.g., `(5|6)\..*` for Joomla 5 and 6)
6. Set the **PHP Minimum** if applicable (e.g., `8.1`)
+5 -5
View File
@@ -7,7 +7,7 @@ The manifest settings feature provides a centralized way to store and manage pro
Each repository can have a manifest that describes:
- **Identity** — project name, organization, description, version, and license
- **Governance** — platform type, mokocli standards version, and standards source URL
- **Governance** — platform type, moko-platform standards version, and standards source URL
- **Build** — language, package type, and entry point
These settings replace the legacy `.mokogitea/manifest.xml` file-based approach.
@@ -35,7 +35,7 @@ If a field already has a value in the database (e.g., from org-level custom fiel
## REST API
The manifest API allows Actions workflows and the mokocli CLI to read and write manifest settings programmatically.
The manifest API allows Actions workflows and the moko-platform CLI to read and write manifest settings programmatically.
### Get Manifest
@@ -57,7 +57,7 @@ Returns the current manifest settings. If no manifest has been saved, returns de
"license_name": "GNU General Public License v3",
"platform": "go",
"standards_version": "05.00.00",
"standards_source": "https://code.mokoconsulting.tech/MokoConsulting/mokocli",
"standards_source": "https://code.mokoconsulting.tech/MokoConsulting/moko-platform",
"language": "Go",
"package_type": "application",
"entry_point": "./"
@@ -101,8 +101,8 @@ Manifest settings are stored in the `repo_manifest` table (migration v347). One
| System | Relationship |
|--------|-------------|
| Update Server | The update server generators read from both manifest settings and update_stream_config. Manifest provides identity metadata; update_stream_config provides feed-specific settings. |
| Custom Fields | Repo-scoped custom fields (org settings) are separate from manifest fields. Custom fields are user-defined; manifest fields follow the mokocli schema. |
| mokocli CLI | The CLI reads manifest settings via the API for version bumping, build decisions, and cross-repo syncing (see issue #505). |
| Custom Fields | Repo-scoped custom fields (org settings) are separate from manifest fields. Custom fields are user-defined; manifest fields follow the moko-platform schema. |
| moko-platform CLI | The CLI reads manifest settings via the API for version bumping, build decisions, and cross-repo syncing (see issue #505). |
---
+1 -1
View File
@@ -195,7 +195,7 @@ curl -X POST -H "Authorization: token TOKEN" \
---
*Repo: [MokoGitea](https://git.mokoconsulting.tech/MokoConsulting/MokoGitea) · [mokocli](https://git.mokoconsulting.tech/MokoConsulting/mokocli/wiki/Home)*
*Repo: [MokoGitea](https://git.mokoconsulting.tech/MokoConsulting/MokoGitea) · [moko-platform](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)*
| Revision | Date | Author | Description |
|---|---|---|---|
+2 -2
View File
@@ -23,9 +23,9 @@
## In Progress
- Rename mokocli to MokoCLI
- Rename moko-platform to MokoPlatform
- Granular role-based permissions for all features (#9)
- Wire mokocli CLI to manifest API (#505)
- Wire moko-platform CLI to manifest API (#505)
- Bulk migrate remaining 41 flat wikis to folders
## Planned