diff --git a/.github/workflows/container-security-scan.yml b/.github/workflows/container-security-scan.yml new file mode 100644 index 00000000..b253a190 --- /dev/null +++ b/.github/workflows/container-security-scan.yml @@ -0,0 +1,59 @@ +name: Container Image Security Scan + +on: + push: + branches: [main] + paths: + - 'apps/api/dockerfile' + - 'apps/api-service/Dockerfile.e2e' + - 'docker-compose.yml' + - '.github/workflows/container-security-scan.yml' + pull_request: + branches: [main] + paths: + - 'apps/api/dockerfile' + - 'apps/api-service/Dockerfile.e2e' + - 'docker-compose.yml' + - '.github/workflows/container-security-scan.yml' + +permissions: + contents: read + security-events: write + +jobs: + trivy-scan: + name: Build & Vulnerability Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build API container image + uses: docker/build-push-action@v5 + with: + context: . + file: apps/api/dockerfile + push: false + load: true + tags: gasguard-api:scan-target + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Run Trivy vulnerability scanner on built image + uses: aquasecurity/trivy-action@0.28.0 + with: + image-ref: 'gasguard-api:scan-target' + format: 'sarif' + output: 'trivy-results.sarif' + severity: 'CRITICAL,HIGH' + ignore-unfixed: true + + - name: Upload Trivy SARIF report to GitHub Security + uses: github/codeql-action/upload-sarif@v3 + if: always() + with: + sarif_file: 'trivy-results.sarif' diff --git a/apps/api-service/Dockerfile.e2e b/apps/api-service/Dockerfile.e2e index 8c74f7f4..504323a2 100644 --- a/apps/api-service/Dockerfile.e2e +++ b/apps/api-service/Dockerfile.e2e @@ -1,23 +1,55 @@ -FROM node:18-alpine +# ── Build Stage ────────────────────────────────────────────────────────── +FROM node:20.18.0-alpine3.20 AS builder WORKDIR /app -# Copy package files +# Copy dependency specifications COPY package*.json ./ COPY tsconfig*.json ./ # Install dependencies -RUN npm ci --only=production +RUN npm ci && \ + npm cache clean --force -# Copy source code +# Copy source and test definitions COPY src/ ./src/ COPY test/ ./test/ -# Build the application +# Compile application RUN npm run build -# Expose port +# ── Hardened Test Runner Stage ─────────────────────────────────────────── +FROM node:20.18.0-alpine3.20 AS runner + +# Install tini for signal management +RUN apk add --no-cache tini ca-certificates && \ + rm -rf /var/cache/apk/* /tmp/* /root/.npm + +WORKDIR /app + +# Create unprivileged non-root user +RUN addgroup -g 10001 -S gasguard && \ + adduser -S -D -H -u 10001 -h /app -s /sbin/nologin -G gasguard -g "GasGuard Service Account" gasguard + +# Copy compiled files and runtime assets with non-root ownership +COPY --from=builder --chown=gasguard:gasguard /app/node_modules ./node_modules +COPY --from=builder --chown=gasguard:gasguard /app/dist ./dist +COPY --from=builder --chown=gasguard:gasguard /app/package*.json ./ +COPY --from=builder --chown=gasguard:gasguard /app/test ./test + +# Environment defaults +ENV NODE_ENV=test \ + PORT=3000 + EXPOSE 3000 -# Start the application -CMD ["npm", "run", "start:prod"] \ No newline at end of file +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 + +# Enforce non-root execution +USER gasguard + +ENTRYPOINT ["/sbin/tini", "--"] + +CMD ["npm", "run", "start:prod"] diff --git a/apps/api/dockerfile b/apps/api/dockerfile index 95352772..dac0bd19 100644 --- a/apps/api/dockerfile +++ b/apps/api/dockerfile @@ -1,56 +1,78 @@ -# Build stage -FROM node:18-alpine AS builder +# ── Build Stage ────────────────────────────────────────────────────────── +FROM node:20.18.0-alpine3.20 AS builder WORKDIR /app -# Copy package files +# Install build dependencies +RUN apk add --no-cache python3 make g++ + +# Copy workspace and lock files COPY package*.json ./ +COPY pnpm-lock.yaml ./ COPY apps/api/package*.json ./apps/api/ COPY libs/engine/package*.json ./libs/engine/ COPY packages/rules/package*.json ./packages/rules/ -# Install dependencies -RUN npm ci +# Install dependencies using pinned lockfile +RUN npm ci --ignore-scripts && \ + npm cache clean --force # Copy source code COPY . . -# Build the API +# Build the API service RUN npm run build --workspace=apps/api -# Production stage -FROM node:18-alpine +# Prune development dependencies for minimal production bundle +RUN npm prune --production && \ + npm cache clean --force + +# ── Production Hardened Stage ─────────────────────────────────────────── +FROM node:20.18.0-alpine3.20 AS runner -RUN apk add --no-cache tini +# Metadata labels (OCI specification) +LABEL org.opencontainers.image.title="GasGuard API Service" \ + org.opencontainers.image.description="Hardened, minimal container image for GasGuard backend API" \ + org.opencontainers.image.vendor="MDTechLabs" \ + org.opencontainers.image.version="1.0.0" + +# Install tini init system and ca-certificates with clean package cache +RUN apk add --no-cache tini ca-certificates && \ + rm -rf /var/cache/apk/* /tmp/* /root/.npm WORKDIR /app -# Create non-root user -RUN addgroup -g 1001 -S nodejs && \ - adduser -S nodejs -u 1001 +# Create dedicated unprivileged non-root user and group (no shell login) +RUN addgroup -g 10001 -S gasguard && \ + adduser -S -D -H -u 10001 -h /app -s /sbin/nologin -G gasguard -g "GasGuard Service Account" gasguard -# Copy package files and installed node_modules -COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./ -COPY --from=builder --chown=nodejs:nodejs /app/apps/api/package*.json ./apps/api/ -COPY --from=builder --chown=nodejs:nodejs /app/libs/engine/package*.json ./libs/engine/ -COPY --from=builder --chown=nodejs:nodejs /app/packages/rules/package*.json ./packages/rules/ -COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules +# Copy production artifacts with non-root ownership +COPY --from=builder --chown=gasguard:gasguard /app/package*.json ./ +COPY --from=builder --chown=gasguard:gasguard /app/apps/api/package*.json ./apps/api/ +COPY --from=builder --chown=gasguard:gasguard /app/libs/engine/package*.json ./libs/engine/ +COPY --from=builder --chown=gasguard:gasguard /app/packages/rules/package*.json ./packages/rules/ +COPY --from=builder --chown=gasguard:gasguard /app/node_modules ./node_modules +COPY --from=builder --chown=gasguard:gasguard /app/dist ./dist -# Copy built application -COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist +# Create logs directory owned by non-root user +RUN mkdir -p /app/logs && chown -R gasguard:gasguard /app/logs -# Set environment -ENV NODE_ENV=production -ENV PORT=3000 +# Environment configuration +ENV NODE_ENV=production \ + PORT=3000 -# Expose port +# Expose internal listening port EXPOSE 3000 -# Switch to non-root user -USER nodejs +# Health check using non-root wget against internal endpoint +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 + +# Enforce non-root execution +USER gasguard -# Use tini as init process +# Use tini as PID 1 init process to handle zombie reap and signal forwarding ENTRYPOINT ["/sbin/tini", "--"] -# Start the API -CMD ["node", "dist/apps/api/main.js"] \ No newline at end of file +# Launch application +CMD ["node", "dist/apps/api/main.js"] diff --git a/docker-compose.yml b/docker-compose.yml index 0fda7b60..af5050d7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,8 @@ services: image: postgres:15-alpine container_name: gasguard-postgres restart: unless-stopped + security_opt: + - "no-new-privileges:true" environment: POSTGRES_USER: ${DB_USER:-gasguard} POSTGRES_PASSWORD: ${DB_PASSWORD:-gasguard123} @@ -34,6 +36,8 @@ services: image: redis:7-alpine container_name: gasguard-redis restart: unless-stopped + security_opt: + - "no-new-privileges:true" command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-redis123} volumes: - redis-data:/data @@ -60,6 +64,9 @@ services: dockerfile: apps/api/Dockerfile container_name: gasguard-api restart: unless-stopped + security_opt: + - "no-new-privileges:true" + user: "10001:10001" depends_on: postgres: condition: service_healthy @@ -114,6 +121,9 @@ services: dockerfile: apps/api/Dockerfile container_name: gasguard-api-canary restart: unless-stopped + security_opt: + - "no-new-privileges:true" + user: "10001:10001" profiles: ["canary"] depends_on: postgres: @@ -163,6 +173,8 @@ services: image: nginx:1.27-alpine container_name: gasguard-nginx-canary restart: unless-stopped + security_opt: + - "no-new-privileges:true" profiles: ["canary"] depends_on: - api diff --git a/docs/CONTAINER_HARDENING.md b/docs/CONTAINER_HARDENING.md new file mode 100644 index 00000000..4bff72b7 --- /dev/null +++ b/docs/CONTAINER_HARDENING.md @@ -0,0 +1,53 @@ +# Container and Image Hardening Guide + +## Overview + +This document describes the security controls and hardening standards implemented for GasGuard container images and deployment manifests to ensure production readiness, minimal attack surfaces, and compliance with the principle of least privilege. + +--- + +## 1. Minimal Base Images + +All application containers utilize minimal Alpine Linux distributions pinned to explicit versions: +- Base image: `node:20.18.0-alpine3.20` +- Package managers and cache artifacts are purged immediately after build steps: + ```dockerfile + rm -rf /var/cache/apk/* /tmp/* /root/.npm + ``` +- Development tools, compilers (`g++`, `make`, `python3`), and source files are quarantined strictly to the `builder` stage and omitted from the production `runner` image. + +--- + +## 2. Non-Root Execution + +Container processes never execute as the root user (`UID 0`): +- A dedicated unprivileged system account is created with fixed UID/GID: + - User: `gasguard` (`UID 10001`) + - Group: `gasguard` (`GID 10001`) + - Login shell: `/sbin/nologin` + - Home directory: `/app` +- All application binaries and runtime assets are owned by `gasguard:gasguard`. +- Containers switch to unprivileged execution via `USER gasguard` prior to entrypoint invocation. + +--- + +## 3. Signal Handling and PID 1 Management + +- `tini` is installed and declared as the container entrypoint (`ENTRYPOINT ["/sbin/tini", "--"]`). +- This ensures proper reaping of zombie processes and correct propagation of termination signals (`SIGTERM`, `SIGINT`) for graceful zero-downtime shutdowns. + +--- + +## 4. Compose Hardening and Privilege Restrictions + +In `docker-compose.yml`, services are configured with defensive security options: +- `security_opt: ["no-new-privileges:true"]`: Prevents child processes from gaining additional privileges via `setuid` or `setgid` binaries. +- Explicit non-root user enforcement (`user: "10001:10001"`). +- Health checks configured with non-root diagnostic commands (`wget --spider`) to continuously report readiness without requiring privileged probing tools. + +--- + +## 5. Automated Vulnerability Scanning + +- Container images are automatically scanned via GitHub Actions (`.github/workflows/container-security-scan.yml`) using Trivy. +- The pipeline scans for `CRITICAL` and `HIGH` severity CVEs, generating SARIF reports uploaded directly to GitHub Security Code Scanning. diff --git a/src/integrations/github/README.md b/src/integrations/github/README.md index 66941ecf..b073a1a9 100644 --- a/src/integrations/github/README.md +++ b/src/integrations/github/README.md @@ -402,3 +402,80 @@ When adding new features to this module: ## License This module is part of GasGuard and follows the same license. + +--- + +## Additional Integration Features + +### 5. Pull Request Changed-File Discovery (#1043) + +Discovers changed files in a pull request with pagination support, file-type filtering, smart contract isolation, and security-sensitive file detection: + +```typescript +import { PullRequestFileDiscovery, GitHubClient } from '@/integrations/github'; + +const client = new GitHubClient({ token: process.env.GITHUB_TOKEN }); +const discovery = new PullRequestFileDiscovery(client, { + owner: 'MDTechLabs', + repo: 'GasGuard', + isPrivate: false, + defaultBranch: 'main', +}); + +// Discover all changed files +const files = await discovery.getChangedFiles(1043, { maxFiles: 200 }); + +// Extract modified smart contracts +const contracts = await discovery.getContractFiles(1043); + +// Get summary metrics and detect workflow/security changes +const summary = await discovery.getChangeSummary(1043); +if (summary.hasSecurityOrConfigFileChanges) { + console.log('Sensitive files changed:', summary.criticalFilesTouched); +} +``` + +### 6. GitHub Organization Allowlist (#1042) + +Enforces organization-level access policies with case insensitivity, wildcard matching, and cached author membership validation: + +```typescript +import { OrganizationAllowlist, GitHubClient } from '@/integrations/github'; + +const allowlist = new OrganizationAllowlist({ + allowedOrganizations: ['MDTechLabs', 'trusted-partner-*'], + allowWildcards: true, + strictCase: false, + requireVerifiedMembers: true, +}, client); + +// Validate repository +const repoCheck = allowlist.validateRepository('MDTechLabs/GasGuard'); +if (!repoCheck.allowed) { + throw new Error(`Repository rejected: ${repoCheck.reason}`); +} + +// Validate PR author membership +const authorCheck = await allowlist.validateAuthor('MDTechLabs', 'contributor'); +``` + +### 7. GitHub Repository Installation Validation (#1041) + +Validates that a GitHub App is actively installed on target repositories with sufficient permissions: + +```typescript +import { InstallationValidator, GitHubClient } from '@/integrations/github'; + +const validator = new InstallationValidator(client, repository); +const result = await validator.validateRepositoryInstallation('MDTechLabs', 'GasGuard', { + requiredPermissions: { + pull_requests: 'read', + contents: 'read', + }, +}); + +if (!result.valid) { + console.error('Installation invalid:', result.errors); + console.log('Remediation:', result.details); +} +``` diff --git a/src/integrations/github/__tests__/installation-validator.spec.ts b/src/integrations/github/__tests__/installation-validator.spec.ts new file mode 100644 index 00000000..e7fd692c --- /dev/null +++ b/src/integrations/github/__tests__/installation-validator.spec.ts @@ -0,0 +1,135 @@ +/** + * Unit tests for GitHub App Repository Installation Validation (#1041) + */ + +import { InstallationValidator } from '../installation-validator'; +import { GitHubClient } from '../github-client'; +import { GitHubRepository } from '../types'; + +describe('InstallationValidator', () => { + let validator: InstallationValidator; + let mockClient: jest.Mocked; + let repository: GitHubRepository; + + beforeEach(() => { + repository = { + owner: 'MDTechLabs', + repo: 'GasGuard', + isPrivate: false, + defaultBranch: 'main', + }; + + mockClient = { + getRepositoryInstallation: jest.fn(), + getAppInstallation: jest.fn(), + } as any; + + validator = new InstallationValidator(mockClient, repository); + }); + + describe('validateRepositoryInstallation', () => { + it('should pass validation when installation is active with required permissions', async () => { + mockClient.getRepositoryInstallation.mockResolvedValueOnce({ + id: 12345, + target_type: 'Organization', + target_id: 6789, + repository_selection: 'all', + permissions: { + pull_requests: 'write', + contents: 'read', + checks: 'write', + }, + suspended_at: null, + }); + + const result = await validator.validateRepositoryInstallation(undefined, undefined, { + requiredPermissions: { + pull_requests: 'read', + contents: 'read', + }, + }); + + expect(result.valid).toBe(true); + expect(result.status).toBe('active'); + expect(result.installationId).toBe(12345); + expect(result.missingRequiredPermissions).toHaveLength(0); + expect(mockClient.getRepositoryInstallation).toHaveBeenCalledWith('MDTechLabs', 'GasGuard'); + }); + + it('should fail validation when installation is suspended', async () => { + mockClient.getRepositoryInstallation.mockResolvedValueOnce({ + id: 9999, + target_type: 'Organization', + permissions: { pull_requests: 'read' }, + suspended_at: '2026-10-01T12:00:00Z', + suspended_by: { login: 'security-admin' }, + }); + + const result = await validator.validateRepositoryInstallation(); + + expect(result.valid).toBe(false); + expect(result.status).toBe('suspended'); + expect(result.suspendedBy).toBe('security-admin'); + expect(result.suspendedAt).toEqual(new Date('2026-10-01T12:00:00Z')); + expect(result.errors[0]).toContain('Installation is suspended'); + }); + + it('should detect missing required permissions and check permission hierarchy', async () => { + mockClient.getRepositoryInstallation.mockResolvedValueOnce({ + id: 1111, + target_type: 'Organization', + permissions: { + pull_requests: 'read', // only read, but write required + contents: 'admin', // admin satisfies write + }, + }); + + const result = await validator.validateRepositoryInstallation('MDTechLabs', 'GasGuard', { + requiredPermissions: { + pull_requests: 'write', + contents: 'write', + checks: 'read', // missing entirely + }, + }); + + expect(result.valid).toBe(false); + expect(result.missingRequiredPermissions).toEqual([ + 'pull_requests:write (current: read)', + 'checks:read (current: none)', + ]); + }); + + it('should handle 404 when app is not installed', async () => { + const error: any = new Error('Not Found'); + error.isAxiosError = true; + error.response = { status: 404 }; + mockClient.getRepositoryInstallation.mockRejectedValueOnce(error); + + const result = await validator.validateRepositoryInstallation('MDTechLabs', 'UninstalledRepo'); + + expect(result.valid).toBe(false); + expect(result.status).toBe('not_installed'); + expect(result.details).toContain('Please install the GitHub App'); + }); + }); + + describe('validateAppInstallation', () => { + it('should validate directly by installation ID', async () => { + mockClient.getAppInstallation.mockResolvedValueOnce({ + id: 54321, + target_type: 'Organization', + target_id: 101, + repository_selection: 'selected', + permissions: { issues: 'write' }, + suspended_at: null, + }); + + const result = await validator.validateAppInstallation(54321); + + expect(result.valid).toBe(true); + expect(result.installationId).toBe(54321); + expect(result.repositorySelection).toBe('selected'); + expect(mockClient.getAppInstallation).toHaveBeenCalledWith(54321); + }); + }); +}); diff --git a/src/integrations/github/__tests__/organization-allowlist.spec.ts b/src/integrations/github/__tests__/organization-allowlist.spec.ts new file mode 100644 index 00000000..ba3320a8 --- /dev/null +++ b/src/integrations/github/__tests__/organization-allowlist.spec.ts @@ -0,0 +1,165 @@ +/** + * Unit tests for GitHub Organization Allowlist (#1042) + */ + +import { OrganizationAllowlist } from '../organization-allowlist'; +import { GitHubClient } from '../github-client'; + +describe('OrganizationAllowlist', () => { + let mockClient: jest.Mocked; + + beforeEach(() => { + mockClient = { + isOrgMember: jest.fn(), + } as any; + }); + + describe('isAllowed', () => { + it('should allow exact organization names (case-insensitive by default)', () => { + const allowlist = new OrganizationAllowlist({ + allowedOrganizations: ['MDTechLabs', 'OpenZeppelin'], + }); + + expect(allowlist.isAllowed('MDTechLabs')).toBe(true); + expect(allowlist.isAllowed('mdtechlabs')).toBe(true); + expect(allowlist.isAllowed('OpenZeppelin')).toBe(true); + expect(allowlist.isAllowed('UntrustedOrg')).toBe(false); + }); + + it('should respect strictCase setting when enabled', () => { + const allowlist = new OrganizationAllowlist({ + allowedOrganizations: ['MDTechLabs'], + strictCase: true, + }); + + expect(allowlist.isAllowed('MDTechLabs')).toBe(true); + expect(allowlist.isAllowed('mdtechlabs')).toBe(false); + }); + + it('should support wildcard patterns', () => { + const allowlist = new OrganizationAllowlist({ + allowedOrganizations: ['MDTechLabs-*', 'corp-*-prod'], + allowWildcards: true, + }); + + expect(allowlist.isAllowed('MDTechLabs-Security')).toBe(true); + expect(allowlist.isAllowed('mdtechlabs-core')).toBe(true); + expect(allowlist.isAllowed('corp-infra-prod')).toBe(true); + expect(allowlist.isAllowed('OtherOrg')).toBe(false); + }); + + it('should return false for empty or missing organization', () => { + const allowlist = new OrganizationAllowlist({ + allowedOrganizations: ['MDTechLabs'], + }); + + expect(allowlist.isAllowed('')).toBe(false); + }); + }); + + describe('validateRepository', () => { + it('should validate string repository format', () => { + const allowlist = new OrganizationAllowlist({ + allowedOrganizations: ['MDTechLabs'], + }); + + const validResult = allowlist.validateRepository('MDTechLabs/GasGuard'); + expect(validResult.allowed).toBe(true); + expect(validResult.organization).toBe('MDTechLabs'); + + const invalidResult = allowlist.validateRepository('MaliciousOrg/BadRepo'); + expect(invalidResult.allowed).toBe(false); + expect(invalidResult.organization).toBe('MaliciousOrg'); + }); + + it('should validate GitHubRepository object', () => { + const allowlist = new OrganizationAllowlist({ + allowedOrganizations: ['MDTechLabs'], + }); + + const result = allowlist.validateRepository({ + owner: 'MDTechLabs', + repo: 'GasGuard', + isPrivate: false, + defaultBranch: 'main', + }); + + expect(result.allowed).toBe(true); + expect(result.matchedPattern).toBe('MDTechLabs'); + }); + }); + + describe('validateAuthor with membership check', () => { + it('should verify author membership via GitHub API and cache results', async () => { + mockClient.isOrgMember.mockResolvedValueOnce(true); + + const allowlist = new OrganizationAllowlist( + { + allowedOrganizations: ['MDTechLabs'], + requireVerifiedMembers: true, + cacheTtlSeconds: 60, + }, + mockClient + ); + + const result1 = await allowlist.validateAuthor('MDTechLabs', 'alice'); + expect(result1.allowed).toBe(true); + expect(result1.isMember).toBe(true); + expect(mockClient.isOrgMember).toHaveBeenCalledTimes(1); + + // Second call should hit the cache + const result2 = await allowlist.validateAuthor('MDTechLabs', 'alice'); + expect(result2.allowed).toBe(true); + expect(result2.isMember).toBe(true); + expect(mockClient.isOrgMember).toHaveBeenCalledTimes(1); // not called again + }); + + it('should deny author if organization is not allowed', async () => { + const allowlist = new OrganizationAllowlist( + { + allowedOrganizations: ['MDTechLabs'], + requireVerifiedMembers: true, + }, + mockClient + ); + + const result = await allowlist.validateAuthor('UnknownOrg', 'bob'); + expect(result.allowed).toBe(false); + expect(mockClient.isOrgMember).not.toHaveBeenCalled(); + }); + + it('should deny author if user is not a member of the organization', async () => { + mockClient.isOrgMember.mockResolvedValueOnce(false); + + const allowlist = new OrganizationAllowlist( + { + allowedOrganizations: ['MDTechLabs'], + requireVerifiedMembers: true, + }, + mockClient + ); + + const result = await allowlist.validateAuthor('MDTechLabs', 'external-user'); + expect(result.allowed).toBe(false); + expect(result.isMember).toBe(false); + }); + }); + + describe('dynamic allowlist management', () => { + it('should allow adding and removing organizations dynamically', () => { + const allowlist = new OrganizationAllowlist({ + allowedOrganizations: ['OrgA'], + }); + + expect(allowlist.isAllowed('OrgB')).toBe(false); + + allowlist.addAllowedOrganization('OrgB'); + expect(allowlist.isAllowed('OrgB')).toBe(true); + expect(allowlist.getAllowedOrganizations()).toEqual(['OrgA', 'OrgB']); + + allowlist.removeAllowedOrganization('OrgA'); + expect(allowlist.isAllowed('OrgA')).toBe(false); + expect(allowlist.getAllowedOrganizations()).toEqual(['OrgB']); + }); + }); +}); diff --git a/src/integrations/github/__tests__/pull-request-discovery.spec.ts b/src/integrations/github/__tests__/pull-request-discovery.spec.ts new file mode 100644 index 00000000..07b80d0d --- /dev/null +++ b/src/integrations/github/__tests__/pull-request-discovery.spec.ts @@ -0,0 +1,229 @@ +/** + * Unit tests for Pull Request File Discovery (#1043) + */ + +import { PullRequestFileDiscovery } from '../pull-request-discovery'; +import { GitHubClient } from '../github-client'; +import { GitHubRepository } from '../types'; + +describe('PullRequestFileDiscovery', () => { + let discovery: PullRequestFileDiscovery; + let mockClient: jest.Mocked; + let repository: GitHubRepository; + + beforeEach(() => { + repository = { + owner: 'MDTechLabs', + repo: 'GasGuard', + isPrivate: false, + defaultBranch: 'main', + }; + + mockClient = { + getPullRequestFiles: jest.fn(), + } as any; + + discovery = new PullRequestFileDiscovery(mockClient, repository); + }); + + describe('getChangedFiles', () => { + it('should fetch and map pull request changed files', async () => { + mockClient.getPullRequestFiles.mockResolvedValueOnce([ + { + sha: 'sha1', + filename: 'contracts/GasGuard.sol', + status: 'modified', + additions: 15, + deletions: 5, + changes: 20, + blob_url: 'https://github.com/blob/1', + raw_url: 'https://github.com/raw/1', + }, + { + sha: 'sha2', + filename: 'test/GasGuard.t.sol', + status: 'added', + additions: 45, + deletions: 0, + changes: 45, + }, + ]); + + const files = await discovery.getChangedFiles(123); + + expect(files).toHaveLength(2); + expect(files[0].filename).toBe('contracts/GasGuard.sol'); + expect(files[0].status).toBe('modified'); + expect(files[0].additions).toBe(15); + expect(files[1].filename).toBe('test/GasGuard.t.sol'); + expect(files[1].status).toBe('added'); + expect(mockClient.getPullRequestFiles).toHaveBeenCalledWith( + 'MDTechLabs', + 'GasGuard', + 123, + 1, + 100 + ); + }); + + it('should paginate when a PR has more than 100 files', async () => { + const page1 = Array.from({ length: 100 }, (_, i) => ({ + sha: `sha-${i}`, + filename: `contracts/Token_${i}.sol`, + status: 'modified', + additions: 1, + deletions: 0, + changes: 1, + })); + const page2 = [ + { + sha: 'sha-101', + filename: 'contracts/Token_101.sol', + status: 'added', + additions: 10, + deletions: 0, + changes: 10, + }, + ]; + + mockClient.getPullRequestFiles + .mockResolvedValueOnce(page1) + .mockResolvedValueOnce(page2); + + const files = await discovery.getChangedFiles(456, { maxFiles: 150 }); + + expect(files).toHaveLength(101); + expect(mockClient.getPullRequestFiles).toHaveBeenCalledTimes(2); + expect(mockClient.getPullRequestFiles).toHaveBeenNthCalledWith(1, 'MDTechLabs', 'GasGuard', 456, 1, 100); + expect(mockClient.getPullRequestFiles).toHaveBeenNthCalledWith(2, 'MDTechLabs', 'GasGuard', 456, 2, 100); + }); + + it('should filter files by specified file extensions', async () => { + mockClient.getPullRequestFiles.mockResolvedValueOnce([ + { sha: '1', filename: 'contracts/Token.sol', status: 'modified' }, + { sha: '2', filename: 'README.md', status: 'modified' }, + { sha: '3', filename: 'src/lib.rs', status: 'added' }, + { sha: '4', filename: 'docs/guide.pdf', status: 'added' }, + ]); + + const files = await discovery.getChangedFiles(1, { fileExtensions: ['.sol', '.rs'] }); + + expect(files).toHaveLength(2); + expect(files.map((f) => f.filename)).toEqual(['contracts/Token.sol', 'src/lib.rs']); + }); + + it('should filter out files matching exclude patterns', async () => { + mockClient.getPullRequestFiles.mockResolvedValueOnce([ + { sha: '1', filename: 'contracts/Token.sol', status: 'modified' }, + { sha: '2', filename: 'vendor/dependency.sol', status: 'modified' }, + { sha: '3', filename: 'node_modules/foo/index.js', status: 'added' }, + ]); + + const files = await discovery.getChangedFiles(1, { + excludePatterns: ['vendor/', 'node_modules/'], + }); + + expect(files).toHaveLength(1); + expect(files[0].filename).toBe('contracts/Token.sol'); + }); + }); + + describe('getChangeSummary', () => { + it('should calculate metrics and detect security-sensitive files', async () => { + mockClient.getPullRequestFiles.mockResolvedValueOnce([ + { + sha: '1', + filename: 'contracts/Vault.sol', + status: 'modified', + additions: 20, + deletions: 5, + changes: 25, + }, + { + sha: '2', + filename: '.github/workflows/ci.yml', + status: 'modified', + additions: 10, + deletions: 2, + changes: 12, + }, + { + sha: '3', + filename: 'package.json', + status: 'modified', + additions: 2, + deletions: 1, + changes: 3, + }, + { + sha: '4', + filename: 'docs/notes.md', + status: 'added', + additions: 50, + deletions: 0, + changes: 50, + }, + ]); + + const summary = await discovery.getChangeSummary(99); + + expect(summary.totalFiles).toBe(4); + expect(summary.additions).toBe(82); + expect(summary.deletions).toBe(8); + expect(summary.changes).toBe(90); + expect(summary.filesByStatus).toEqual({ modified: 3, added: 1 }); + expect(summary.affectedFileTypes).toEqual({ + '.sol': 1, + '.yml': 1, + '.json': 1, + '.md': 1, + }); + expect(summary.hasSecurityOrConfigFileChanges).toBe(true); + expect(summary.criticalFilesTouched).toEqual([ + '.github/workflows/ci.yml', + 'package.json', + ]); + }); + }); + + describe('getContractFiles', () => { + it('should return only smart contract files', async () => { + mockClient.getPullRequestFiles.mockResolvedValueOnce([ + { sha: '1', filename: 'contracts/Vault.sol', status: 'modified' }, + { sha: '2', filename: 'contracts/token.cairo', status: 'added' }, + { sha: '3', filename: 'contracts/escrow.vy', status: 'modified' }, + { sha: '4', filename: 'scripts/deploy.js', status: 'added' }, + ]); + + const contractFiles = await discovery.getContractFiles(77); + + expect(contractFiles).toHaveLength(3); + expect(contractFiles.map((c) => c.filename)).toEqual([ + 'contracts/Vault.sol', + 'contracts/token.cairo', + 'contracts/escrow.vy', + ]); + }); + }); + + describe('hasSecuritySensitiveChanges', () => { + it('should return true if workflow or config file is changed', async () => { + mockClient.getPullRequestFiles.mockResolvedValueOnce([ + { sha: '1', filename: '.github/workflows/deploy.yml', status: 'modified' }, + ]); + + const hasSensitive = await discovery.hasSecuritySensitiveChanges(12); + expect(hasSensitive).toBe(true); + }); + + it('should return false if only non-critical files are changed', async () => { + mockClient.getPullRequestFiles.mockResolvedValueOnce([ + { sha: '1', filename: 'src/utils/math.ts', status: 'modified' }, + { sha: '2', filename: 'README.md', status: 'modified' }, + ]); + + const hasSensitive = await discovery.hasSecuritySensitiveChanges(12); + expect(hasSensitive).toBe(false); + }); + }); +}); diff --git a/src/integrations/github/github-client.ts b/src/integrations/github/github-client.ts index 80c7ebeb..d5201450 100644 --- a/src/integrations/github/github-client.ts +++ b/src/integrations/github/github-client.ts @@ -216,4 +216,61 @@ export class GitHubClient { reset: new Date(rateData.reset * 1000), }; } + + /** + * Get pull request changed files (#1043) + */ + async getPullRequestFiles( + owner: string, + repo: string, + pullNumber: number, + page = 1, + perPage = 100 + ): Promise { + const response = await this.client.get( + `/repos/${owner}/${repo}/pulls/${pullNumber}/files`, + { params: { page, per_page: perPage } } + ); + return response.data; + } + + /** + * Check if a user is a member of an organization (#1042) + */ + async isOrgMember(org: string, username: string): Promise { + try { + const response = await this.client.get(`/orgs/${org}/members/${username}`); + return response.status === 204 || response.status === 200; + } catch (error) { + if (axios.isAxiosError(error) && (error.response?.status === 404 || error.response?.status === 302)) { + return false; + } + throw error; + } + } + + /** + * Get organization details (#1042) + */ + async getOrganization(org: string): Promise { + const response = await this.client.get(`/orgs/${org}`); + return response.data; + } + + /** + * Get repository installation details (#1041) + */ + async getRepositoryInstallation(owner: string, repo: string): Promise { + const response = await this.client.get(`/repos/${owner}/${repo}/installation`); + return response.data; + } + + /** + * Get App installation details (#1041) + */ + async getAppInstallation(installationId: number | string): Promise { + const response = await this.client.get(`/app/installations/${installationId}`); + return response.data; + } + } diff --git a/src/integrations/github/index.ts b/src/integrations/github/index.ts index 2cd8850c..f3d09fd6 100644 --- a/src/integrations/github/index.ts +++ b/src/integrations/github/index.ts @@ -6,6 +6,9 @@ * - Private repository access diagnostics (#1048) * - Branch protection awareness (#1044) * - Repository language detection (#1046) + * - Pull request changed-file discovery (#1043) + * - GitHub organization allowlists (#1042) + * - GitHub repository installation validation (#1041) */ export * from './types'; @@ -14,30 +17,55 @@ export * from './monorepo-boundaries'; export * from './access-diagnostics'; export * from './branch-protection'; export * from './language-detection'; +export * from './pull-request-discovery'; +export * from './organization-allowlist'; +export * from './installation-validator'; import { GitHubClient } from './github-client'; import { MonorepoBoundaryDetector } from './monorepo-boundaries'; import { AccessDiagnostics } from './access-diagnostics'; import { BranchProtectionAwareness } from './branch-protection'; import { LanguageDetector } from './language-detection'; -import { GitHubAuthConfig, GitHubRepository, GitHubIntegrationConfig } from './types'; +import { PullRequestFileDiscovery } from './pull-request-discovery'; +import { OrganizationAllowlist } from './organization-allowlist'; +import { InstallationValidator } from './installation-validator'; +import { + GitHubRepository, + GitHubIntegrationConfig, + OrganizationAllowlistConfig, +} from './types'; + +export interface ExtendedGitHubIntegrationConfig extends GitHubIntegrationConfig { + organizationAllowlist?: OrganizationAllowlistConfig; +} /** * Main GitHub Integration class that combines all features */ export class GitHubIntegration { + private repository: GitHubRepository; private client: GitHubClient; private monorepoDetector: MonorepoBoundaryDetector; private accessDiagnostics: AccessDiagnostics; private branchProtection: BranchProtectionAwareness; private languageDetector: LanguageDetector; + private prDiscovery: PullRequestFileDiscovery; + private allowlist?: OrganizationAllowlist; + private installationValidator: InstallationValidator; - constructor(config: GitHubIntegrationConfig) { + constructor(config: ExtendedGitHubIntegrationConfig) { + this.repository = config.repository; this.client = new GitHubClient(config.auth); this.monorepoDetector = new MonorepoBoundaryDetector(this.client); this.accessDiagnostics = new AccessDiagnostics(this.client, config.repository); this.branchProtection = new BranchProtectionAwareness(this.client, config.repository); this.languageDetector = new LanguageDetector(this.client, config.repository); + this.prDiscovery = new PullRequestFileDiscovery(this.client, config.repository); + this.installationValidator = new InstallationValidator(this.client, config.repository); + + if (config.organizationAllowlist) { + this.allowlist = new OrganizationAllowlist(config.organizationAllowlist, this.client); + } } /** @@ -75,15 +103,25 @@ export class GitHubIntegration { return this.languageDetector; } - private repository: GitHubRepository; + /** + * Get pull request file discovery (#1043) + */ + getPullRequestDiscovery(): PullRequestFileDiscovery { + return this.prDiscovery; + } - constructor(config: GitHubIntegrationConfig) { - this.repository = config.repository; - this.client = new GitHubClient(config.auth); - this.monorepoDetector = new MonorepoBoundaryDetector(this.client); - this.accessDiagnostics = new AccessDiagnostics(this.client, config.repository); - this.branchProtection = new BranchProtectionAwareness(this.client, config.repository); - this.languageDetector = new LanguageDetector(this.client, config.repository); + /** + * Get organization allowlist (#1042) + */ + getOrganizationAllowlist(): OrganizationAllowlist | undefined { + return this.allowlist; + } + + /** + * Get repository installation validator (#1041) + */ + getInstallationValidator(): InstallationValidator { + return this.installationValidator; } /** @@ -94,12 +132,14 @@ export class GitHubIntegration { languages: any; branchProtection: any; access: any; + installation: any; }> { const results = { monorepo: false, languages: null, branchProtection: null, access: null, + installation: null, }; try { @@ -125,6 +165,11 @@ export class GitHubIntegration { if (this.accessDiagnostics) { results.access = await this.accessDiagnostics.runDiagnostics(); } + + // Validate installation + if (this.installationValidator) { + results.installation = await this.installationValidator.validateRepositoryInstallation(); + } } catch (error) { console.error('Repository analysis failed:', error); } diff --git a/src/integrations/github/installation-validator.ts b/src/integrations/github/installation-validator.ts new file mode 100644 index 00000000..383240ba --- /dev/null +++ b/src/integrations/github/installation-validator.ts @@ -0,0 +1,179 @@ +/** + * GitHub App Repository Installation Validation (#1041) + * + * Validates GitHub App installation status, repository selection scope, + * and granted repository permissions with permission hierarchy checking + * and actionable remediation diagnostics. + */ + +import axios from 'axios'; +import { GitHubClient } from './github-client'; +import { + GitHubRepository, + InstallationValidationResult, + InstallationValidationOptions, +} from './types'; + +export class InstallationValidator { + private client: GitHubClient; + private defaultRepository?: GitHubRepository; + + // Permission levels for comparison + private static readonly PERMISSION_HIERARCHY: Record = { + none: 0, + read: 1, + write: 2, + admin: 3, + }; + + constructor(client: GitHubClient, defaultRepository?: GitHubRepository) { + this.client = client; + this.defaultRepository = defaultRepository; + } + + /** + * Validate that the GitHub App is correctly installed on a repository with required permissions + */ + async validateRepositoryInstallation( + owner?: string, + repo?: string, + options: InstallationValidationOptions = {} + ): Promise { + const targetOwner = owner ?? this.defaultRepository?.owner; + const targetRepo = repo ?? this.defaultRepository?.repo; + + if (!targetOwner || !targetRepo) { + return { + valid: false, + targetType: 'Unknown', + repositorySelection: 'selected', + permissions: {}, + missingRequiredPermissions: [], + status: 'not_installed', + errors: ['Target owner and repo must be provided or configured in repository context.'], + }; + } + + try { + const data = await this.client.getRepositoryInstallation(targetOwner, targetRepo); + return this.evaluateInstallationData(data, options); + } catch (error) { + if (axios.isAxiosError(error)) { + if (error.response?.status === 404) { + return { + valid: false, + targetType: 'Unknown', + repositorySelection: 'selected', + permissions: {}, + missingRequiredPermissions: options.requiredPermissions + ? Object.keys(options.requiredPermissions) + : [], + status: 'not_installed', + errors: [`GitHub App is not installed on repository '${targetOwner}/${targetRepo}'.`], + details: 'Please install the GitHub App from your organization or repository settings.', + }; + } + + if (error.response?.status === 401 || error.response?.status === 403) { + return { + valid: false, + targetType: 'Unknown', + repositorySelection: 'selected', + permissions: {}, + missingRequiredPermissions: [], + status: 'unauthorized', + errors: [`Insufficient credentials to query installation on '${targetOwner}/${targetRepo}'.`], + }; + } + } + + return { + valid: false, + targetType: 'Unknown', + repositorySelection: 'selected', + permissions: {}, + missingRequiredPermissions: [], + status: 'not_installed', + errors: [`Failed to validate installation on '${targetOwner}/${targetRepo}': ${error}`], + }; + } + } + + /** + * Validate an installation by its installation ID directly + */ + async validateAppInstallation( + installationId: number | string, + options: InstallationValidationOptions = {} + ): Promise { + try { + const data = await this.client.getAppInstallation(installationId); + return this.evaluateInstallationData(data, options); + } catch (error) { + return { + valid: false, + installationId: typeof installationId === 'number' ? installationId : parseInt(installationId, 10), + targetType: 'Unknown', + repositorySelection: 'selected', + permissions: {}, + missingRequiredPermissions: [], + status: 'not_installed', + errors: [`Failed to query installation ID ${installationId}: ${error}`], + }; + } + } + + /** + * Evaluate raw GitHub installation payload against options and permission requirements + */ + private evaluateInstallationData( + data: any, + options: InstallationValidationOptions + ): InstallationValidationResult { + const isSuspended = Boolean(data.suspended_at); + const status = isSuspended ? 'suspended' : 'active'; + const permissions: Record = data.permissions ?? {}; + const errors: string[] = []; + + if (isSuspended) { + errors.push(`Installation is suspended (by: ${data.suspended_by?.login ?? 'administrator'}).`); + } + + // Check required permissions + const missingPermissions: string[] = []; + if (options.requiredPermissions) { + for (const [perm, requiredLevel] of Object.entries(options.requiredPermissions)) { + const actualLevel = permissions[perm] ?? 'none'; + const actualRank = InstallationValidator.PERMISSION_HIERARCHY[actualLevel] ?? 0; + const requiredRank = InstallationValidator.PERMISSION_HIERARCHY[requiredLevel] ?? 0; + + if (actualRank < requiredRank) { + missingPermissions.push(`${perm}:${requiredLevel} (current: ${actualLevel})`); + } + } + } + + if (missingPermissions.length > 0) { + errors.push(`Missing required installation permissions: ${missingPermissions.join(', ')}`); + } + + const isValid = !isSuspended && missingPermissions.length === 0; + + return { + valid: isValid, + installationId: data.id, + targetType: data.target_type ?? 'Unknown', + targetId: data.target_id, + repositorySelection: data.repository_selection ?? 'selected', + permissions, + missingRequiredPermissions: missingPermissions, + status, + suspendedAt: data.suspended_at ? new Date(data.suspended_at) : null, + suspendedBy: data.suspended_by?.login ?? null, + errors, + details: isValid + ? `Installation #${data.id} is active with all required permissions.` + : `Installation #${data.id} has validation issues: ${errors.join('; ')}`, + }; + } +} diff --git a/src/integrations/github/organization-allowlist.ts b/src/integrations/github/organization-allowlist.ts new file mode 100644 index 00000000..be0dc2b9 --- /dev/null +++ b/src/integrations/github/organization-allowlist.ts @@ -0,0 +1,197 @@ +/** + * GitHub Organization Allowlist (#1042) + * + * Enforces organization-level access policies, supporting exact matching, + * wildcard/glob patterns, case insensitivity, cached author membership verification, + * and comprehensive audit logging. + */ + +import { GitHubClient } from './github-client'; +import { + GitHubRepository, + OrganizationAllowlistConfig, + OrganizationValidationResult, +} from './types'; + +export class OrganizationAllowlist { + private config: OrganizationAllowlistConfig; + private client?: GitHubClient; + private memberCache: Map; + + constructor(config: OrganizationAllowlistConfig, client?: GitHubClient) { + this.config = { + allowedOrganizations: [...config.allowedOrganizations], + allowWildcards: config.allowWildcards ?? true, + strictCase: config.strictCase ?? false, + cacheTtlSeconds: config.cacheTtlSeconds ?? 300, + requireVerifiedMembers: config.requireVerifiedMembers ?? false, + }; + this.client = client; + this.memberCache = new Map(); + } + + /** + * Check if an organization name matches the allowlist + */ + isAllowed(organization: string): boolean { + if (!organization || this.config.allowedOrganizations.length === 0) { + return false; + } + + const orgToTest = this.config.strictCase ? organization : organization.toLowerCase(); + + for (const pattern of this.config.allowedOrganizations) { + const targetPattern = this.config.strictCase ? pattern : pattern.toLowerCase(); + + if (this.config.allowWildcards && targetPattern.includes('*')) { + const regex = new RegExp(`^${targetPattern.replace(/[-[\]{}()+?.,\\^$|#\s]/g, '\\$&').replace(/\*/g, '.*')}$`); + if (regex.test(orgToTest)) { + return true; + } + } else if (orgToTest === targetPattern) { + return true; + } + } + + return false; + } + + /** + * Validate a repository against the organization allowlist + */ + validateRepository(repository: GitHubRepository | string): OrganizationValidationResult { + const owner = typeof repository === 'string' + ? repository.split('/')[0] + : repository.owner; + + if (!owner) { + return { + allowed: false, + organization: '', + reason: 'Missing repository owner/organization', + timestamp: new Date(), + }; + } + + const allowed = this.isAllowed(owner); + let matchedPattern: string | undefined; + + if (allowed) { + matchedPattern = this.findMatchingPattern(owner); + } + + return { + allowed, + organization: owner, + matchedPattern, + reason: allowed + ? `Organization '${owner}' matches allowlist rule '${matchedPattern}'` + : `Organization '${owner}' is not present in the allowlist`, + timestamp: new Date(), + }; + } + + /** + * Validate pull request author against organization membership + */ + async validateAuthor(organization: string, username: string): Promise { + const orgResult = this.validateRepository({ owner: organization, repo: '', isPrivate: false, defaultBranch: 'main' }); + + if (!orgResult.allowed) { + return orgResult; + } + + if (!this.config.requireVerifiedMembers || !this.client) { + return orgResult; + } + + // Check membership cache + const cacheKey = `${organization}:${username}`.toLowerCase(); + const cached = this.memberCache.get(cacheKey); + const now = Date.now(); + + if (cached && cached.expiresAt > now) { + return { + ...orgResult, + allowed: cached.isMember, + isMember: cached.isMember, + reason: cached.isMember + ? `User '${username}' is a verified member of '${organization}' (cached)` + : `User '${username}' is not a verified member of '${organization}' (cached)`, + }; + } + + try { + const isMember = await this.client.isOrgMember(organization, username); + const ttlMs = (this.config.cacheTtlSeconds ?? 300) * 1000; + this.memberCache.set(cacheKey, { isMember, expiresAt: now + ttlMs }); + + return { + ...orgResult, + allowed: isMember, + isMember, + reason: isMember + ? `User '${username}' is a verified member of '${organization}'` + : `User '${username}' is not a verified member of '${organization}'`, + }; + } catch (error) { + return { + ...orgResult, + allowed: false, + isMember: false, + reason: `Failed to verify membership for '${username}' in '${organization}': ${error}`, + }; + } + } + + /** + * Add an organization or wildcard pattern to the allowlist + */ + addAllowedOrganization(organization: string): void { + if (!this.config.allowedOrganizations.includes(organization)) { + this.config.allowedOrganizations.push(organization); + } + } + + /** + * Remove an organization from the allowlist + */ + removeAllowedOrganization(organization: string): void { + this.config.allowedOrganizations = this.config.allowedOrganizations.filter( + (org) => org !== organization + ); + } + + /** + * Get all currently allowed organizations/patterns + */ + getAllowedOrganizations(): string[] { + return [...this.config.allowedOrganizations]; + } + + /** + * Clear the membership cache + */ + clearCache(): void { + this.memberCache.clear(); + } + + /** + * Find which pattern matched the organization + */ + private findMatchingPattern(organization: string): string | undefined { + const orgToTest = this.config.strictCase ? organization : organization.toLowerCase(); + + for (const pattern of this.config.allowedOrganizations) { + const targetPattern = this.config.strictCase ? pattern : pattern.toLowerCase(); + if (this.config.allowWildcards && targetPattern.includes('*')) { + const regex = new RegExp(`^${targetPattern.replace(/[-[\]{}()+?.,\\^$|#\s]/g, '\\$&').replace(/\*/g, '.*')}$`); + if (regex.test(orgToTest)) return pattern; + } else if (orgToTest === targetPattern) { + return pattern; + } + } + + return undefined; + } +} diff --git a/src/integrations/github/pull-request-discovery.ts b/src/integrations/github/pull-request-discovery.ts new file mode 100644 index 00000000..77209553 --- /dev/null +++ b/src/integrations/github/pull-request-discovery.ts @@ -0,0 +1,213 @@ +/** + * Pull Request File Discovery (#1043) + * + * Provides automated discovery and analysis of changed files in GitHub pull requests, + * including pagination handling, status categorization, contract file isolation, + * and security-sensitive change detection. + */ + +import { GitHubClient } from './github-client'; +import { + GitHubRepository, + PullRequestFile, + PullRequestChangeSummary, + PullRequestDiscoveryOptions, +} from './types'; + +export class PullRequestFileDiscovery { + private client: GitHubClient; + private repository: GitHubRepository; + + // Patterns that indicate configuration or security-sensitive components + private static readonly SECURITY_SENSITIVE_PATTERNS = [ + /^\.github\/workflows\//i, + /^\.github\/actions\//i, + /^contracts\/.*audit/i, + /^contracts\/.*access/i, + /^src\/security\//i, + /^rules\//i, + /\.env(\..+)?$/i, + /hardhat\.config\.(ts|js)$/i, + /foundry\.toml$/i, + /Cargo\.(toml|lock)$/i, + /package(-lock)?\.json$/i, + /pnpm-lock\.yaml$/i, + ]; + + // Smart contract file extensions + private static readonly SMART_CONTRACT_EXTENSIONS = [ + '.sol', + '.rs', + '.vy', + '.cairo', + '.fe', + ]; + + constructor(client: GitHubClient, repository: GitHubRepository) { + this.client = client; + this.repository = repository; + } + + /** + * Fetch all changed files for a pull request, handling pagination up to maxFiles + */ + async getChangedFiles( + pullNumber: number, + options: PullRequestDiscoveryOptions = {} + ): Promise { + const maxFiles = options.maxFiles ?? 300; + const perPage = Math.min(100, maxFiles); + let page = 1; + let allFiles: PullRequestFile[] = []; + let hasMore = true; + + while (hasMore && allFiles.length < maxFiles) { + const rawFiles = await this.client.getPullRequestFiles( + this.repository.owner, + this.repository.repo, + pullNumber, + page, + perPage + ); + + if (!rawFiles || rawFiles.length === 0) { + break; + } + + for (const item of rawFiles) { + if (allFiles.length >= maxFiles) break; + + const file: PullRequestFile = { + sha: item.sha, + filename: item.filename, + status: item.status, + additions: item.additions ?? 0, + deletions: item.deletions ?? 0, + changes: item.changes ?? (item.additions ?? 0) + (item.deletions ?? 0), + blobUrl: item.blob_url, + rawUrl: item.raw_url, + contentsUrl: item.contents_url, + patch: options.includePatch ? item.patch : undefined, + previousFilename: item.previous_filename, + }; + + if (this.matchesOptions(file.filename, options)) { + allFiles.push(file); + } + } + + if (rawFiles.length < perPage) { + hasMore = false; + } else { + page++; + } + } + + return allFiles; + } + + /** + * Get an aggregated change summary for a pull request + */ + async getChangeSummary( + pullNumber: number, + options: PullRequestDiscoveryOptions = {} + ): Promise { + const files = await this.getChangedFiles(pullNumber, options); + + let additions = 0; + let deletions = 0; + let changes = 0; + const filesByStatus: Record = {}; + const affectedFileTypes: Record = {}; + const criticalFilesTouched: string[] = []; + + for (const file of files) { + additions += file.additions; + deletions += file.deletions; + changes += file.changes; + + // Track status + filesByStatus[file.status] = (filesByStatus[file.status] ?? 0) + 1; + + // Track file extension + const ext = this.getFileExtension(file.filename); + affectedFileTypes[ext] = (affectedFileTypes[ext] ?? 0) + 1; + + // Check critical/security file + if (this.isSecurityOrConfigFile(file.filename)) { + criticalFilesTouched.push(file.filename); + } + } + + return { + totalFiles: files.length, + additions, + deletions, + changes, + files, + filesByStatus, + affectedFileTypes, + hasSecurityOrConfigFileChanges: criticalFilesTouched.length > 0, + criticalFilesTouched, + }; + } + + /** + * Get only smart contract files modified in the pull request + */ + async getContractFiles(pullNumber: number): Promise { + return this.getChangedFiles(pullNumber, { + fileExtensions: PullRequestFileDiscovery.SMART_CONTRACT_EXTENSIONS, + }); + } + + /** + * Check whether any security or workflow files are touched in the pull request + */ + async hasSecuritySensitiveChanges(pullNumber: number): Promise { + const files = await this.getChangedFiles(pullNumber, { maxFiles: 100 }); + return files.some((f) => this.isSecurityOrConfigFile(f.filename)); + } + + /** + * Match filename against filtering options + */ + private matchesOptions(filename: string, options: PullRequestDiscoveryOptions): boolean { + if (options.fileExtensions && options.fileExtensions.length > 0) { + const ext = this.getFileExtension(filename); + const matchesExt = options.fileExtensions.some( + (e) => e.toLowerCase() === ext.toLowerCase() || `.${e.toLowerCase().replace(/^\./, '')}` === ext.toLowerCase() + ); + if (!matchesExt) return false; + } + + if (options.excludePatterns && options.excludePatterns.length > 0) { + for (const pattern of options.excludePatterns) { + if (filename.includes(pattern) || filename.startsWith(pattern)) { + return false; + } + } + } + + return true; + } + + /** + * Determine file extension + */ + private getFileExtension(filename: string): string { + const lastDot = filename.lastIndexOf('.'); + if (lastDot === -1 || lastDot === 0) return 'none'; + return filename.slice(lastDot).toLowerCase(); + } + + /** + * Check if a filename matches known security or config patterns + */ + private isSecurityOrConfigFile(filename: string): boolean { + return PullRequestFileDiscovery.SECURITY_SENSITIVE_PATTERNS.some((pattern) => + pattern.test(filename) + ); + } +} diff --git a/src/integrations/github/types.ts b/src/integrations/github/types.ts index e9714b49..5dfb2074 100644 --- a/src/integrations/github/types.ts +++ b/src/integrations/github/types.ts @@ -72,3 +72,76 @@ export interface GitHubIntegrationConfig { enablePackageBoundaries: boolean; enableAccessDiagnostics: boolean; } + +// --- Pull Request File Discovery (#1043) --- +export interface PullRequestFile { + sha: string; + filename: string; + status: 'added' | 'removed' | 'modified' | 'renamed' | 'copied' | 'changed' | 'unchanged'; + additions: number; + deletions: number; + changes: number; + blobUrl?: string; + rawUrl?: string; + contentsUrl?: string; + patch?: string; + previousFilename?: string; +} + +export interface PullRequestChangeSummary { + totalFiles: number; + additions: number; + deletions: number; + changes: number; + files: PullRequestFile[]; + filesByStatus: Record; + affectedFileTypes: Record; + hasSecurityOrConfigFileChanges: boolean; + criticalFilesTouched: string[]; +} + +export interface PullRequestDiscoveryOptions { + maxFiles?: number; + fileExtensions?: string[]; + excludePatterns?: string[]; + includePatch?: boolean; +} + +// --- GitHub Organization Allowlist (#1042) --- +export interface OrganizationAllowlistConfig { + allowedOrganizations: string[]; + allowWildcards?: boolean; + strictCase?: boolean; + cacheTtlSeconds?: number; + requireVerifiedMembers?: boolean; +} + +export interface OrganizationValidationResult { + allowed: boolean; + organization: string; + matchedPattern?: string; + reason?: string; + timestamp: Date; + isMember?: boolean; +} + +// --- GitHub App Repository Installation Validation (#1041) --- +export interface InstallationValidationResult { + valid: boolean; + installationId?: number; + targetType: 'User' | 'Organization' | 'Unknown'; + targetId?: number; + repositorySelection: 'all' | 'selected'; + permissions: Record; + missingRequiredPermissions: string[]; + status: 'active' | 'suspended' | 'not_installed' | 'unauthorized'; + suspendedAt?: Date | null; + suspendedBy?: string | null; + errors: string[]; + details?: string; +} + +export interface InstallationValidationOptions { + requiredPermissions?: Record; + checkAccess?: boolean; +}