Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AppController } from "./app.controller";
import { ExampleController } from "./example/example.controller";
import { FailedTransactionController } from "./controllers/failed-transaction.controller";
import { CrossChainGasController } from "./controllers/cross-chain-gas.controller";
import { FindingsNestController } from "./findings/findings.nest-controller";
import { FailedTransactionService } from "./services/failed-transaction.service";
import { MitigationService } from "./services/mitigation.service";
import { TransactionAnalysisService } from "./services/transaction-analysis.service";
Expand Down Expand Up @@ -48,6 +49,7 @@ import { ScheduleModule } from '@nestjs/schedule';
ExampleController,
FailedTransactionController,
CrossChainGasController,
FindingsNestController,
// Add your controllers here - remember to add @Version('1') decorator
],
providers: [
Expand Down
29 changes: 29 additions & 0 deletions apps/api/src/findings/findings.nest-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* NestJS adapter for the findings API (Issue #1241 — cursor pagination).
*
* findings.controller.ts / findings.service.ts / cursor.ts (#992) already
* implement full keyset/cursor pagination with tenant isolation (#996); that
* logic was only reachable through apps/api/src/server.ts, an Express
* entrypoint the deployed container does not run (main.ts boots the
* NestJS AppModule — see apps/api/src/main.ts and the Dockerfile CMD).
* This controller registers the existing handlers on a real, guarded Nest
* route instead of duplicating their logic, so `GET /v1/findings` and
* `GET /v1/findings/:id` go through the same global guards (rate limiting,
* JWT auth, RBAC) as every other endpoint in this app.
*/
import { Controller, Get, Req, Res } from "@nestjs/common";
import { Request, Response } from "express";
import { findingsController } from "./findings.controller";

@Controller({ path: "findings", version: "1" })
export class FindingsNestController {
@Get()
list(@Req() req: Request, @Res() res: Response): void {
findingsController.list(req, res);
}

@Get(":id")
getOne(@Req() req: Request, @Res() res: Response): void {
findingsController.getOne(req, res);
}
}
10 changes: 10 additions & 0 deletions apps/api/src/rate-limiting/controllers/admin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ import {
UsageStats,
MAX_TRANSACTION_LIMITS,
} from "../schemas/rate-limit.schema";
import { Roles, Role } from "../../auth/decorators";

/**
* Per-API-key quota/usage/reset management (Issue #1240). These operate on
* another caller's token, not the current caller's own — without the admin
* role restriction below, any authenticated user could inflate their own
* limits or degrade another tenant's by targeting its API key here, which
* would defeat the per-token isolation this module exists to enforce.
*/

interface UpdateQuotaDto {
requestsPerMinute?: number;
Expand All @@ -47,6 +56,7 @@ interface ResetResponse {
}

@Controller("admin/api-keys")
@Roles(Role.ADMIN)
export class RateLimitAdminController {
private readonly logger = new Logger(RateLimitAdminController.name);

Expand Down
24 changes: 24 additions & 0 deletions src/integrations/github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ This module provides comprehensive GitHub repository integration features for Ga
- **Private Repository Access Diagnostics** (#1048) - Comprehensive diagnostics for accessing private GitHub repositories
- **Branch Protection Awareness** (#1044) - Understand and work with GitHub branch protection rules
- **Repository Language Detection** (#1046) - Detect and analyze programming languages used in repositories
- **GitHub App Permission Minimization Checks** (#1244) - Flag GitHub App installations granted more access than GasGuard actually uses

## Installation

Expand Down Expand Up @@ -480,6 +481,29 @@ if (!result.valid) {
}
```

### 8. GitHub App Permission Minimization Checks (#1244)

`InstallationValidator` (above) checks that an installation has *at least*
the permissions GasGuard needs. `PermissionMinimizationChecker` checks the
opposite — that it doesn't have *more* than GasGuard needs — against a
fixed baseline (`GASGUARD_BASELINE_PERMISSIONS`) kept in sync with what
`GitHubClient` actually calls. An installation can pass one check and fail
the other; they're independent.

```typescript
import { PermissionMinimizationChecker, GitHubClient } from '@/integrations/github';

const checker = new PermissionMinimizationChecker(client);
const result = await checker.checkRepositoryInstallation('MDTechLabs', 'GasGuard');

if (!result.isMinimized) {
console.warn('Over-privileged installation:', result.recommendations);
// result.excessPermissions: granted above the level GasGuard uses
// result.unusedPermissions: granted but never used at all
// result.broadRepositorySelection: installed on 'all' repos, not 'selected'
}
```

## GitHub Enterprise Server

Set `baseUrl` on the auth config to target a GHES instance. Both the host URL and
Expand Down
23 changes: 23 additions & 0 deletions src/integrations/github/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* - Pull request changed-file discovery (#1043)
* - GitHub organization allowlists (#1042)
* - GitHub repository installation validation (#1041)
* - GitHub App permission minimization checks (#1244)
*/

export * from './types';
Expand All @@ -20,6 +21,7 @@ export * from './language-detection';
export * from './pull-request-discovery';
export * from './organization-allowlist';
export * from './installation-validator';
export * from './permission-minimization';
export * from './enterprise-config';
export * from './token-expiration';

Expand All @@ -31,6 +33,7 @@ import { LanguageDetector } from './language-detection';
import { PullRequestFileDiscovery } from './pull-request-discovery';
import { OrganizationAllowlist } from './organization-allowlist';
import { InstallationValidator } from './installation-validator';
import { PermissionMinimizationChecker } from './permission-minimization';
import {
GitHubRepository,
GitHubIntegrationConfig,
Expand All @@ -54,6 +57,7 @@ export class GitHubIntegration {
private prDiscovery: PullRequestFileDiscovery;
private allowlist?: OrganizationAllowlist;
private installationValidator: InstallationValidator;
private permissionMinimizationChecker: PermissionMinimizationChecker;

constructor(config: ExtendedGitHubIntegrationConfig) {
this.repository = config.repository;
Expand All @@ -64,6 +68,7 @@ export class GitHubIntegration {
this.languageDetector = new LanguageDetector(this.client, config.repository);
this.prDiscovery = new PullRequestFileDiscovery(this.client, config.repository);
this.installationValidator = new InstallationValidator(this.client, config.repository);
this.permissionMinimizationChecker = new PermissionMinimizationChecker(this.client);

if (config.organizationAllowlist) {
this.allowlist = new OrganizationAllowlist(config.organizationAllowlist, this.client);
Expand Down Expand Up @@ -126,6 +131,13 @@ export class GitHubIntegration {
return this.installationValidator;
}

/**
* Get the GitHub App permission minimization checker (#1244)
*/
getPermissionMinimizationChecker(): PermissionMinimizationChecker {
return this.permissionMinimizationChecker;
}

/**
* Run a comprehensive repository analysis
*/
Expand All @@ -135,13 +147,15 @@ export class GitHubIntegration {
branchProtection: any;
access: any;
installation: any;
permissionMinimization: any;
}> {
const results = {
monorepo: false,
languages: null,
branchProtection: null,
access: null,
installation: null,
permissionMinimization: null,
};

try {
Expand Down Expand Up @@ -172,6 +186,15 @@ export class GitHubIntegration {
if (this.installationValidator) {
results.installation = await this.installationValidator.validateRepositoryInstallation();
}

// Check installation permission minimization
if (this.permissionMinimizationChecker) {
results.permissionMinimization =
await this.permissionMinimizationChecker.checkRepositoryInstallation(
this.repository.owner,
this.repository.repo,
);
}
} catch (error) {
console.error('Repository analysis failed:', error);
}
Expand Down
167 changes: 167 additions & 0 deletions src/integrations/github/permission-minimization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* GitHub App Permission Minimization Checks (#1244)
*
* InstallationValidator (#1041) checks that an installation has *at least*
* the permissions GasGuard requires. This checker looks the other
* direction: it flags an installation that has *more* access than
* GasGuard's integration actually uses — the over-privileged case that
* InstallationValidator cannot see, since "has enough" and "has too much"
* are independent conditions an installation can satisfy at the same time.
*
* Checks performed against GASGUARD_BASELINE_PERMISSIONS:
* - a granted permission above the level GasGuard's client code
* actually calls (e.g. `contents: write` when every call GasGuard
* makes only reads content)
* - a granted permission GasGuard does not use at all
* - `repository_selection: 'all'` (every repo in the account/org) instead
* of an explicit, selected subset
*/

import { GitHubClient } from './github-client';
import {
ExcessPermission,
PermissionMinimizationOptions,
PermissionMinimizationResult,
} from './types';

const PERMISSION_HIERARCHY: Record<string, number> = {
none: 0,
read: 1,
write: 2,
admin: 3,
};

/**
* The permissions GasGuard's GitHub client (github-client.ts) actually
* exercises, and the minimum level each is used at. Keep this in sync with
* github-client.ts: a permission this client never calls for should not
* appear here, and a permission it needs must be listed at its true
* minimum level (not rounded up "to be safe" — that defeats the point of a
* minimization check).
*/
export const GASGUARD_BASELINE_PERMISSIONS: Record<string, string> = {
// Base repository metadata — required for any GitHub App installation.
metadata: 'read',
// getContents / getTree / getLanguages — source reading for analysis.
contents: 'read',
// getPullRequestFiles — PR changed-file discovery (#1043).
pull_requests: 'read',
// getBranchProtection — branch protection awareness (#1044).
administration: 'read',
};

export class PermissionMinimizationChecker {
constructor(private readonly client: GitHubClient) {}

/**
* Check the installation on a specific repository.
*/
async checkRepositoryInstallation(
owner: string,
repo: string,
options: PermissionMinimizationOptions = {},
): Promise<PermissionMinimizationResult> {
const data = await this.client.getRepositoryInstallation(owner, repo);
return this.evaluate(data, options);
}

/**
* Check an installation directly by its installation ID.
*/
async checkAppInstallation(
installationId: number | string,
options: PermissionMinimizationOptions = {},
): Promise<PermissionMinimizationResult> {
const data = await this.client.getAppInstallation(installationId);
return this.evaluate(data, options);
}

/**
* Pure evaluation over an already-fetched installation payload —
* exposed so callers (and tests) can check minimization without an
* extra API round trip when they already have the installation data
* (e.g. from InstallationValidator's own fetch).
*/
evaluate(
data: { id?: number; permissions?: Record<string, string>; repository_selection?: 'all' | 'selected' },
options: PermissionMinimizationOptions = {},
): PermissionMinimizationResult {
const baseline = options.baselinePermissions ?? GASGUARD_BASELINE_PERMISSIONS;
const granted: Record<string, string> = data.permissions ?? {};

const excessPermissions: ExcessPermission[] = [];
const unusedPermissions: string[] = [];

for (const [permission, grantedLevel] of Object.entries(granted)) {
if (grantedLevel === 'none') continue;

const requiredLevel = baseline[permission];
if (requiredLevel === undefined) {
unusedPermissions.push(permission);
continue;
}

const grantedRank = PERMISSION_HIERARCHY[grantedLevel] ?? 0;
const requiredRank = PERMISSION_HIERARCHY[requiredLevel] ?? 0;
if (grantedRank > requiredRank) {
excessPermissions.push({ permission, grantedLevel, requiredLevel });
}
}

const repositorySelection = data.repository_selection ?? 'selected';
const broadRepositorySelection = repositorySelection === 'all';

const isMinimized =
excessPermissions.length === 0 &&
unusedPermissions.length === 0 &&
!broadRepositorySelection;

const recommendations = this.buildRecommendations(
excessPermissions,
unusedPermissions,
broadRepositorySelection,
);

return {
installationId: data.id,
isMinimized,
excessPermissions,
unusedPermissions,
broadRepositorySelection,
repositorySelection,
grantedPermissions: granted,
baselinePermissions: baseline,
recommendations,
};
}

private buildRecommendations(
excessPermissions: ExcessPermission[],
unusedPermissions: string[],
broadRepositorySelection: boolean,
): string[] {
const recommendations: string[] = [];

for (const excess of excessPermissions) {
recommendations.push(
`Reduce '${excess.permission}' from '${excess.grantedLevel}' to '${excess.requiredLevel}' — ` +
`GasGuard does not use the elevated access.`,
);
}

for (const unused of unusedPermissions) {
recommendations.push(
`Remove permission '${unused}' — GasGuard's integration does not use it at all.`,
);
}

if (broadRepositorySelection) {
recommendations.push(
`Change repository selection from 'all' to 'selected' and explicitly list the ` +
`repositories GasGuard needs, instead of granting access to every repository in the account/org.`,
);
}

return recommendations;
}
}
32 changes: 32 additions & 0 deletions src/integrations/github/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,35 @@ export interface InstallationValidationOptions {
requiredPermissions?: Record<string, string>;
checkAccess?: boolean;
}

// --- GitHub App Permission Minimization (#1244) ---
export interface ExcessPermission {
permission: string;
grantedLevel: string;
requiredLevel: string;
}

export interface PermissionMinimizationOptions {
/**
* Override GasGuard's default baseline (GASGUARD_BASELINE_PERMISSIONS) —
* e.g. a stricter environment-specific policy, or for testing.
*/
baselinePermissions?: Record<string, string>;
}

export interface PermissionMinimizationResult {
installationId?: number;
/** True only when there is no excess permission, no unused permission, and repository selection is not 'all'. */
isMinimized: boolean;
/** Permissions granted at a higher level than GasGuard's baseline requires. */
excessPermissions: ExcessPermission[];
/** Permissions granted that do not appear in GasGuard's baseline at all. */
unusedPermissions: string[];
/** True when the installation is scoped to 'all' repositories rather than a selected subset. */
broadRepositorySelection: boolean;
repositorySelection: 'all' | 'selected';
grantedPermissions: Record<string, string>;
baselinePermissions: Record<string, string>;
/** Human-readable remediation steps, one per finding. */
recommendations: string[];
}
Loading
Loading