From d42041c5b5eeff5198acf6c8057b54ce69cf4b67 Mon Sep 17 00:00:00 2001 From: Alex <63423455+AlexMadera@users.noreply.github.com> Date: Thu, 25 Dec 2025 15:51:55 +0100 Subject: [PATCH] ci: implement enterprise-grade PR quality gates and security scanning (#266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: implement enterprise-grade PR quality gates and security scanning * ci: implement enterprise-grade PR quality gates and security scanning * fix:pr comments and improve code * fix: improve commit linting and code quality * Removed the dependency-review job (i added it) * fix: address CodeRabbit review comments - Expand scope pattern to allow uppercase, underscores, slashes, dots - Add concurrency control to cancel duplicate security scan runs - Add explanatory comment for Bandit CLI flags - Remove dependency-review job (requires repo settings) šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * docs: update commit lint examples with expanded scope patterns Show slashes and dots in scope examples to demonstrate the newly allowed characters (api/users, package.json) šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * chore: remove feature request issue template Feature requests are directed to GitHub Discussions via the issue template config.yml šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix: address security vulnerabilities in service orchestrator - Fix port parsing crash on malformed docker-compose entries - Fix shell injection risk by using shlex.split() with shell=False Prevents crashes when docker-compose.yml contains environment variables in port mappings (e.g., '${PORT}:8080') and eliminates shell injection vulnerabilities in subprocess execution. šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .github/dependabot.yml | 37 +++ .github/workflows/ci.yml | 2 +- .../{auto-label.yml => issue-auto-label.yml} | 2 +- .github/workflows/lint.yml | 23 -- .github/workflows/pr-auto-label.yml | 227 ++++++++++++++++++ .github/workflows/pr-status-check.yml | 72 ++++++ .github/workflows/pr-status-gate.yml | 161 +++++++++++++ .github/workflows/quality-commit-lint.yml | 74 ++++++ .github/workflows/quality-dco.yml | 70 ++++++ .github/workflows/quality-security.yml | 178 ++++++++++++++ .../graphiti/queries_pkg/graphiti.py | 2 +- .../graphiti/queries_pkg/search.py | 2 +- apps/backend/project/analyzer.py | 2 +- apps/backend/review/state.py | 4 +- apps/backend/services/orchestrator.py | 17 +- package-lock.json | 17 ++ 16 files changed, 855 insertions(+), 35 deletions(-) create mode 100644 .github/dependabot.yml rename .github/workflows/{auto-label.yml => issue-auto-label.yml} (98%) create mode 100644 .github/workflows/pr-auto-label.yml create mode 100644 .github/workflows/pr-status-check.yml create mode 100644 .github/workflows/pr-status-gate.yml create mode 100644 .github/workflows/quality-commit-lint.yml create mode 100644 .github/workflows/quality-dco.yml create mode 100644 .github/workflows/quality-security.yml create mode 100644 package-lock.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..53c113d2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,37 @@ +version: 2 +updates: + # Python dependencies + - package-ecosystem: pip + directory: /apps/backend + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: + - dependencies + - python + commit-message: + prefix: "chore(deps)" + + # npm dependencies + - package-ecosystem: npm + directory: /apps/frontend + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: + - dependencies + - javascript + commit-message: + prefix: "chore(deps)" + + # GitHub Actions + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: + - dependencies + - ci + commit-message: + prefix: "ci(deps)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e47df54..80cc6e05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: PYTHONPATH: ${{ github.workspace }}/apps/backend run: | source .venv/bin/activate - pytest ../../tests/ -v --cov=. --cov-report=xml --cov-report=term-missing + pytest ../../tests/ -v --cov=. --cov-report=xml --cov-report=term-missing --cov-fail-under=30 - name: Upload coverage reports if: matrix.python-version == '3.12' diff --git a/.github/workflows/auto-label.yml b/.github/workflows/issue-auto-label.yml similarity index 98% rename from .github/workflows/auto-label.yml rename to .github/workflows/issue-auto-label.yml index e1347438..bab02454 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/issue-auto-label.yml @@ -1,4 +1,4 @@ -name: Auto Label +name: Issue Auto Label on: issues: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 4eb25026..7b26083f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -28,26 +28,3 @@ jobs: - name: Run ruff format check run: ruff format apps/backend/ --check --diff - # TypeScript/React linting - frontend: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '24' - - - name: Install dependencies - working-directory: apps/frontend - run: npm ci --ignore-scripts - - - name: Run ESLint - working-directory: apps/frontend - run: npm run lint - - - name: Run TypeScript check - working-directory: apps/frontend - run: npm run typecheck diff --git a/.github/workflows/pr-auto-label.yml b/.github/workflows/pr-auto-label.yml new file mode 100644 index 00000000..ac6775e7 --- /dev/null +++ b/.github/workflows/pr-auto-label.yml @@ -0,0 +1,227 @@ +name: PR Auto Label + +on: + pull_request: + types: [opened, synchronize, reopened] + +# Cancel in-progress runs for the same PR +concurrency: + group: pr-auto-label-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + label: + name: Auto Label PR + runs-on: ubuntu-latest + # Don't run on fork PRs (they can't write labels) + if: github.event.pull_request.head.repo.full_name == github.repository + timeout-minutes: 5 + steps: + - name: Auto-label PR + uses: actions/github-script@v7 + with: + retries: 3 + retry-exempt-status-codes: 400,401,403,404,422 + script: | + const { owner, repo } = context.repo; + const pr = context.payload.pull_request; + const prNumber = pr.number; + const title = pr.title; + + console.log(`::group::PR #${prNumber} - Auto-labeling`); + console.log(`Title: ${title}`); + + const labelsToAdd = new Set(); + const labelsToRemove = new Set(); + + // ═══════════════════════════════════════════════════════════════ + // TYPE LABELS (from PR title - Conventional Commits) + // ═══════════════════════════════════════════════════════════════ + const typeMap = { + 'feat': 'feature', + 'fix': 'bug', + 'docs': 'documentation', + 'refactor': 'refactor', + 'test': 'test', + 'ci': 'ci', + 'chore': 'chore', + 'perf': 'performance', + 'style': 'style', + 'build': 'build' + }; + + const typeMatch = title.match(/^(\w+)(\(.+?\))?(!)?:/); + if (typeMatch) { + const type = typeMatch[1].toLowerCase(); + const isBreaking = typeMatch[3] === '!'; + + if (typeMap[type]) { + labelsToAdd.add(typeMap[type]); + console.log(` šŸ“ Type: ${type} → ${typeMap[type]}`); + } + + if (isBreaking) { + labelsToAdd.add('breaking-change'); + console.log(` āš ļø Breaking change detected`); + } + } else { + console.log(` āš ļø No conventional commit prefix found in title`); + } + + // ═══════════════════════════════════════════════════════════════ + // AREA LABELS (from changed files) + // ═══════════════════════════════════════════════════════════════ + let files = []; + try { + const { data } = await github.rest.pulls.listFiles({ + owner, + repo, + pull_number: prNumber, + per_page: 100 + }); + files = data; + } catch (e) { + console.log(` āš ļø Could not fetch files: ${e.message}`); + } + + const areas = { + frontend: false, + backend: false, + ci: false, + docs: false, + tests: false + }; + + for (const file of files) { + const path = file.filename; + if (path.startsWith('apps/frontend/')) areas.frontend = true; + if (path.startsWith('apps/backend/')) areas.backend = true; + if (path.startsWith('.github/')) areas.ci = true; + if (path.endsWith('.md') || path.startsWith('docs/')) areas.docs = true; + if (path.startsWith('tests/') || path.includes('.test.') || path.includes('.spec.')) areas.tests = true; + } + + // Determine area label (mutually exclusive) + const areaLabels = ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci']; + + if (areas.frontend && areas.backend) { + labelsToAdd.add('area/fullstack'); + areaLabels.filter(l => l !== 'area/fullstack').forEach(l => labelsToRemove.add(l)); + console.log(` šŸ“ Area: fullstack (${files.length} files)`); + } else if (areas.frontend) { + labelsToAdd.add('area/frontend'); + areaLabels.filter(l => l !== 'area/frontend').forEach(l => labelsToRemove.add(l)); + console.log(` šŸ“ Area: frontend (${files.length} files)`); + } else if (areas.backend) { + labelsToAdd.add('area/backend'); + areaLabels.filter(l => l !== 'area/backend').forEach(l => labelsToRemove.add(l)); + console.log(` šŸ“ Area: backend (${files.length} files)`); + } else if (areas.ci) { + labelsToAdd.add('area/ci'); + areaLabels.filter(l => l !== 'area/ci').forEach(l => labelsToRemove.add(l)); + console.log(` šŸ“ Area: ci (${files.length} files)`); + } + + // ═══════════════════════════════════════════════════════════════ + // SIZE LABELS (from lines changed) + // ═══════════════════════════════════════════════════════════════ + const additions = pr.additions || 0; + const deletions = pr.deletions || 0; + const totalLines = additions + deletions; + + const sizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL']; + let sizeLabel; + + if (totalLines < 10) sizeLabel = 'size/XS'; + else if (totalLines < 100) sizeLabel = 'size/S'; + else if (totalLines < 500) sizeLabel = 'size/M'; + else if (totalLines < 1000) sizeLabel = 'size/L'; + else sizeLabel = 'size/XL'; + + labelsToAdd.add(sizeLabel); + sizeLabels.filter(l => l !== sizeLabel).forEach(l => labelsToRemove.add(l)); + console.log(` šŸ“ Size: ${sizeLabel} (+${additions}/-${deletions} = ${totalLines} lines)`); + + console.log('::endgroup::'); + + // ═══════════════════════════════════════════════════════════════ + // APPLY LABELS + // ═══════════════════════════════════════════════════════════════ + console.log(`::group::Applying labels`); + + // Remove old labels (in parallel) + const removeArray = [...labelsToRemove].filter(l => !labelsToAdd.has(l)); + if (removeArray.length > 0) { + const removePromises = removeArray.map(async (label) => { + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: prNumber, + name: label + }); + console.log(` āœ“ Removed: ${label}`); + } catch (e) { + if (e.status !== 404) { + console.log(` ⚠ Could not remove ${label}: ${e.message}`); + } + } + }); + await Promise.all(removePromises); + } + + // Add new labels + const addArray = [...labelsToAdd]; + if (addArray.length > 0) { + try { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: prNumber, + labels: addArray + }); + console.log(` āœ“ Added: ${addArray.join(', ')}`); + } catch (e) { + // Some labels might not exist + if (e.status === 404) { + core.warning(`Some labels do not exist. Please create them in repository settings.`); + // Try adding one by one + for (const label of addArray) { + try { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: prNumber, + labels: [label] + }); + } catch (e2) { + console.log(` ⚠ Label '${label}' does not exist`); + } + } + } else { + throw e; + } + } + } + + console.log('::endgroup::'); + + // Summary + console.log(`āœ… PR #${prNumber} labeled: ${addArray.join(', ')}`); + + // Write job summary + core.summary + .addHeading(`PR #${prNumber} Auto-Labels`, 3) + .addTable([ + [{data: 'Category', header: true}, {data: 'Label', header: true}], + ['Type', typeMatch ? typeMap[typeMatch[1].toLowerCase()] || 'none' : 'none'], + ['Area', areas.frontend && areas.backend ? 'fullstack' : areas.frontend ? 'frontend' : areas.backend ? 'backend' : 'other'], + ['Size', sizeLabel] + ]) + .addRaw(`\n**Files changed:** ${files.length}\n`) + .addRaw(`**Lines:** +${additions} / -${deletions}\n`); + await core.summary.write(); diff --git a/.github/workflows/pr-status-check.yml b/.github/workflows/pr-status-check.yml new file mode 100644 index 00000000..95c6239e --- /dev/null +++ b/.github/workflows/pr-status-check.yml @@ -0,0 +1,72 @@ +name: PR Status Check + +on: + pull_request: + types: [opened, synchronize, reopened] + +# Cancel in-progress runs for the same PR +concurrency: + group: pr-status-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + pull-requests: write + +jobs: + mark-checking: + name: Set Checking Status + runs-on: ubuntu-latest + # Don't run on fork PRs (they can't write labels) + if: github.event.pull_request.head.repo.full_name == github.repository + timeout-minutes: 5 + steps: + - name: Update PR status label + uses: actions/github-script@v7 + with: + retries: 3 + retry-exempt-status-codes: 400,401,403,404,422 + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + const statusLabels = ['šŸ”„ Checking', 'āœ… Ready for Review', 'āŒ Checks Failed']; + + console.log(`::group::PR #${prNumber} - Setting status to Checking`); + + // Remove old status labels (parallel for speed) + const removePromises = statusLabels.map(async (label) => { + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: prNumber, + name: label + }); + console.log(` āœ“ Removed: ${label}`); + } catch (e) { + if (e.status !== 404) { + console.log(` ⚠ Could not remove ${label}: ${e.message}`); + } + } + }); + + await Promise.all(removePromises); + + // Add checking label + try { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: prNumber, + labels: ['šŸ”„ Checking'] + }); + console.log(` āœ“ Added: šŸ”„ Checking`); + } catch (e) { + // Label might not exist - create helpful error + if (e.status === 404) { + core.warning(`Label 'šŸ”„ Checking' does not exist. Please create it in repository settings.`); + } + throw e; + } + + console.log('::endgroup::'); + console.log(`āœ… PR #${prNumber} marked as checking`); diff --git a/.github/workflows/pr-status-gate.yml b/.github/workflows/pr-status-gate.yml new file mode 100644 index 00000000..addc8f9a --- /dev/null +++ b/.github/workflows/pr-status-gate.yml @@ -0,0 +1,161 @@ +name: PR Status Gate + +on: + workflow_run: + workflows: [CI, Lint, Quality Security, Quality DCO, Quality Commit Lint] + types: [completed] + +permissions: + pull-requests: write + actions: read + +jobs: + update-status: + name: Update PR Status + runs-on: ubuntu-latest + # Only run if this workflow_run is associated with a PR + if: github.event.workflow_run.pull_requests[0] != null + timeout-minutes: 5 + steps: + - name: Check all workflows and update label + uses: actions/github-script@v7 + with: + retries: 3 + retry-exempt-status-codes: 400,401,403,404,422 + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.workflow_run.pull_requests[0].number; + const headSha = context.payload.workflow_run.head_sha; + const triggerWorkflow = context.payload.workflow_run.name; + + // Required workflows configuration + const required = [ + { name: 'CI', file: 'ci.yml' }, + { name: 'Lint', file: 'lint.yml' }, + { name: 'Quality Security', file: 'quality-security.yml' }, + { name: 'Quality DCO', file: 'quality-dco.yml' }, + { name: 'Quality Commit Lint', file: 'quality-commit-lint.yml' } + ]; + + const statusLabels = { + checking: 'šŸ”„ Checking', + passed: 'āœ… Ready for Review', + failed: 'āŒ Checks Failed' + }; + + console.log(`::group::PR #${prNumber} - Checking workflow status`); + console.log(`Triggered by: ${triggerWorkflow}`); + console.log(`Head SHA: ${headSha}`); + console.log(''); + + let allComplete = true; + let anyFailed = false; + const results = []; + + // Check all required workflows + for (const workflow of required) { + try { + const { data } = await github.rest.actions.listWorkflowRuns({ + owner, + repo, + workflow_id: workflow.file, + head_sha: headSha, + per_page: 1 + }); + + if (!data.workflow_runs.length) { + results.push({ name: workflow.name, status: 'ā³ Pending', complete: false }); + allComplete = false; + continue; + } + + const run = data.workflow_runs[0]; + + if (run.status !== 'completed') { + results.push({ name: workflow.name, status: 'šŸ”„ Running', complete: false }); + allComplete = false; + } else if (run.conclusion === 'success') { + results.push({ name: workflow.name, status: 'āœ… Passed', complete: true }); + } else if (run.conclusion === 'skipped') { + results.push({ name: workflow.name, status: 'ā­ļø Skipped', complete: true }); + } else { + results.push({ name: workflow.name, status: 'āŒ Failed', complete: true, failed: true }); + anyFailed = true; + } + } catch (error) { + console.log(`::warning::Error checking ${workflow.name}: ${error.message}`); + results.push({ name: workflow.name, status: 'āš ļø Error', complete: false }); + allComplete = false; + } + } + + // Print results table + console.log('Workflow Status:'); + console.log('─'.repeat(40)); + for (const r of results) { + console.log(` ${r.status.padEnd(12)} ${r.name}`); + } + console.log('─'.repeat(40)); + console.log('::endgroup::'); + + // Only update label if all workflows are complete + if (!allComplete) { + console.log('ā³ Not all workflows complete yet - keeping current label'); + return; + } + + // Determine final label + const newLabel = anyFailed ? statusLabels.failed : statusLabels.passed; + + console.log(`::group::Updating PR #${prNumber} label`); + + // Remove old status labels + for (const label of Object.values(statusLabels)) { + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: prNumber, + name: label + }); + console.log(` āœ“ Removed: ${label}`); + } catch (e) { + if (e.status !== 404) { + console.log(` ⚠ Could not remove ${label}: ${e.message}`); + } + } + } + + // Add final status label + try { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: prNumber, + labels: [newLabel] + }); + console.log(` āœ“ Added: ${newLabel}`); + } catch (e) { + if (e.status === 404) { + core.warning(`Label '${newLabel}' does not exist. Please create it in repository settings.`); + } + throw e; + } + + console.log('::endgroup::'); + + // Summary + if (anyFailed) { + console.log(`āŒ PR #${prNumber} has failing checks`); + core.summary.addRaw(`## āŒ PR #${prNumber} - Checks Failed\n\n`); + } else { + console.log(`āœ… PR #${prNumber} is ready for review`); + core.summary.addRaw(`## āœ… PR #${prNumber} - Ready for Review\n\n`); + } + + // Add results to summary + core.summary.addTable([ + [{data: 'Workflow', header: true}, {data: 'Status', header: true}], + ...results.map(r => [r.name, r.status]) + ]); + await core.summary.write(); diff --git a/.github/workflows/quality-commit-lint.yml b/.github/workflows/quality-commit-lint.yml new file mode 100644 index 00000000..c1eb9977 --- /dev/null +++ b/.github/workflows/quality-commit-lint.yml @@ -0,0 +1,74 @@ +name: Quality Commit Lint + +on: + pull_request: + branches: [main, develop] + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +jobs: + check: + name: Conventional Commits + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Validate PR title + uses: actions/github-script@v7 + with: + retries: 3 + script: | + const pr = context.payload.pull_request; + const title = pr.title; + + console.log(`::group::PR #${pr.number} - Validating PR title`); + console.log(`Title: ${title}`); + + // Conventional Commits pattern for PR title + // type(scope)?: description (max 100 chars) + // Optional ! for breaking changes: feat!: or feat(scope)!: + // Scope allows: letters, numbers, hyphens, underscores, slashes, dots + const pattern = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-zA-Z0-9_\-\/\.]+\))?!?: .{1,100}$/; + + const isValid = pattern.test(title); + console.log(`Valid: ${isValid}`); + console.log('::endgroup::'); + + if (!isValid) { + let errorMsg = '## āŒ PR Title Validation Failed\n\n'; + errorMsg += `Your PR title does not follow [Conventional Commits](https://www.conventionalcommits.org/) format:\n\n`; + errorMsg += `> \`${title}\`\n\n`; + errorMsg += '### Expected Format\n\n'; + errorMsg += '```\ntype(scope): description\n```\n\n'; + errorMsg += '| Type | Description |\n'; + errorMsg += '|------|-------------|\n'; + errorMsg += '| `feat` | New feature |\n'; + errorMsg += '| `fix` | Bug fix |\n'; + errorMsg += '| `docs` | Documentation only |\n'; + errorMsg += '| `style` | Code style (formatting, etc.) |\n'; + errorMsg += '| `refactor` | Code refactoring |\n'; + errorMsg += '| `perf` | Performance improvement |\n'; + errorMsg += '| `test` | Adding/updating tests |\n'; + errorMsg += '| `build` | Build system changes |\n'; + errorMsg += '| `ci` | CI/CD changes |\n'; + errorMsg += '| `chore` | Maintenance tasks |\n'; + errorMsg += '| `revert` | Reverting changes |\n\n'; + errorMsg += '### Examples\n\n'; + errorMsg += '```\nfeat(auth): add OAuth2 login support\nfix(api/users): handle null response correctly\nfix(package.json): update dependencies\ndocs: update README installation steps\nci: add automated release workflow\n```\n\n'; + errorMsg += '### How to Fix\n\n'; + errorMsg += 'Edit your PR title to follow the format above.\n'; + + core.summary.addRaw(errorMsg); + await core.summary.write(); + + core.setFailed('PR title does not follow Conventional Commits format'); + } else { + console.log(`āœ… PR title follows Conventional Commits format`); + + core.summary + .addHeading('āœ… PR Title Valid', 3) + .addRaw(`PR title follows Conventional Commits format: \`${title}\``); + await core.summary.write(); + } diff --git a/.github/workflows/quality-dco.yml b/.github/workflows/quality-dco.yml new file mode 100644 index 00000000..cfb15ec7 --- /dev/null +++ b/.github/workflows/quality-dco.yml @@ -0,0 +1,70 @@ +name: Quality DCO + +on: + pull_request: + branches: [main, develop] + +permissions: + contents: read + pull-requests: read + +jobs: + check: + name: DCO Check + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check DCO Sign-off + uses: dcoapp/dco-check@v1 + with: + require-signoff: true + + - name: DCO Help on Failure + if: failure() + uses: actions/github-script@v7 + with: + script: | + const helpMsg = `## āŒ DCO Sign-off Required + +This project requires all commits to be signed off with the Developer Certificate of Origin (DCO). + +### How to Fix + +**Option 1: Sign off your last commit** +\`\`\`bash +git commit --amend --signoff +git push --force-with-lease +\`\`\` + +**Option 2: Sign off all commits in this PR** +\`\`\`bash +git rebase HEAD~N --signoff # Replace N with number of commits +git push --force-with-lease +\`\`\` + +**Option 3: Configure git to always sign off** +\`\`\`bash +git config --global format.signoff true +\`\`\` + +### What is DCO? + +The [Developer Certificate of Origin](https://developercertificate.org/) is a lightweight way for contributors to certify that they wrote or have the right to submit the code they are contributing. + +By signing off, you agree to the DCO terms: +- The contribution was created by you +- You have the right to submit it under the project's license +- You understand the contribution is public and recorded + +### Sign-off Format + +Your commit message should end with: +\`\`\` +Signed-off-by: Your Name +\`\`\` + +This line is automatically added when you use \`git commit -s\` or \`git commit --signoff\`. +`; + + core.summary.addRaw(helpMsg); + await core.summary.write(); diff --git a/.github/workflows/quality-security.yml b/.github/workflows/quality-security.yml new file mode 100644 index 00000000..3f347634 --- /dev/null +++ b/.github/workflows/quality-security.yml @@ -0,0 +1,178 @@ +name: Quality Security + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + schedule: + - cron: '0 0 * * 1' # Weekly on Monday at midnight UTC + +# Cancel in-progress runs for the same branch/PR +concurrency: + group: security-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + security-events: write + actions: read + +jobs: + codeql: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + language: [python, javascript-typescript] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + queries: +security-extended,security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" + + python-security: + name: Python Security (Bandit) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Bandit + run: pip install bandit + + - name: Run Bandit security scan + id: bandit + run: | + echo "::group::Running Bandit security scan" + # Run Bandit; exit code 1 means issues found (expected), other codes are errors + # Flags: -r=recursive, -ll=severity LOW+, -ii=confidence LOW+, -f=format, -o=output + bandit -r apps/backend/ -ll -ii -f json -o bandit-report.json || BANDIT_EXIT=$? + if [ "${BANDIT_EXIT:-0}" -gt 1 ]; then + echo "::error::Bandit scan failed with exit code $BANDIT_EXIT" + exit 1 + fi + echo "::endgroup::" + + - name: Analyze Bandit results + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + // Check if report exists + if (!fs.existsSync('bandit-report.json')) { + core.setFailed('Bandit report not found - scan may have failed'); + return; + } + + const report = JSON.parse(fs.readFileSync('bandit-report.json', 'utf8')); + const results = report.results || []; + + // Categorize by severity + const high = results.filter(r => r.issue_severity === 'HIGH'); + const medium = results.filter(r => r.issue_severity === 'MEDIUM'); + const low = results.filter(r => r.issue_severity === 'LOW'); + + console.log(`::group::Bandit Security Scan Results`); + console.log(`Found ${results.length} issues:`); + console.log(` šŸ”“ HIGH: ${high.length}`); + console.log(` 🟔 MEDIUM: ${medium.length}`); + console.log(` 🟢 LOW: ${low.length}`); + console.log(''); + + // Print high severity issues + if (high.length > 0) { + console.log('High Severity Issues:'); + console.log('─'.repeat(60)); + for (const issue of high) { + console.log(` ${issue.filename}:${issue.line_number}`); + console.log(` ${issue.issue_text}`); + console.log(` Test: ${issue.test_id} (${issue.test_name})`); + console.log(''); + } + } + console.log('::endgroup::'); + + // Build summary + let summary = `## šŸ”’ Python Security Scan (Bandit)\n\n`; + summary += `| Severity | Count |\n`; + summary += `|----------|-------|\n`; + summary += `| šŸ”“ High | ${high.length} |\n`; + summary += `| 🟔 Medium | ${medium.length} |\n`; + summary += `| 🟢 Low | ${low.length} |\n\n`; + + if (high.length > 0) { + summary += `### High Severity Issues\n\n`; + for (const issue of high) { + summary += `- **${issue.filename}:${issue.line_number}**\n`; + summary += ` - ${issue.issue_text}\n`; + summary += ` - Test: \`${issue.test_id}\` (${issue.test_name})\n\n`; + } + } + + core.summary.addRaw(summary); + await core.summary.write(); + + // Fail if high severity issues found + if (high.length > 0) { + core.setFailed(`Found ${high.length} high severity security issue(s)`); + } else { + console.log('āœ… No high severity security issues found'); + } + + # Summary job that waits for all security checks + security-summary: + name: Security Summary + runs-on: ubuntu-latest + needs: [codeql, python-security] + if: always() + timeout-minutes: 5 + steps: + - name: Check security results + uses: actions/github-script@v7 + with: + script: | + const codeql = '${{ needs.codeql.result }}'; + const bandit = '${{ needs.python-security.result }}'; + + console.log('Security Check Results:'); + console.log(` CodeQL: ${codeql}`); + console.log(` Bandit: ${bandit}`); + + // Only 'failure' is a real failure; 'skipped' is acceptable (e.g., path filters) + const acceptable = ['success', 'skipped']; + const codeqlOk = acceptable.includes(codeql); + const banditOk = acceptable.includes(bandit); + const allPassed = codeqlOk && banditOk; + + if (allPassed) { + console.log('\nāœ… All security checks passed'); + core.summary.addRaw('## āœ… Security Checks Passed\n\nAll security scans completed successfully.'); + } else { + console.log('\nāŒ Some security checks failed'); + core.summary.addRaw('## āŒ Security Checks Failed\n\nOne or more security scans found issues.'); + core.setFailed('Security checks failed'); + } + + await core.summary.write(); diff --git a/apps/backend/integrations/graphiti/queries_pkg/graphiti.py b/apps/backend/integrations/graphiti/queries_pkg/graphiti.py index 798cf5f7..2b372706 100644 --- a/apps/backend/integrations/graphiti/queries_pkg/graphiti.py +++ b/apps/backend/integrations/graphiti/queries_pkg/graphiti.py @@ -108,7 +108,7 @@ class GraphitiMemory: if self.group_id_mode == GroupIdMode.PROJECT: project_name = self.project_dir.name path_hash = hashlib.md5( - str(self.project_dir.resolve()).encode() + str(self.project_dir.resolve()).encode(), usedforsecurity=False ).hexdigest()[:8] return f"project_{project_name}_{path_hash}" else: diff --git a/apps/backend/integrations/graphiti/queries_pkg/search.py b/apps/backend/integrations/graphiti/queries_pkg/search.py index 00c1d6ce..ce519d67 100644 --- a/apps/backend/integrations/graphiti/queries_pkg/search.py +++ b/apps/backend/integrations/graphiti/queries_pkg/search.py @@ -75,7 +75,7 @@ class GraphitiSearch: if self.group_id_mode == GroupIdMode.SPEC and include_project_context: project_name = self.project_dir.name path_hash = hashlib.md5( - str(self.project_dir.resolve()).encode() + str(self.project_dir.resolve()).encode(), usedforsecurity=False ).hexdigest()[:8] project_group_id = f"project_{project_name}_{path_hash}" if project_group_id != self.group_id: diff --git a/apps/backend/project/analyzer.py b/apps/backend/project/analyzer.py index f4ed0237..3ee8b38d 100644 --- a/apps/backend/project/analyzer.py +++ b/apps/backend/project/analyzer.py @@ -112,7 +112,7 @@ class ProjectAnalyzer: "docker-compose.yaml", ] - hasher = hashlib.md5() + hasher = hashlib.md5(usedforsecurity=False) files_found = 0 for filename in hash_files: diff --git a/apps/backend/review/state.py b/apps/backend/review/state.py index 8a365174..cd536bc5 100644 --- a/apps/backend/review/state.py +++ b/apps/backend/review/state.py @@ -22,7 +22,7 @@ def _compute_file_hash(file_path: Path) -> str: return "" try: content = file_path.read_text(encoding="utf-8") - return hashlib.md5(content.encode("utf-8")).hexdigest() + return hashlib.md5(content.encode("utf-8"), usedforsecurity=False).hexdigest() except (OSError, UnicodeDecodeError): return "" @@ -35,7 +35,7 @@ def _compute_spec_hash(spec_dir: Path) -> str: spec_hash = _compute_file_hash(spec_dir / "spec.md") plan_hash = _compute_file_hash(spec_dir / "implementation_plan.json") combined = f"{spec_hash}:{plan_hash}" - return hashlib.md5(combined.encode("utf-8")).hexdigest() + return hashlib.md5(combined.encode("utf-8"), usedforsecurity=False).hexdigest() @dataclass diff --git a/apps/backend/services/orchestrator.py b/apps/backend/services/orchestrator.py index 671d050e..a50b78ab 100644 --- a/apps/backend/services/orchestrator.py +++ b/apps/backend/services/orchestrator.py @@ -21,6 +21,7 @@ Usage: """ import json +import shlex import subprocess import time from dataclasses import dataclass, field @@ -178,9 +179,13 @@ class ServiceOrchestrator: ports = config.get("ports", []) port = None if ports: - port_mapping = str(ports[0]) - if ":" in port_mapping: - port = int(port_mapping.split(":")[0]) + try: + port_mapping = str(ports[0]) + if ":" in port_mapping: + port = int(port_mapping.split(":")[0]) + except (ValueError, IndexError): + # Skip malformed port mappings (e.g., environment variables) + port = None # Determine health check URL health_url = None @@ -331,9 +336,11 @@ class ServiceOrchestrator: for service in self._services: if service.startup_command: try: + # Use shlex.split() for safe parsing of shell-like syntax + # shell=False prevents shell injection vulnerabilities proc = subprocess.Popen( - service.startup_command, - shell=True, + shlex.split(service.startup_command), + shell=False, cwd=self.project_dir / service.path if service.path else self.project_dir, diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..287c180a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,17 @@ +{ + "name": "auto-claude", + "version": "2.7.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "auto-claude", + "version": "2.7.2", + "license": "AGPL-3.0", + "engines": { + "node": ">=24.0.0", + "npm": ">=10.0.0" + } + } + } +}