@cryptotaxi247 / CoPilot / commits / 07d7ab29

635 GitHub audit (#684)

* feat: Implement GitHub Audit functionality - Added schema definitions for GitHub audit requests and responses in `github_audit.py`. - Developed the `GitHubAuditService` class to handle the auditing process, including organization, repository, workflow, and member checks. - Created API routes for initiating audits and retrieving audit summaries in `github_audit.py`. - Implemented frontend API endpoints for running audits and fetching available checks in `githubAudit.ts`. - Defined TypeScript types for audit requests and responses in `githubAudit.d.ts`. * feat: Add GitHub audit models and migration tables * Refactor GitHub Audit schemas: Introduce new request and response models for audit configurations, exclusions, and baselines; enhance existing models with optional fields and improved descriptions. * feat: add GitHub audit components and functionality - Introduced GitHubAuditExclusionForm for managing exclusions. - Created GitHubAuditFilters for filtering configurations. - Added GitHubAuditGradeBadge for displaying grades. - Implemented GitHubAuditList for listing configurations with filters. - Developed GitHubAuditReportCard for displaying audit report summaries. - Created GitHubAuditReportDetail for detailed report views. - Added GitHubAuditStats for displaying statistics. - Updated index.ts to export new components. - Created GitHubAuditOverview view to integrate the audit list. * feat: optimize GitHub audit report retrieval with selective column fetching and improved count query * feat: enhance GitHub audit detail component with improved state management and error handling * feat: enhance scoring logic in GitHub audit service to exclude NOT_APPLICABLE checks and apply severity penalties * feat: refine scoring logic in GitHub audit service to use weighted pass rates and improve severity handling * feat: add GitHub Audit Reference Guide component with detailed controls and API permissions * precommit fixes * lint fixes * chore: update CURRENT_VERSION to 0.1.41

taylor_socfortress committed Feb 9, 2026 at 15:37 UTC 07d7ab2927969cdffffacced5467ee7813d50f0d
28 files changed +5635 -14
backend/alembic/env.py
+4
@@ -44,6 +44,10 @@ from app.incidents.models import Notification
44 from app.integrations.alert_creation_settings.models.alert_creation_settings import (
45 AlertCreationSettings,
46 )
47 +from app.integrations.github_audit.model import GitHubAuditBaseline
48 +from app.integrations.github_audit.model import GitHubAuditCheckExclusion
49 +from app.integrations.github_audit.model import GitHubAuditConfig
50 +from app.integrations.github_audit.model import GitHubAuditReport
51 from app.integrations.models.customer_integration_settings import CustomerIntegrations
52 from app.integrations.monitoring_alert.models.monitoring_alert import MonitoringAlerts
53 from app.network_connectors.models.network_connectors import AvailableNetworkConnectors
backend/alembic/versions/fb51d610b306_add_github_audit_tables.py new
+154
@@ -0,0 +1,154 @@
1 +"""Add github audit tables
2 +
3 +Revision ID: fb51d610b306
4 +Revises: 72635705c067
5 +Create Date: 2026-02-09 13:20:39.267397
6 +
7 +"""
8 +from typing import Sequence
9 +from typing import Union
10 +
11 +import sqlalchemy as sa
12 +
13 +from alembic import op
14 +
15 +# revision identifiers, used by Alembic.
16 +revision: str = "fb51d610b306"
17 +down_revision: Union[str, None] = "72635705c067"
18 +branch_labels: Union[str, Sequence[str], None] = None
19 +depends_on: Union[str, Sequence[str], None] = None
20 +
21 +
22 +def upgrade() -> None:
23 + # ### commands auto generated by Alembic - please adjust! ###
24 + op.create_table(
25 + "github_audit_config",
26 + sa.Column("repo_filter_list", sa.JSON(), nullable=True),
27 + sa.Column("id", sa.Integer(), nullable=False),
28 + sa.Column("customer_code", sa.String(length=50), nullable=False),
29 + sa.Column("github_token", sa.String(length=500), nullable=False),
30 + sa.Column("organization", sa.String(length=100), nullable=False),
31 + sa.Column("token_type", sa.String(length=50), nullable=False),
32 + sa.Column("token_expires_at", sa.DateTime(), nullable=True),
33 + sa.Column("enabled", sa.Boolean(), nullable=False),
34 + sa.Column("auto_audit_enabled", sa.Boolean(), nullable=False),
35 + sa.Column("audit_schedule_cron", sa.String(length=50), nullable=True),
36 + sa.Column("include_repos", sa.Boolean(), nullable=False),
37 + sa.Column("include_workflows", sa.Boolean(), nullable=False),
38 + sa.Column("include_members", sa.Boolean(), nullable=False),
39 + sa.Column("include_archived_repos", sa.Boolean(), nullable=False),
40 + sa.Column("repo_filter_mode", sa.String(length=20), nullable=False),
41 + sa.Column("notify_on_critical", sa.Boolean(), nullable=False),
42 + sa.Column("notify_on_high", sa.Boolean(), nullable=False),
43 + sa.Column("notification_webhook_url", sa.String(length=500), nullable=True),
44 + sa.Column("notification_email", sa.String(length=255), nullable=True),
45 + sa.Column("minimum_passing_score", sa.Float(), nullable=False),
46 + sa.Column("created_at", sa.DateTime(), nullable=False),
47 + sa.Column("updated_at", sa.DateTime(), nullable=False),
48 + sa.Column("created_by", sa.String(length=100), nullable=True),
49 + sa.Column("updated_by", sa.String(length=100), nullable=True),
50 + sa.Column("last_audit_at", sa.DateTime(), nullable=True),
51 + sa.Column("last_audit_score", sa.Float(), nullable=True),
52 + sa.Column("last_audit_grade", sa.String(length=2), nullable=True),
53 + sa.PrimaryKeyConstraint("id"),
54 + )
55 + op.create_index(op.f("ix_github_audit_config_customer_code"), "github_audit_config", ["customer_code"], unique=False)
56 + op.create_table(
57 + "github_audit_check_exclusion",
58 + sa.Column("id", sa.Integer(), nullable=False),
59 + sa.Column("config_id", sa.Integer(), nullable=False),
60 + sa.Column("customer_code", sa.String(length=50), nullable=False),
61 + sa.Column("check_id", sa.String(length=100), nullable=False),
62 + sa.Column("resource_name", sa.String(length=255), nullable=True),
63 + sa.Column("resource_type", sa.String(length=50), nullable=True),
64 + sa.Column("reason", sa.String(length=1024), nullable=False),
65 + sa.Column("approved_by", sa.String(length=100), nullable=True),
66 + sa.Column("approved_at", sa.DateTime(), nullable=True),
67 + sa.Column("expires_at", sa.DateTime(), nullable=True),
68 + sa.Column("enabled", sa.Boolean(), nullable=False),
69 + sa.Column("created_at", sa.DateTime(), nullable=False),
70 + sa.Column("created_by", sa.String(length=100), nullable=False),
71 + sa.ForeignKeyConstraint(
72 + ["config_id"],
73 + ["github_audit_config.id"],
74 + ),
75 + sa.PrimaryKeyConstraint("id"),
76 + )
77 + op.create_index(op.f("ix_github_audit_check_exclusion_config_id"), "github_audit_check_exclusion", ["config_id"], unique=False)
78 + op.create_index(op.f("ix_github_audit_check_exclusion_customer_code"), "github_audit_check_exclusion", ["customer_code"], unique=False)
79 + op.create_table(
80 + "github_audit_report",
81 + sa.Column("full_report", sa.JSON(), nullable=True),
82 + sa.Column("top_findings", sa.JSON(), nullable=True),
83 + sa.Column("id", sa.Integer(), nullable=False),
84 + sa.Column("config_id", sa.Integer(), nullable=False),
85 + sa.Column("customer_code", sa.String(length=50), nullable=False),
86 + sa.Column("report_name", sa.String(length=255), nullable=False),
87 + sa.Column("organization", sa.String(length=100), nullable=False),
88 + sa.Column("audit_started_at", sa.DateTime(), nullable=False),
89 + sa.Column("audit_completed_at", sa.DateTime(), nullable=True),
90 + sa.Column("audit_duration_seconds", sa.Float(), nullable=True),
91 + sa.Column("total_repos_audited", sa.Integer(), nullable=False),
92 + sa.Column("total_checks", sa.Integer(), nullable=False),
93 + sa.Column("passed_checks", sa.Integer(), nullable=False),
94 + sa.Column("failed_checks", sa.Integer(), nullable=False),
95 + sa.Column("warning_checks", sa.Integer(), nullable=False),
96 + sa.Column("critical_findings", sa.Integer(), nullable=False),
97 + sa.Column("high_findings", sa.Integer(), nullable=False),
98 + sa.Column("medium_findings", sa.Integer(), nullable=False),
99 + sa.Column("low_findings", sa.Integer(), nullable=False),
100 + sa.Column("score", sa.Float(), nullable=False),
101 + sa.Column("grade", sa.String(length=2), nullable=False),
102 + sa.Column("status", sa.String(length=50), nullable=False),
103 + sa.Column("error_message", sa.String(length=1024), nullable=True),
104 + sa.Column("triggered_by", sa.String(length=50), nullable=False),
105 + sa.Column("triggered_by_user", sa.String(length=100), nullable=True),
106 + sa.ForeignKeyConstraint(
107 + ["config_id"],
108 + ["github_audit_config.id"],
109 + ),
110 + sa.PrimaryKeyConstraint("id"),
111 + )
112 + op.create_index(op.f("ix_github_audit_report_config_id"), "github_audit_report", ["config_id"], unique=False)
113 + op.create_index(op.f("ix_github_audit_report_customer_code"), "github_audit_report", ["customer_code"], unique=False)
114 + op.create_table(
115 + "github_audit_baseline",
116 + sa.Column("expected_checks", sa.JSON(), nullable=True),
117 + sa.Column("id", sa.Integer(), nullable=False),
118 + sa.Column("config_id", sa.Integer(), nullable=False),
119 + sa.Column("customer_code", sa.String(length=50), nullable=False),
120 + sa.Column("name", sa.String(length=255), nullable=False),
121 + sa.Column("description", sa.String(length=1024), nullable=True),
122 + sa.Column("baseline_report_id", sa.Integer(), nullable=True),
123 + sa.Column("is_active", sa.Boolean(), nullable=False),
124 + sa.Column("created_at", sa.DateTime(), nullable=False),
125 + sa.Column("created_by", sa.String(length=100), nullable=False),
126 + sa.ForeignKeyConstraint(
127 + ["baseline_report_id"],
128 + ["github_audit_report.id"],
129 + ),
130 + sa.ForeignKeyConstraint(
131 + ["config_id"],
132 + ["github_audit_config.id"],
133 + ),
134 + sa.PrimaryKeyConstraint("id"),
135 + )
136 + op.create_index(op.f("ix_github_audit_baseline_config_id"), "github_audit_baseline", ["config_id"], unique=False)
137 + op.create_index(op.f("ix_github_audit_baseline_customer_code"), "github_audit_baseline", ["customer_code"], unique=False)
138 + # ### end Alembic commands ###
139 +
140 +
141 +def downgrade() -> None:
142 + # ### commands auto generated by Alembic - please adjust! ###
143 + op.drop_index(op.f("ix_github_audit_baseline_customer_code"), table_name="github_audit_baseline")
144 + op.drop_index(op.f("ix_github_audit_baseline_config_id"), table_name="github_audit_baseline")
145 + op.drop_table("github_audit_baseline")
146 + op.drop_index(op.f("ix_github_audit_report_customer_code"), table_name="github_audit_report")
147 + op.drop_index(op.f("ix_github_audit_report_config_id"), table_name="github_audit_report")
148 + op.drop_table("github_audit_report")
149 + op.drop_index(op.f("ix_github_audit_check_exclusion_customer_code"), table_name="github_audit_check_exclusion")
150 + op.drop_index(op.f("ix_github_audit_check_exclusion_config_id"), table_name="github_audit_check_exclusion")
151 + op.drop_table("github_audit_check_exclusion")
152 + op.drop_index(op.f("ix_github_audit_config_customer_code"), table_name="github_audit_config")
153 + op.drop_table("github_audit_config")
154 + # ### end Alembic commands ###
backend/app/integrations/github_audit/model.py new
+240
@@ -0,0 +1,240 @@
1 +from datetime import datetime
2 +from typing import Dict
3 +from typing import List
4 +from typing import Optional
5 +
6 +from sqlmodel import JSON
7 +from sqlmodel import Column
8 +from sqlmodel import Field
9 +from sqlmodel import Relationship
10 +from sqlmodel import SQLModel
11 +from sqlmodel import Text
12 +
13 +
14 +class GitHubAuditConfig(SQLModel, table=True):
15 + """Configuration for GitHub organization security audits per customer."""
16 +
17 + __tablename__ = "github_audit_config"
18 +
19 + id: Optional[int] = Field(default=None, primary_key=True)
20 + customer_code: str = Field(max_length=50, nullable=False, index=True)
21 +
22 + # GitHub Authentication
23 + github_token: str = Field(max_length=500, nullable=False, description="GitHub PAT or App token (encrypted)")
24 + organization: str = Field(max_length=100, nullable=False, description="GitHub organization name")
25 +
26 + # Token metadata
27 + token_type: str = Field(
28 + max_length=50,
29 + default="pat",
30 + description="Token type: 'pat' (Personal Access Token) or 'app' (GitHub App)",
31 + )
32 + token_expires_at: Optional[datetime] = Field(
33 + nullable=True,
34 + description="When the token expires (if applicable)",
35 + )
36 +
37 + # Audit configuration
38 + enabled: bool = Field(default=True, description="Whether audits are enabled for this org")
39 + auto_audit_enabled: bool = Field(
40 + default=False,
41 + description="Whether to run audits automatically on schedule",
42 + )
43 + audit_schedule_cron: Optional[str] = Field(
44 + max_length=50,
45 + nullable=True,
46 + description="Cron expression for scheduled audits (e.g., '0 0 * * 1' for weekly Monday)",
47 + )
48 +
49 + # Scope options - what to include in audits
50 + include_repos: bool = Field(default=True, description="Include repository-level audits")
51 + include_workflows: bool = Field(default=True, description="Include GitHub Actions audits")
52 + include_members: bool = Field(default=True, description="Include member/permission audits")
53 + include_archived_repos: bool = Field(default=False, description="Include archived repositories")
54 +
55 + # Filtering
56 + repo_filter_mode: str = Field(
57 + max_length=20,
58 + default="all",
59 + description="'all', 'include', or 'exclude'",
60 + )
61 + repo_filter_list: Optional[List[str]] = Field(
62 + sa_column=Column(JSON),
63 + nullable=True,
64 + description="List of repos to include/exclude based on filter_mode",
65 + )
66 +
67 + # Notification settings
68 + notify_on_critical: bool = Field(default=True, description="Send notification on critical findings")
69 + notify_on_high: bool = Field(default=False, description="Send notification on high findings")
70 + notification_webhook_url: Optional[str] = Field(
71 + max_length=500,
72 + nullable=True,
73 + description="Webhook URL for audit notifications",
74 + )
75 + notification_email: Optional[str] = Field(
76 + max_length=255,
77 + nullable=True,
78 + description="Email for audit notifications",
79 + )
80 +
81 + # Thresholds
82 + minimum_passing_score: float = Field(
83 + default=70.0,
84 + description="Minimum score to consider audit passing (0-100)",
85 + )
86 +
87 + # Metadata
88 + created_at: datetime = Field(default_factory=datetime.utcnow)
89 + updated_at: datetime = Field(default_factory=datetime.utcnow)
90 + created_by: Optional[str] = Field(max_length=100, nullable=True)
91 + updated_by: Optional[str] = Field(max_length=100, nullable=True)
92 +
93 + # Last audit info
94 + last_audit_at: Optional[datetime] = Field(nullable=True, description="When last audit was run")
95 + last_audit_score: Optional[float] = Field(nullable=True, description="Score from last audit")
96 + last_audit_grade: Optional[str] = Field(max_length=2, nullable=True, description="Grade from last audit")
97 +
98 + # Relationship to audit reports
99 + audit_reports: List["GitHubAuditReport"] = Relationship(back_populates="config")
100 +
101 +
102 +class GitHubAuditReport(SQLModel, table=True):
103 + """Stored GitHub audit reports."""
104 +
105 + __tablename__ = "github_audit_report"
106 +
107 + id: Optional[int] = Field(default=None, primary_key=True)
108 + config_id: int = Field(foreign_key="github_audit_config.id", nullable=False, index=True)
109 + customer_code: str = Field(max_length=50, nullable=False, index=True)
110 +
111 + # Report identification
112 + report_name: str = Field(max_length=255, nullable=False)
113 + organization: str = Field(max_length=100, nullable=False)
114 +
115 + # Audit timing
116 + audit_started_at: datetime = Field(default_factory=datetime.utcnow)
117 + audit_completed_at: Optional[datetime] = Field(nullable=True)
118 + audit_duration_seconds: Optional[float] = Field(nullable=True)
119 +
120 + # Summary data
121 + total_repos_audited: int = Field(default=0)
122 + total_checks: int = Field(default=0)
123 + passed_checks: int = Field(default=0)
124 + failed_checks: int = Field(default=0)
125 + warning_checks: int = Field(default=0)
126 + critical_findings: int = Field(default=0)
127 + high_findings: int = Field(default=0)
128 + medium_findings: int = Field(default=0)
129 + low_findings: int = Field(default=0)
130 + score: float = Field(default=0.0)
131 + grade: str = Field(max_length=2, default="F")
132 +
133 + # Status
134 + status: str = Field(
135 + max_length=50,
136 + default="running",
137 + description="'running', 'completed', 'failed'",
138 + )
139 + error_message: Optional[str] = Field(sa_column=Text, nullable=True)
140 +
141 + # Full report data stored as JSON
142 + full_report: Optional[Dict] = Field(
143 + sa_column=Column(JSON),
144 + nullable=True,
145 + description="Complete audit report data",
146 + )
147 +
148 + # Top findings for quick access
149 + top_findings: Optional[List[Dict]] = Field(
150 + sa_column=Column(JSON),
151 + nullable=True,
152 + description="Top priority findings",
153 + )
154 +
155 + # Triggered by
156 + triggered_by: str = Field(
157 + max_length=50,
158 + default="manual",
159 + description="'manual', 'scheduled', 'api'",
160 + )
161 + triggered_by_user: Optional[str] = Field(max_length=100, nullable=True)
162 +
163 + # Relationship back to config
164 + config: GitHubAuditConfig = Relationship(back_populates="audit_reports")
165 +
166 +
167 +class GitHubAuditCheckExclusion(SQLModel, table=True):
168 + """Exclusion rules for specific audit checks."""
169 +
170 + __tablename__ = "github_audit_check_exclusion"
171 +
172 + id: Optional[int] = Field(default=None, primary_key=True)
173 + config_id: int = Field(foreign_key="github_audit_config.id", nullable=False, index=True)
174 + customer_code: str = Field(max_length=50, nullable=False, index=True)
175 +
176 + # What to exclude
177 + check_id: str = Field(
178 + max_length=100,
179 + nullable=False,
180 + description="The check ID to exclude (e.g., 'repo-branch-protection')",
181 + )
182 + resource_name: Optional[str] = Field(
183 + max_length=255,
184 + nullable=True,
185 + description="Specific resource to exclude (e.g., repo name). Null = all resources",
186 + )
187 + resource_type: Optional[str] = Field(
188 + max_length=50,
189 + nullable=True,
190 + description="Type of resource: 'organization', 'repository', 'workflow', 'member'",
191 + )
192 +
193 + # Why excluded
194 + reason: str = Field(sa_column=Text, nullable=False, description="Reason for exclusion")
195 + approved_by: Optional[str] = Field(max_length=100, nullable=True)
196 + approved_at: Optional[datetime] = Field(nullable=True)
197 +
198 + # Expiration
199 + expires_at: Optional[datetime] = Field(
200 + nullable=True,
201 + description="When this exclusion expires (null = never)",
202 + )
203 +
204 + # Metadata
205 + enabled: bool = Field(default=True)
206 + created_at: datetime = Field(default_factory=datetime.utcnow)
207 + created_by: str = Field(max_length=100, nullable=False)
208 +
209 +
210 +class GitHubAuditBaseline(SQLModel, table=True):
211 + """Baseline configuration for expected audit results."""
212 +
213 + __tablename__ = "github_audit_baseline"
214 +
215 + id: Optional[int] = Field(default=None, primary_key=True)
216 + config_id: int = Field(foreign_key="github_audit_config.id", nullable=False, index=True)
217 + customer_code: str = Field(max_length=50, nullable=False, index=True)
218 +
219 + # Baseline name
220 + name: str = Field(max_length=255, nullable=False)
221 + description: Optional[str] = Field(sa_column=Text, nullable=True)
222 +
223 + # Expected values
224 + expected_checks: Optional[Dict] = Field(
225 + sa_column=Column(JSON),
226 + nullable=True,
227 + description="Expected check results by check_id: {check_id: expected_status}",
228 + )
229 +
230 + # Baseline from a previous report
231 + baseline_report_id: Optional[int] = Field(
232 + foreign_key="github_audit_report.id",
233 + nullable=True,
234 + description="Report used to create this baseline",
235 + )
236 +
237 + # Metadata
238 + is_active: bool = Field(default=True, description="Whether this is the active baseline")
239 + created_at: datetime = Field(default_factory=datetime.utcnow)
240 + created_by: str = Field(max_length=100, nullable=False)
backend/app/integrations/github_audit/routes/github_audit.py new
+984
@@ -0,0 +1,984 @@
1 +from datetime import datetime
2 +from datetime import timezone
3 +from typing import Optional
4 +
5 +from fastapi import APIRouter
6 +from fastapi import Depends
7 +from fastapi import HTTPException
8 +from fastapi import Path
9 +from fastapi import Query
10 +from fastapi import Security
11 +from loguru import logger
12 +from sqlalchemy import select
13 +from sqlalchemy.ext.asyncio import AsyncSession
14 +
15 +from app.auth.utils import AuthHandler
16 +from app.db.db_session import get_db
17 +from app.integrations.github_audit.model import GitHubAuditBaseline
18 +from app.integrations.github_audit.model import GitHubAuditCheckExclusion
19 +from app.integrations.github_audit.model import GitHubAuditConfig
20 +from app.integrations.github_audit.model import GitHubAuditReport
21 +from app.integrations.github_audit.schema.github_audit import AvailableChecksResponse
22 +from app.integrations.github_audit.schema.github_audit import GitHubAuditBaselineCreate
23 +from app.integrations.github_audit.schema.github_audit import (
24 + GitHubAuditBaselineResponse,
25 +)
26 +from app.integrations.github_audit.schema.github_audit import GitHubAuditConfigCreate
27 +from app.integrations.github_audit.schema.github_audit import GitHubAuditConfigResponse
28 +from app.integrations.github_audit.schema.github_audit import GitHubAuditConfigUpdate
29 +from app.integrations.github_audit.schema.github_audit import GitHubAuditExclusionCreate
30 +from app.integrations.github_audit.schema.github_audit import (
31 + GitHubAuditExclusionResponse,
32 +)
33 +from app.integrations.github_audit.schema.github_audit import GitHubAuditExclusionUpdate
34 +from app.integrations.github_audit.schema.github_audit import (
35 + GitHubAuditReportListResponse,
36 +)
37 +from app.integrations.github_audit.schema.github_audit import GitHubAuditReportResponse
38 +from app.integrations.github_audit.schema.github_audit import GitHubAuditRequest
39 +from app.integrations.github_audit.schema.github_audit import GitHubAuditResponse
40 +from app.integrations.github_audit.schema.github_audit import GitHubAuditSummaryResponse
41 +from app.integrations.github_audit.services.github_audit import run_github_audit
42 +from app.integrations.github_audit.services.github_audit import run_github_audit_summary
43 +
44 +github_audit_router = APIRouter()
45 +
46 +
47 +# ==================== Configuration Routes ====================
48 +
49 +
50 +@github_audit_router.post(
51 + "/config",
52 + response_model=GitHubAuditConfigResponse,
53 + description="Create a new GitHub Audit configuration for a customer",
54 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
55 +)
56 +async def create_config(
57 + config: GitHubAuditConfigCreate,
58 + session: AsyncSession = Depends(get_db),
59 +) -> GitHubAuditConfigResponse:
60 + """Create a new GitHub Audit configuration."""
61 + logger.info(f"Creating GitHub Audit config for customer: {config.customer_code}")
62 +
63 + # Check if config already exists for this customer + organization
64 + existing = await session.execute(
65 + select(GitHubAuditConfig).where(
66 + GitHubAuditConfig.customer_code == config.customer_code,
67 + GitHubAuditConfig.organization == config.organization,
68 + ),
69 + )
70 + if existing.scalar_one_or_none():
71 + raise HTTPException(
72 + status_code=400,
73 + detail=f"Configuration already exists for customer '{config.customer_code}' and organization '{config.organization}'",
74 + )
75 +
76 + db_config = GitHubAuditConfig(
77 + customer_code=config.customer_code,
78 + github_token=config.github_token, # TODO: Encrypt before storing
79 + organization=config.organization,
80 + token_type=config.token_type,
81 + token_expires_at=config.token_expires_at,
82 + enabled=config.enabled,
83 + auto_audit_enabled=config.auto_audit_enabled,
84 + audit_schedule_cron=config.audit_schedule_cron,
85 + include_repos=config.include_repos,
86 + include_workflows=config.include_workflows,
87 + include_members=config.include_members,
88 + include_archived_repos=config.include_archived_repos,
89 + repo_filter_mode=config.repo_filter_mode,
90 + repo_filter_list=config.repo_filter_list,
91 + notify_on_critical=config.notify_on_critical,
92 + notify_on_high=config.notify_on_high,
93 + notification_webhook_url=config.notification_webhook_url,
94 + notification_email=config.notification_email,
95 + minimum_passing_score=config.minimum_passing_score,
96 + created_by=config.created_by,
97 + )
98 +
99 + session.add(db_config)
100 + await session.commit()
101 + await session.refresh(db_config)
102 +
103 + return GitHubAuditConfigResponse(
104 + success=True,
105 + message="GitHub Audit configuration created successfully",
106 + config=db_config,
107 + )
108 +
109 +
110 +@github_audit_router.get(
111 + "/config",
112 + response_model=GitHubAuditConfigResponse,
113 + description="Get GitHub Audit configurations",
114 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
115 +)
116 +async def get_configs(
117 + customer_code: Optional[str] = Query(None, description="Filter by customer code"),
118 + session: AsyncSession = Depends(get_db),
119 +) -> GitHubAuditConfigResponse:
120 + """Get all GitHub Audit configurations, optionally filtered by customer."""
121 + query = select(GitHubAuditConfig)
122 +
123 + if customer_code:
124 + query = query.where(GitHubAuditConfig.customer_code == customer_code)
125 +
126 + result = await session.execute(query)
127 + configs = result.scalars().all()
128 +
129 + # Mask tokens in response
130 + for config in configs:
131 + if config.github_token:
132 + config.github_token = "***" + config.github_token[-4:] if len(config.github_token) > 4 else "***"
133 +
134 + return GitHubAuditConfigResponse(
135 + success=True,
136 + message=f"Found {len(configs)} configuration(s)",
137 + configs=list(configs),
138 + )
139 +
140 +
141 +@github_audit_router.get(
142 + "/config/{config_id}",
143 + response_model=GitHubAuditConfigResponse,
144 + description="Get a specific GitHub Audit configuration",
145 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
146 +)
147 +async def get_config(
148 + config_id: int = Path(..., description="Configuration ID"),
149 + session: AsyncSession = Depends(get_db),
150 +) -> GitHubAuditConfigResponse:
151 + """Get a specific GitHub Audit configuration by ID."""
152 + result = await session.execute(
153 + select(GitHubAuditConfig).where(GitHubAuditConfig.id == config_id),
154 + )
155 + config = result.scalar_one_or_none()
156 +
157 + if not config:
158 + raise HTTPException(status_code=404, detail="Configuration not found")
159 +
160 + # Mask token
161 + if config.github_token:
162 + config.github_token = "***" + config.github_token[-4:] if len(config.github_token) > 4 else "***"
163 +
164 + return GitHubAuditConfigResponse(
165 + success=True,
166 + message="Configuration retrieved successfully",
167 + config=config,
168 + )
169 +
170 +
171 +@github_audit_router.put(
172 + "/config/{config_id}",
173 + response_model=GitHubAuditConfigResponse,
174 + description="Update a GitHub Audit configuration",
175 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
176 +)
177 +async def update_config(
178 + config_update: GitHubAuditConfigUpdate,
179 + config_id: int = Path(..., description="Configuration ID"),
180 + session: AsyncSession = Depends(get_db),
181 +) -> GitHubAuditConfigResponse:
182 + """Update an existing GitHub Audit configuration."""
183 + result = await session.execute(
184 + select(GitHubAuditConfig).where(GitHubAuditConfig.id == config_id),
185 + )
186 + config = result.scalar_one_or_none()
187 +
188 + if not config:
189 + raise HTTPException(status_code=404, detail="Configuration not found")
190 +
191 + # Update fields that were provided
192 + update_data = config_update.dict(exclude_unset=True)
193 +
194 + for field, value in update_data.items():
195 + if value is not None:
196 + setattr(config, field, value)
197 +
198 + config.updated_at = datetime.now(timezone.utc)
199 +
200 + await session.commit()
201 + await session.refresh(config)
202 +
203 + # Mask token
204 + if config.github_token:
205 + config.github_token = "***" + config.github_token[-4:] if len(config.github_token) > 4 else "***"
206 +
207 + return GitHubAuditConfigResponse(
208 + success=True,
209 + message="Configuration updated successfully",
210 + config=config,
211 + )
212 +
213 +
214 +@github_audit_router.delete(
215 + "/config/{config_id}",
216 + description="Delete a GitHub Audit configuration",
217 + dependencies=[Security(AuthHandler().require_any_scope("admin"))],
218 +)
219 +async def delete_config(
220 + config_id: int = Path(..., description="Configuration ID"),
221 + session: AsyncSession = Depends(get_db),
222 +):
223 + """Delete a GitHub Audit configuration and all associated data."""
224 + result = await session.execute(
225 + select(GitHubAuditConfig).where(GitHubAuditConfig.id == config_id),
226 + )
227 + config = result.scalar_one_or_none()
228 +
229 + if not config:
230 + raise HTTPException(status_code=404, detail="Configuration not found")
231 +
232 + # Delete associated records
233 + await session.execute(
234 + select(GitHubAuditReport).where(GitHubAuditReport.config_id == config_id),
235 + )
236 + await session.execute(
237 + select(GitHubAuditCheckExclusion).where(GitHubAuditCheckExclusion.config_id == config_id),
238 + )
239 + await session.execute(
240 + select(GitHubAuditBaseline).where(GitHubAuditBaseline.config_id == config_id),
241 + )
242 +
243 + await session.delete(config)
244 + await session.commit()
245 +
246 + return {"success": True, "message": "Configuration deleted successfully"}
247 +
248 +
249 +# ==================== Audit Execution Routes ====================
250 +
251 +
252 +@github_audit_router.post(
253 + "/config/{config_id}/audit",
254 + response_model=GitHubAuditResponse,
255 + description="Run a GitHub audit using a saved configuration",
256 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
257 +)
258 +async def run_audit_from_config(
259 + config_id: int = Path(..., description="Configuration ID"),
260 + session: AsyncSession = Depends(get_db),
261 +) -> GitHubAuditResponse:
262 + """Run a GitHub audit using a saved configuration."""
263 + logger.info(f"Running GitHub audit from config ID: {config_id}")
264 +
265 + # Get configuration
266 + result = await session.execute(
267 + select(GitHubAuditConfig).where(GitHubAuditConfig.id == config_id),
268 + )
269 + config = result.scalar_one_or_none()
270 +
271 + if not config:
272 + raise HTTPException(status_code=404, detail="Configuration not found")
273 +
274 + if not config.enabled:
275 + raise HTTPException(status_code=400, detail="This configuration is disabled")
276 +
277 + # Get exclusions for this config
278 + exclusions_result = await session.execute(
279 + select(GitHubAuditCheckExclusion).where(
280 + GitHubAuditCheckExclusion.config_id == config_id,
281 + GitHubAuditCheckExclusion.enabled == True,
282 + ),
283 + )
284 + exclusions = exclusions_result.scalars().all()
285 +
286 + # Build request from config
287 + repo_filter = None
288 + if config.repo_filter_mode == "include" and config.repo_filter_list:
289 + repo_filter = config.repo_filter_list
290 +
291 + request = GitHubAuditRequest(
292 + organization=config.organization,
293 + include_repos=config.include_repos,
294 + include_workflows=config.include_workflows,
295 + include_members=config.include_members,
296 + repo_filter=repo_filter,
297 + )
298 +
299 + # Create report record
300 + report = GitHubAuditReport(
301 + config_id=config.id,
302 + customer_code=config.customer_code,
303 + report_name=f"Audit-{config.organization}-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}",
304 + organization=config.organization,
305 + status="running",
306 + triggered_by="manual",
307 + )
308 + session.add(report)
309 + await session.commit()
310 + await session.refresh(report)
311 +
312 + try:
313 + # Run the audit
314 + start_time = datetime.now(timezone.utc)
315 + audit_response = await run_github_audit(config.github_token, request)
316 + end_time = datetime.now(timezone.utc)
317 +
318 + # Apply exclusions to findings
319 + if exclusions:
320 + exclusion_set = {(e.check_id, e.resource_name) for e in exclusions}
321 + audit_response.top_findings = [
322 + f
323 + for f in audit_response.top_findings
324 + if (f.check_id, f.resource_name) not in exclusion_set and (f.check_id, None) not in exclusion_set
325 + ]
326 +
327 + # Update report with results
328 + report.status = "completed" if audit_response.success else "failed"
329 + report.audit_completed_at = end_time
330 + report.audit_duration_seconds = (end_time - start_time).total_seconds()
331 +
332 + if audit_response.summary:
333 + report.total_repos_audited = audit_response.summary.total_repos_audited
334 + report.total_checks = audit_response.summary.total_checks
335 + report.passed_checks = audit_response.summary.passed_checks
336 + report.failed_checks = audit_response.summary.failed_checks
337 + report.warning_checks = audit_response.summary.warning_checks
338 + report.critical_findings = audit_response.summary.critical_findings
339 + report.high_findings = audit_response.summary.high_findings
340 + report.medium_findings = audit_response.summary.medium_findings
341 + report.low_findings = audit_response.summary.low_findings
342 + report.score = audit_response.summary.score
343 + report.grade = audit_response.summary.grade
344 +
345 + report.full_report = audit_response.dict()
346 + report.top_findings = [f.dict() for f in audit_response.top_findings[:20]]
347 +
348 + # Update config with last audit info
349 + config.last_audit_at = end_time
350 + if audit_response.summary:
351 + config.last_audit_score = audit_response.summary.score
352 + config.last_audit_grade = audit_response.summary.grade
353 +
354 + await session.commit()
355 +
356 + return audit_response
357 +
358 + except Exception as e:
359 + logger.error(f"Audit failed: {e}")
360 + report.status = "failed"
361 + report.error_message = str(e)
362 + report.audit_completed_at = datetime.now(timezone.utc)
363 + await session.commit()
364 + raise HTTPException(status_code=500, detail=f"Audit failed: {e}")
365 +
366 +
367 +@github_audit_router.post(
368 + "/audit",
369 + response_model=GitHubAuditResponse,
370 + description="Run a one-time GitHub audit (without saving config)",
371 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
372 +)
373 +async def run_audit_adhoc(
374 + request: GitHubAuditRequest,
375 + github_token: str = Query(..., description="GitHub Personal Access Token"),
376 +) -> GitHubAuditResponse:
377 + """Run a one-time GitHub audit without saving configuration."""
378 + logger.info(f"Running ad-hoc GitHub audit for organization: {request.organization}")
379 +
380 + try:
381 + return await run_github_audit(github_token, request)
382 + except Exception as e:
383 + logger.error(f"GitHub audit failed: {e}")
384 + raise HTTPException(status_code=500, detail=f"Audit failed: {e}")
385 +
386 +
387 +@github_audit_router.post(
388 + "/config/{config_id}/audit/summary",
389 + response_model=GitHubAuditSummaryResponse,
390 + description="Run a GitHub audit and return summary only",
391 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
392 +)
393 +async def run_audit_summary_from_config(
394 + config_id: int = Path(..., description="Configuration ID"),
395 + session: AsyncSession = Depends(get_db),
396 +) -> GitHubAuditSummaryResponse:
397 + """Run a GitHub audit using saved config and return only the summary."""
398 + logger.info(f"Running GitHub audit summary from config ID: {config_id}")
399 +
400 + result = await session.execute(
401 + select(GitHubAuditConfig).where(GitHubAuditConfig.id == config_id),
402 + )
403 + config = result.scalar_one_or_none()
404 +
405 + if not config:
406 + raise HTTPException(status_code=404, detail="Configuration not found")
407 +
408 + if not config.enabled:
409 + raise HTTPException(status_code=400, detail="This configuration is disabled")
410 +
411 + request = GitHubAuditRequest(
412 + organization=config.organization,
413 + include_repos=config.include_repos,
414 + include_workflows=config.include_workflows,
415 + include_members=config.include_members,
416 + )
417 +
418 + try:
419 + return await run_github_audit_summary(config.github_token, request)
420 + except Exception as e:
421 + logger.error(f"GitHub audit summary failed: {e}")
422 + raise HTTPException(status_code=500, detail=f"Audit failed: {e}")
423 +
424 +
425 +# ==================== Report Routes ====================
426 +
427 +
428 +@github_audit_router.get(
429 + "/reports",
430 + response_model=GitHubAuditReportListResponse,
431 + description="Get list of GitHub audit reports",
432 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
433 +)
434 +async def get_reports(
435 + customer_code: Optional[str] = Query(None, description="Filter by customer code"),
436 + config_id: Optional[int] = Query(None, description="Filter by config ID"),
437 + organization: Optional[str] = Query(None, description="Filter by organization"),
438 + status: Optional[str] = Query(None, description="Filter by status"),
439 + limit: int = Query(50, ge=1, le=200, description="Maximum number of reports"),
440 + offset: int = Query(0, ge=0, description="Offset for pagination"),
441 + session: AsyncSession = Depends(get_db),
442 +) -> GitHubAuditReportListResponse:
443 + """Get list of GitHub audit reports with optional filters."""
444 + from sqlalchemy import func
445 +
446 + # Select only the columns we need for the list view (exclude large JSON columns)
447 + query = select(
448 + GitHubAuditReport.id,
449 + GitHubAuditReport.config_id,
450 + GitHubAuditReport.customer_code,
451 + GitHubAuditReport.report_name,
452 + GitHubAuditReport.organization,
453 + GitHubAuditReport.audit_started_at,
454 + GitHubAuditReport.audit_completed_at,
455 + GitHubAuditReport.audit_duration_seconds,
456 + GitHubAuditReport.total_repos_audited,
457 + GitHubAuditReport.total_checks,
458 + GitHubAuditReport.passed_checks,
459 + GitHubAuditReport.failed_checks,
460 + GitHubAuditReport.warning_checks,
461 + GitHubAuditReport.critical_findings,
462 + GitHubAuditReport.high_findings,
463 + GitHubAuditReport.medium_findings,
464 + GitHubAuditReport.low_findings,
465 + GitHubAuditReport.score,
466 + GitHubAuditReport.grade,
467 + GitHubAuditReport.status,
468 + GitHubAuditReport.triggered_by,
469 + GitHubAuditReport.triggered_by_user,
470 + ).order_by(GitHubAuditReport.audit_started_at.desc())
471 +
472 + if customer_code:
473 + query = query.where(GitHubAuditReport.customer_code == customer_code)
474 + if config_id:
475 + query = query.where(GitHubAuditReport.config_id == config_id)
476 + if organization:
477 + query = query.where(GitHubAuditReport.organization == organization)
478 + if status:
479 + query = query.where(GitHubAuditReport.status == status)
480 +
481 + # Get total count using a separate count query
482 + count_query = select(func.count(GitHubAuditReport.id))
483 + if customer_code:
484 + count_query = count_query.where(GitHubAuditReport.customer_code == customer_code)
485 + if config_id:
486 + count_query = count_query.where(GitHubAuditReport.config_id == config_id)
487 + if organization:
488 + count_query = count_query.where(GitHubAuditReport.organization == organization)
489 + if status:
490 + count_query = count_query.where(GitHubAuditReport.status == status)
491 +
492 + count_result = await session.execute(count_query)
493 + total = count_result.scalar() or 0
494 +
495 + # Apply pagination
496 + query = query.offset(offset).limit(limit)
497 + result = await session.execute(query)
498 + rows = result.all()
499 +
500 + # Convert rows to dictionaries
501 + report_summaries = []
502 + for row in rows:
503 + report_dict = {
504 + "id": row.id,
505 + "config_id": row.config_id,
506 + "customer_code": row.customer_code,
507 + "report_name": row.report_name,
508 + "organization": row.organization,
509 + "audit_started_at": row.audit_started_at,
510 + "audit_completed_at": row.audit_completed_at,
511 + "audit_duration_seconds": row.audit_duration_seconds,
512 + "total_repos_audited": row.total_repos_audited,
513 + "total_checks": row.total_checks,
514 + "passed_checks": row.passed_checks,
515 + "failed_checks": row.failed_checks,
516 + "warning_checks": row.warning_checks,
517 + "critical_findings": row.critical_findings,
518 + "high_findings": row.high_findings,
519 + "medium_findings": row.medium_findings,
520 + "low_findings": row.low_findings,
521 + "score": row.score,
522 + "grade": row.grade,
523 + "status": row.status,
524 + "triggered_by": row.triggered_by,
525 + "triggered_by_user": row.triggered_by_user,
526 + }
527 + report_summaries.append(report_dict)
528 +
529 + return GitHubAuditReportListResponse(
530 + success=True,
531 + message=f"Found {total} report(s)",
532 + reports=report_summaries,
533 + total_count=total,
534 + )
535 +
536 +
537 +@github_audit_router.get(
538 + "/reports/{report_id}",
539 + response_model=GitHubAuditReportResponse,
540 + description="Get a specific GitHub audit report with full details",
541 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
542 +)
543 +async def get_report(
544 + report_id: int = Path(..., description="Report ID"),
545 + session: AsyncSession = Depends(get_db),
546 +) -> GitHubAuditReportResponse:
547 + """Get a specific GitHub audit report with full details."""
548 + result = await session.execute(
549 + select(GitHubAuditReport).where(GitHubAuditReport.id == report_id),
550 + )
551 + report = result.scalar_one_or_none()
552 +
553 + if not report:
554 + raise HTTPException(status_code=404, detail="Report not found")
555 +
556 + return GitHubAuditReportResponse(
557 + success=True,
558 + message="Report retrieved successfully",
559 + report=report,
560 + )
561 +
562 +
563 +@github_audit_router.delete(
564 + "/reports/{report_id}",
565 + description="Delete a GitHub audit report",
566 + dependencies=[Security(AuthHandler().require_any_scope("admin"))],
567 +)
568 +async def delete_report(
569 + report_id: int = Path(..., description="Report ID"),
570 + session: AsyncSession = Depends(get_db),
571 +):
572 + """Delete a specific GitHub audit report."""
573 + result = await session.execute(
574 + select(GitHubAuditReport).where(GitHubAuditReport.id == report_id),
575 + )
576 + report = result.scalar_one_or_none()
577 +
578 + if not report:
579 + raise HTTPException(status_code=404, detail="Report not found")
580 +
581 + await session.delete(report)
582 + await session.commit()
583 +
584 + return {"success": True, "message": "Report deleted successfully"}
585 +
586 +
587 +# ==================== Exclusion Routes ====================
588 +
589 +
590 +@github_audit_router.post(
591 + "/config/{config_id}/exclusions",
592 + response_model=GitHubAuditExclusionResponse,
593 + description="Add an exclusion rule for a specific audit check",
594 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
595 +)
596 +async def create_exclusion(
597 + exclusion: GitHubAuditExclusionCreate,
598 + config_id: int = Path(..., description="Configuration ID"),
599 + session: AsyncSession = Depends(get_db),
600 +) -> GitHubAuditExclusionResponse:
601 + """Create an exclusion rule for a specific check."""
602 + # Verify config exists
603 + config_result = await session.execute(
604 + select(GitHubAuditConfig).where(GitHubAuditConfig.id == config_id),
605 + )
606 + config = config_result.scalar_one_or_none()
607 +
608 + if not config:
609 + raise HTTPException(status_code=404, detail="Configuration not found")
610 +
611 + db_exclusion = GitHubAuditCheckExclusion(
612 + config_id=config_id,
613 + customer_code=config.customer_code,
614 + check_id=exclusion.check_id,
615 + resource_name=exclusion.resource_name,
616 + resource_type=exclusion.resource_type,
617 + reason=exclusion.reason,
618 + approved_by=exclusion.approved_by,
619 + approved_at=datetime.now(timezone.utc) if exclusion.approved_by else None,
620 + expires_at=exclusion.expires_at,
621 + created_by=exclusion.created_by,
622 + )
623 +
624 + session.add(db_exclusion)
625 + await session.commit()
626 + await session.refresh(db_exclusion)
627 +
628 + return GitHubAuditExclusionResponse(
629 + success=True,
630 + message="Exclusion created successfully",
631 + exclusion=db_exclusion,
632 + )
633 +
634 +
635 +@github_audit_router.get(
636 + "/config/{config_id}/exclusions",
637 + response_model=GitHubAuditExclusionResponse,
638 + description="Get all exclusion rules for a configuration",
639 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
640 +)
641 +async def get_exclusions(
642 + config_id: int = Path(..., description="Configuration ID"),
643 + include_expired: bool = Query(False, description="Include expired exclusions"),
644 + session: AsyncSession = Depends(get_db),
645 +) -> GitHubAuditExclusionResponse:
646 + """Get all exclusion rules for a configuration."""
647 + query = select(GitHubAuditCheckExclusion).where(
648 + GitHubAuditCheckExclusion.config_id == config_id,
649 + )
650 +
651 + if not include_expired:
652 + query = query.where(
653 + (GitHubAuditCheckExclusion.expires_at.is_(None)) | (GitHubAuditCheckExclusion.expires_at > datetime.now(timezone.utc)),
654 + )
655 +
656 + result = await session.execute(query)
657 + exclusions = result.scalars().all()
658 +
659 + return GitHubAuditExclusionResponse(
660 + success=True,
661 + message=f"Found {len(exclusions)} exclusion(s)",
662 + exclusions=list(exclusions),
663 + )
664 +
665 +
666 +@github_audit_router.put(
667 + "/exclusions/{exclusion_id}",
668 + response_model=GitHubAuditExclusionResponse,
669 + description="Update an exclusion rule",
670 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
671 +)
672 +async def update_exclusion(
673 + exclusion_update: GitHubAuditExclusionUpdate,
674 + exclusion_id: int = Path(..., description="Exclusion ID"),
675 + session: AsyncSession = Depends(get_db),
676 +) -> GitHubAuditExclusionResponse:
677 + """Update an exclusion rule."""
678 + result = await session.execute(
679 + select(GitHubAuditCheckExclusion).where(GitHubAuditCheckExclusion.id == exclusion_id),
680 + )
681 + exclusion = result.scalar_one_or_none()
682 +
683 + if not exclusion:
684 + raise HTTPException(status_code=404, detail="Exclusion not found")
685 +
686 + update_data = exclusion_update.dict(exclude_unset=True)
687 + for field, value in update_data.items():
688 + if value is not None:
689 + setattr(exclusion, field, value)
690 +
691 + await session.commit()
692 + await session.refresh(exclusion)
693 +
694 + return GitHubAuditExclusionResponse(
695 + success=True,
696 + message="Exclusion updated successfully",
697 + exclusion=exclusion,
698 + )
699 +
700 +
701 +@github_audit_router.delete(
702 + "/exclusions/{exclusion_id}",
703 + description="Delete an exclusion rule",
704 + dependencies=[Security(AuthHandler().require_any_scope("admin"))],
705 +)
706 +async def delete_exclusion(
707 + exclusion_id: int = Path(..., description="Exclusion ID"),
708 + session: AsyncSession = Depends(get_db),
709 +):
710 + """Delete an exclusion rule."""
711 + result = await session.execute(
712 + select(GitHubAuditCheckExclusion).where(GitHubAuditCheckExclusion.id == exclusion_id),
713 + )
714 + exclusion = result.scalar_one_or_none()
715 +
716 + if not exclusion:
717 + raise HTTPException(status_code=404, detail="Exclusion not found")
718 +
719 + await session.delete(exclusion)
720 + await session.commit()
721 +
722 + return {"success": True, "message": "Exclusion deleted successfully"}
723 +
724 +
725 +# ==================== Baseline Routes ====================
726 +
727 +
728 +@github_audit_router.post(
729 + "/config/{config_id}/baselines",
730 + response_model=GitHubAuditBaselineResponse,
731 + description="Create a baseline from a previous audit report",
732 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
733 +)
734 +async def create_baseline(
735 + baseline: GitHubAuditBaselineCreate,
736 + config_id: int = Path(..., description="Configuration ID"),
737 + session: AsyncSession = Depends(get_db),
738 +) -> GitHubAuditBaselineResponse:
739 + """Create a baseline from a previous audit report."""
740 + # Verify config exists
741 + config_result = await session.execute(
742 + select(GitHubAuditConfig).where(GitHubAuditConfig.id == config_id),
743 + )
744 + config = config_result.scalar_one_or_none()
745 +
746 + if not config:
747 + raise HTTPException(status_code=404, detail="Configuration not found")
748 +
749 + # If baseline_report_id provided, extract expected checks from that report
750 + expected_checks = baseline.expected_checks
751 + if baseline.baseline_report_id:
752 + report_result = await session.execute(
753 + select(GitHubAuditReport).where(GitHubAuditReport.id == baseline.baseline_report_id),
754 + )
755 + report = report_result.scalar_one_or_none()
756 +
757 + if not report:
758 + raise HTTPException(status_code=404, detail="Baseline report not found")
759 +
760 + if report.full_report:
761 + # Extract check statuses from report
762 + expected_checks = {}
763 + full_report = report.full_report
764 +
765 + # Organization checks
766 + if "organization_results" in full_report and full_report["organization_results"]:
767 + for check in full_report["organization_results"].get("checks", []):
768 + expected_checks[check["check_id"]] = check["status"]
769 +
770 + # Repository checks
771 + for repo in full_report.get("repository_results", []):
772 + for check in repo.get("checks", []):
773 + key = f"{check['check_id']}:{repo['repo_name']}"
774 + expected_checks[key] = check["status"]
775 +
776 + # Deactivate other baselines for this config
777 + if baseline.is_active:
778 + await session.execute(
779 + select(GitHubAuditBaseline).where(GitHubAuditBaseline.config_id == config_id).where(GitHubAuditBaseline.is_active == True),
780 + )
781 + existing = await session.execute(
782 + select(GitHubAuditBaseline).where(
783 + GitHubAuditBaseline.config_id == config_id,
784 + GitHubAuditBaseline.is_active == True,
785 + ),
786 + )
787 + for b in existing.scalars().all():
788 + b.is_active = False
789 +
790 + db_baseline = GitHubAuditBaseline(
791 + config_id=config_id,
792 + customer_code=config.customer_code,
793 + name=baseline.name,
794 + description=baseline.description,
795 + expected_checks=expected_checks,
796 + baseline_report_id=baseline.baseline_report_id,
797 + is_active=baseline.is_active,
798 + created_by=baseline.created_by,
799 + )
800 +
801 + session.add(db_baseline)
802 + await session.commit()
803 + await session.refresh(db_baseline)
804 +
805 + return GitHubAuditBaselineResponse(
806 + success=True,
807 + message="Baseline created successfully",
808 + baseline=db_baseline,
809 + )
810 +
811 +
812 +@github_audit_router.get(
813 + "/config/{config_id}/baselines",
814 + response_model=GitHubAuditBaselineResponse,
815 + description="Get all baselines for a configuration",
816 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
817 +)
818 +async def get_baselines(
819 + config_id: int = Path(..., description="Configuration ID"),
820 + active_only: bool = Query(False, description="Only return active baseline"),
821 + session: AsyncSession = Depends(get_db),
822 +) -> GitHubAuditBaselineResponse:
823 + """Get all baselines for a configuration."""
824 + query = select(GitHubAuditBaseline).where(GitHubAuditBaseline.config_id == config_id)
825 +
826 + if active_only:
827 + query = query.where(GitHubAuditBaseline.is_active == True)
828 +
829 + result = await session.execute(query)
830 + baselines = result.scalars().all()
831 +
832 + return GitHubAuditBaselineResponse(
833 + success=True,
834 + message=f"Found {len(baselines)} baseline(s)",
835 + baselines=list(baselines),
836 + )
837 +
838 +
839 +@github_audit_router.delete(
840 + "/baselines/{baseline_id}",
841 + description="Delete a baseline",
842 + dependencies=[Security(AuthHandler().require_any_scope("admin"))],
843 +)
844 +async def delete_baseline(
845 + baseline_id: int = Path(..., description="Baseline ID"),
846 + session: AsyncSession = Depends(get_db),
847 +):
848 + """Delete a baseline."""
849 + result = await session.execute(
850 + select(GitHubAuditBaseline).where(GitHubAuditBaseline.id == baseline_id),
851 + )
852 + baseline = result.scalar_one_or_none()
853 +
854 + if not baseline:
855 + raise HTTPException(status_code=404, detail="Baseline not found")
856 +
857 + await session.delete(baseline)
858 + await session.commit()
859 +
860 + return {"success": True, "message": "Baseline deleted successfully"}
861 +
862 +
863 +# ==================== Available Checks Route ====================
864 +
865 +
866 +@github_audit_router.get(
867 + "/checks",
868 + response_model=AvailableChecksResponse,
869 + description="Get list of all available audit checks",
870 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
871 +)
872 +async def get_available_checks() -> AvailableChecksResponse:
873 + """Get list of all audit checks that will be performed."""
874 + return AvailableChecksResponse(
875 + success=True,
876 + message="Available audit checks retrieved successfully",
877 + checks=[
878 + {
879 + "id": "org-2fa-required",
880 + "name": "Two-Factor Authentication Required",
881 + "category": "organization",
882 + "severity": "critical",
883 + "description": "Checks if 2FA is required for all organization members",
884 + },
885 + {
886 + "id": "org-default-permission",
887 + "name": "Default Repository Permission",
888 + "category": "organization",
889 + "severity": "medium",
890 + "description": "Checks the default permission level for new repositories",
891 + },
892 + {
893 + "id": "org-member-repo-creation",
894 + "name": "Member Repository Creation",
895 + "category": "organization",
896 + "severity": "low",
897 + "description": "Checks if members can create repositories",
898 + },
899 + {
900 + "id": "org-public-repo-creation",
901 + "name": "Public Repository Creation",
902 + "category": "organization",
903 + "severity": "high",
904 + "description": "Checks if members can create public repositories",
905 + },
906 + {
907 + "id": "org-verified-domains",
908 + "name": "Verified Domains",
909 + "category": "organization",
910 + "severity": "medium",
911 + "description": "Checks if organization has verified domains",
912 + },
913 + {
914 + "id": "org-sso-enforcement",
915 + "name": "SAML SSO Enforcement",
916 + "category": "organization",
917 + "severity": "high",
918 + "description": "Checks if SAML SSO is enforced",
919 + },
920 + {
921 + "id": "repo-branch-protection",
922 + "name": "Default Branch Protection",
923 + "category": "repository",
924 + "severity": "high",
925 + "description": "Checks if default branch has protection rules",
926 + },
927 + {
928 + "id": "repo-secret-scanning",
929 + "name": "Secret Scanning",
930 + "category": "repository",
931 + "severity": "high",
932 + "description": "Checks if secret scanning is enabled",
933 + },
934 + {
935 + "id": "repo-dependabot-alerts",
936 + "name": "Dependabot Alerts",
937 + "category": "repository",
938 + "severity": "high",
939 + "description": "Checks if Dependabot alerts are enabled",
940 + },
941 + {
942 + "id": "repo-code-scanning",
943 + "name": "Code Scanning (GHAS)",
944 + "category": "repository",
945 + "severity": "medium",
946 + "description": "Checks if code scanning is enabled",
947 + },
948 + {
949 + "id": "repo-private-vuln-reporting",
950 + "name": "Private Vulnerability Reporting",
951 + "category": "repository",
952 + "severity": "low",
953 + "description": "Checks if private vulnerability reporting is enabled",
954 + },
955 + {
956 + "id": "repo-license",
957 + "name": "Repository License",
958 + "category": "repository",
959 + "severity": "low",
960 + "description": "Checks if public repositories have a license",
961 + },
962 + {
963 + "id": "repo-branch-deletion",
964 + "name": "Default Branch Deletion Protection",
965 + "category": "repository",
966 + "severity": "high",
967 + "description": "Checks if default branch is protected from deletion",
968 + },
969 + {
970 + "id": "actions-allowed-all",
971 + "name": "Actions Permission Policy",
972 + "category": "workflow",
973 + "severity": "medium",
974 + "description": "Checks which GitHub Actions are allowed to run",
975 + },
976 + {
977 + "id": "actions-default-token-perms",
978 + "name": "Default Workflow Token Permissions",
979 + "category": "workflow",
980 + "severity": "medium",
981 + "description": "Checks default GITHUB_TOKEN permissions in workflows",
982 + },
983 + ],
984 + )
backend/app/integrations/github_audit/schema/github_audit.py new
+283
@@ -0,0 +1,283 @@
1 +from datetime import datetime
2 +from enum import Enum
3 +from typing import Any
4 +from typing import Dict
5 +from typing import List
6 +from typing import Optional
7 +
8 +from pydantic import BaseModel
9 +from pydantic import Field
10 +
11 +
12 +class AuditStatus(str, Enum):
13 + PASS = "pass"
14 + FAIL = "fail"
15 + WARNING = "warning"
16 + NOT_APPLICABLE = "not_applicable"
17 +
18 +
19 +class SeverityLevel(str, Enum):
20 + CRITICAL = "critical"
21 + HIGH = "high"
22 + MEDIUM = "medium"
23 + LOW = "low"
24 + INFO = "info"
25 +
26 +
27 +# ==================== Request Schemas ====================
28 +
29 +
30 +class GitHubAuditRequest(BaseModel):
31 + """Request to run a GitHub organization audit."""
32 +
33 + organization: str = Field(..., description="GitHub organization name to audit")
34 + include_repos: bool = Field(True, description="Include repository-level audits")
35 + include_workflows: bool = Field(True, description="Include workflow/actions audits")
36 + include_members: bool = Field(True, description="Include member/permission audits")
37 + repo_filter: Optional[List[str]] = Field(None, description="Specific repos to audit (None = all)")
38 +
39 +
40 +class GitHubAuditConfigCreate(BaseModel):
41 + """Request to create a GitHub Audit configuration."""
42 +
43 + customer_code: str = Field(..., max_length=50, description="Customer code")
44 + github_token: str = Field(..., max_length=500, description="GitHub PAT or App token")
45 + organization: str = Field(..., max_length=100, description="GitHub organization name")
46 + token_type: str = Field("pat", max_length=50, description="Token type: 'pat' or 'app'")
47 + token_expires_at: Optional[datetime] = Field(None, description="When the token expires")
48 + enabled: bool = Field(True, description="Whether audits are enabled")
49 + auto_audit_enabled: bool = Field(False, description="Enable scheduled audits")
50 + audit_schedule_cron: Optional[str] = Field(None, max_length=50, description="Cron schedule")
51 + include_repos: bool = Field(True, description="Include repository audits")
52 + include_workflows: bool = Field(True, description="Include workflow audits")
53 + include_members: bool = Field(True, description="Include member audits")
54 + include_archived_repos: bool = Field(False, description="Include archived repos")
55 + repo_filter_mode: str = Field("all", description="'all', 'include', or 'exclude'")
56 + repo_filter_list: Optional[List[str]] = Field(None, description="Repos to include/exclude")
57 + notify_on_critical: bool = Field(True, description="Notify on critical findings")
58 + notify_on_high: bool = Field(False, description="Notify on high findings")
59 + notification_webhook_url: Optional[str] = Field(None, max_length=500)
60 + notification_email: Optional[str] = Field(None, max_length=255)
61 + minimum_passing_score: float = Field(70.0, ge=0, le=100)
62 + created_by: Optional[str] = Field(None, max_length=100)
63 +
64 +
65 +class GitHubAuditConfigUpdate(BaseModel):
66 + """Request to update a GitHub Audit configuration."""
67 +
68 + github_token: Optional[str] = Field(None, max_length=500)
69 + organization: Optional[str] = Field(None, max_length=100)
70 + token_type: Optional[str] = Field(None, max_length=50)
71 + token_expires_at: Optional[datetime] = None
72 + enabled: Optional[bool] = None
73 + auto_audit_enabled: Optional[bool] = None
74 + audit_schedule_cron: Optional[str] = Field(None, max_length=50)
75 + include_repos: Optional[bool] = None
76 + include_workflows: Optional[bool] = None
77 + include_members: Optional[bool] = None
78 + include_archived_repos: Optional[bool] = None
79 + repo_filter_mode: Optional[str] = None
80 + repo_filter_list: Optional[List[str]] = None
81 + notify_on_critical: Optional[bool] = None
82 + notify_on_high: Optional[bool] = None
83 + notification_webhook_url: Optional[str] = Field(None, max_length=500)
84 + notification_email: Optional[str] = Field(None, max_length=255)
85 + minimum_passing_score: Optional[float] = Field(None, ge=0, le=100)
86 + updated_by: Optional[str] = Field(None, max_length=100)
87 +
88 +
89 +class GitHubAuditExclusionCreate(BaseModel):
90 + """Request to create a check exclusion."""
91 +
92 + check_id: str = Field(..., max_length=100, description="Check ID to exclude")
93 + resource_name: Optional[str] = Field(None, max_length=255, description="Specific resource")
94 + resource_type: Optional[str] = Field(None, max_length=50)
95 + reason: str = Field(..., description="Reason for exclusion")
96 + approved_by: Optional[str] = Field(None, max_length=100)
97 + expires_at: Optional[datetime] = None
98 + created_by: str = Field(..., max_length=100)
99 +
100 +
101 +class GitHubAuditExclusionUpdate(BaseModel):
102 + """Request to update a check exclusion."""
103 +
104 + reason: Optional[str] = None
105 + approved_by: Optional[str] = Field(None, max_length=100)
106 + expires_at: Optional[datetime] = None
107 + enabled: Optional[bool] = None
108 +
109 +
110 +class GitHubAuditBaselineCreate(BaseModel):
111 + """Request to create a baseline."""
112 +
113 + name: str = Field(..., max_length=255)
114 + description: Optional[str] = None
115 + expected_checks: Optional[Dict[str, str]] = None
116 + baseline_report_id: Optional[int] = None
117 + is_active: bool = Field(True)
118 + created_by: str = Field(..., max_length=100)
119 +
120 +
121 +class GitHubAuditBaselineUpdate(BaseModel):
122 + """Request to update a baseline."""
123 +
124 + name: Optional[str] = Field(None, max_length=255)
125 + description: Optional[str] = None
126 + is_active: Optional[bool] = None
127 +
128 +
129 +# ==================== Audit Check Result Schemas ====================
130 +
131 +
132 +class AuditCheck(BaseModel):
133 + """Individual audit check result."""
134 +
135 + check_id: str = Field(..., description="Unique identifier for the check")
136 + check_name: str = Field(..., description="Human-readable check name")
137 + category: str = Field(..., description="Category (e.g., 'repository', 'organization')")
138 + status: AuditStatus = Field(..., description="Pass/Fail/Warning status")
139 + severity: SeverityLevel = Field(..., description="Severity if failed")
140 + description: str = Field(..., description="Description of what was checked")
141 + recommendation: Optional[str] = Field(None, description="Remediation recommendation")
142 + details: Optional[Dict[str, Any]] = Field(None, description="Additional context")
143 + resource_name: Optional[str] = Field(None, description="Name of resource being checked")
144 + resource_type: Optional[str] = Field(None, description="Type of resource")
145 +
146 +
147 +class RepositoryAuditResult(BaseModel):
148 + """Audit results for a single repository."""
149 +
150 + repo_name: str
151 + repo_full_name: str
152 + repo_url: str
153 + is_private: bool
154 + is_archived: bool = False
155 + default_branch: str
156 + checks: List[AuditCheck] = Field(default_factory=list)
157 + passed_count: int = 0
158 + failed_count: int = 0
159 + warning_count: int = 0
160 +
161 +
162 +class OrganizationAuditResult(BaseModel):
163 + """Audit results for organization-level settings."""
164 +
165 + org_name: str
166 + org_url: str
167 + checks: List[AuditCheck] = Field(default_factory=list)
168 + passed_count: int = 0
169 + failed_count: int = 0
170 + warning_count: int = 0
171 +
172 +
173 +class WorkflowAuditResult(BaseModel):
174 + """Audit results for GitHub Actions workflows."""
175 +
176 + repo_name: str
177 + workflow_name: str
178 + workflow_path: str
179 + checks: List[AuditCheck] = Field(default_factory=list)
180 +
181 +
182 +class MemberAuditResult(BaseModel):
183 + """Audit results for organization members."""
184 +
185 + username: str
186 + role: str
187 + has_2fa: Optional[bool] = None
188 + checks: List[AuditCheck] = Field(default_factory=list)
189 +
190 +
191 +class AuditSummary(BaseModel):
192 + """Summary of the entire audit."""
193 +
194 + organization: str
195 + audit_timestamp: str
196 + total_repos_audited: int = 0
197 + total_checks: int = 0
198 + passed_checks: int = 0
199 + failed_checks: int = 0
200 + warning_checks: int = 0
201 + critical_findings: int = 0
202 + high_findings: int = 0
203 + medium_findings: int = 0
204 + low_findings: int = 0
205 + score: float = 0.0
206 + grade: str = "F"
207 +
208 +
209 +# ==================== Response Schemas ====================
210 +
211 +
212 +class GitHubAuditResponse(BaseModel):
213 + """Full GitHub audit response."""
214 +
215 + success: bool
216 + message: str
217 + summary: Optional[AuditSummary] = None
218 + organization_results: Optional[OrganizationAuditResult] = None
219 + repository_results: List[RepositoryAuditResult] = Field(default_factory=list)
220 + workflow_results: List[WorkflowAuditResult] = Field(default_factory=list)
221 + member_results: List[MemberAuditResult] = Field(default_factory=list)
222 + top_findings: List[AuditCheck] = Field(default_factory=list)
223 +
224 +
225 +class GitHubAuditSummaryResponse(BaseModel):
226 + """Lightweight summary response."""
227 +
228 + success: bool
229 + message: str
230 + summary: Optional[AuditSummary] = None
231 + top_findings: List[AuditCheck] = Field(default_factory=list)
232 +
233 +
234 +class GitHubAuditConfigResponse(BaseModel):
235 + """Response for config operations."""
236 +
237 + success: bool
238 + message: str
239 + config: Optional[Any] = None # GitHubAuditConfig model
240 + configs: Optional[List[Any]] = None # List of configs
241 +
242 +
243 +class GitHubAuditReportListResponse(BaseModel):
244 + """Response for listing reports."""
245 +
246 + success: bool
247 + message: str
248 + reports: List[Dict[str, Any]] = Field(default_factory=list)
249 + total_count: int = 0
250 +
251 +
252 +class GitHubAuditReportResponse(BaseModel):
253 + """Response for single report."""
254 +
255 + success: bool
256 + message: str
257 + report: Optional[Any] = None # GitHubAuditReport model
258 +
259 +
260 +class GitHubAuditExclusionResponse(BaseModel):
261 + """Response for exclusion operations."""
262 +
263 + success: bool
264 + message: str
265 + exclusion: Optional[Any] = None
266 + exclusions: Optional[List[Any]] = None
267 +
268 +
269 +class GitHubAuditBaselineResponse(BaseModel):
270 + """Response for baseline operations."""
271 +
272 + success: bool
273 + message: str
274 + baseline: Optional[Any] = None
275 + baselines: Optional[List[Any]] = None
276 +
277 +
278 +class AvailableChecksResponse(BaseModel):
279 + """Response for available checks."""
280 +
281 + success: bool
282 + message: str
283 + checks: List[Dict[str, str]] = Field(default_factory=list)
backend/app/integrations/github_audit/services/github_audit.py new
+1031
@@ -0,0 +1,1031 @@
1 +import asyncio
2 +from datetime import datetime
3 +from datetime import timezone
4 +from typing import Any
5 +from typing import Dict
6 +from typing import List
7 +from typing import Optional
8 +from typing import Tuple
9 +
10 +import httpx
11 +from loguru import logger
12 +
13 +from app.integrations.github_audit.schema.github_audit import AuditCheck
14 +from app.integrations.github_audit.schema.github_audit import AuditStatus
15 +from app.integrations.github_audit.schema.github_audit import AuditSummary
16 +from app.integrations.github_audit.schema.github_audit import GitHubAuditRequest
17 +from app.integrations.github_audit.schema.github_audit import GitHubAuditResponse
18 +from app.integrations.github_audit.schema.github_audit import GitHubAuditSummaryResponse
19 +from app.integrations.github_audit.schema.github_audit import MemberAuditResult
20 +from app.integrations.github_audit.schema.github_audit import OrganizationAuditResult
21 +from app.integrations.github_audit.schema.github_audit import RepositoryAuditResult
22 +from app.integrations.github_audit.schema.github_audit import SeverityLevel
23 +from app.integrations.github_audit.schema.github_audit import WorkflowAuditResult
24 +
25 +# GitHub API base URL
26 +GITHUB_API_BASE = "https://api.github.com"
27 +
28 +
29 +class GitHubAuditService:
30 + """Service for performing GitHub organization security audits"""
31 +
32 + def __init__(self, token: str, organization: str):
33 + self.token = token
34 + self.organization = organization
35 + self.headers = {
36 + "Authorization": f"Bearer {token}",
37 + "Accept": "application/vnd.github+json",
38 + "X-GitHub-Api-Version": "2022-11-28",
39 + }
40 + self.client: Optional[httpx.AsyncClient] = None
41 +
42 + async def __aenter__(self):
43 + self.client = httpx.AsyncClient(timeout=30.0)
44 + return self
45 +
46 + async def __aexit__(self, exc_type, exc_val, exc_tb):
47 + if self.client:
48 + await self.client.aclose()
49 +
50 + async def _request(
51 + self,
52 + method: str,
53 + endpoint: str,
54 + params: Optional[Dict[str, Any]] = None,
55 + ) -> Tuple[Optional[Dict[str, Any]], int]:
56 + """Make a request to GitHub API"""
57 + url = f"{GITHUB_API_BASE}{endpoint}"
58 +
59 + try:
60 + response = await self.client.request(
61 + method,
62 + url,
63 + headers=self.headers,
64 + params=params,
65 + )
66 + if response.status_code == 200:
67 + return response.json(), response.status_code
68 + elif response.status_code == 404:
69 + return None, 404
70 + else:
71 + logger.warning(f"GitHub API error: {response.status_code} - {response.text[:200]}")
72 + return None, response.status_code
73 + except Exception as e:
74 + logger.error(f"GitHub API request failed: {e}")
75 + return None, 500
76 +
77 + async def _paginate(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
78 + """Paginate through GitHub API results"""
79 + results = []
80 + params = params or {}
81 + params["per_page"] = 100
82 + page = 1
83 +
84 + while True:
85 + params["page"] = page
86 + data, status = await self._request("GET", endpoint, params)
87 +
88 + if status != 200 or not data:
89 + break
90 +
91 + if isinstance(data, list):
92 + results.extend(data)
93 + if len(data) < 100:
94 + break
95 + else:
96 + results.append(data)
97 + break
98 +
99 + page += 1
100 +
101 + return results
102 +
103 + # ==================== Organization Checks ====================
104 +
105 + async def audit_organization(self) -> OrganizationAuditResult:
106 + """Audit organization-level security settings"""
107 + logger.info(f"Auditing organization: {self.organization}")
108 +
109 + checks: List[AuditCheck] = []
110 +
111 + # Get organization info
112 + org_data, status = await self._request("GET", f"/orgs/{self.organization}")
113 +
114 + if status != 200 or not org_data:
115 + return OrganizationAuditResult(
116 + org_name=self.organization,
117 + org_url=f"https://github.com/{self.organization}",
118 + checks=[
119 + AuditCheck(
120 + check_id="org-access",
121 + check_name="Organization Access",
122 + category="organization",
123 + status=AuditStatus.FAIL,
124 + severity=SeverityLevel.CRITICAL,
125 + description="Unable to access organization",
126 + recommendation="Verify the token has org:read permissions",
127 + ),
128 + ],
129 + failed_count=1,
130 + )
131 +
132 + # Check: Two-factor authentication requirement
133 + checks.append(await self._check_2fa_requirement(org_data))
134 +
135 + # Check: Default repository permission
136 + checks.append(await self._check_default_repo_permission(org_data))
137 +
138 + # Check: Members can create repositories
139 + checks.append(await self._check_member_repo_creation(org_data))
140 +
141 + # Check: Members can create public repositories
142 + checks.append(await self._check_public_repo_creation(org_data))
143 +
144 + # Check: Verified domains
145 + checks.append(await self._check_verified_domains())
146 +
147 + # Check: SSO enforcement
148 + checks.append(await self._check_sso_enforcement())
149 +
150 + # Calculate counts
151 + passed = sum(1 for c in checks if c.status == AuditStatus.PASS)
152 + failed = sum(1 for c in checks if c.status == AuditStatus.FAIL)
153 + warnings = sum(1 for c in checks if c.status == AuditStatus.WARNING)
154 +
155 + return OrganizationAuditResult(
156 + org_name=self.organization,
157 + org_url=org_data.get("html_url", f"https://github.com/{self.organization}"),
158 + checks=checks,
159 + passed_count=passed,
160 + failed_count=failed,
161 + warning_count=warnings,
162 + )
163 +
164 + async def _check_2fa_requirement(self, org_data: Dict[str, Any]) -> AuditCheck:
165 + """Check if 2FA is required for organization members"""
166 + two_factor_required = org_data.get("two_factor_requirement_enabled", False)
167 +
168 + return AuditCheck(
169 + check_id="org-2fa-required",
170 + check_name="Two-Factor Authentication Required",
171 + category="organization",
172 + status=AuditStatus.PASS if two_factor_required else AuditStatus.FAIL,
173 + severity=SeverityLevel.CRITICAL,
174 + description="Checks if 2FA is required for all organization members",
175 + recommendation="Enable 2FA requirement in Organization Settings > Authentication security" if not two_factor_required else None,
176 + resource_name=self.organization,
177 + resource_type="organization",
178 + )
179 +
180 + async def _check_default_repo_permission(self, org_data: Dict[str, Any]) -> AuditCheck:
181 + """Check default repository permission level"""
182 + default_permission = org_data.get("default_repository_permission", "read")
183 +
184 + # Acceptable: none, read. Risky: write, admin
185 + is_secure = default_permission in ["none", "read"]
186 +
187 + return AuditCheck(
188 + check_id="org-default-permission",
189 + check_name="Default Repository Permission",
190 + category="organization",
191 + status=AuditStatus.PASS if is_secure else AuditStatus.WARNING,
192 + severity=SeverityLevel.MEDIUM,
193 + description=f"Default repository permission is set to '{default_permission}'",
194 + recommendation="Set default repository permission to 'read' or 'none' to follow least privilege" if not is_secure else None,
195 + details={"current_permission": default_permission},
196 + resource_name=self.organization,
197 + resource_type="organization",
198 + )
199 +
200 + async def _check_member_repo_creation(self, org_data: Dict[str, Any]) -> AuditCheck:
201 + """Check if members can create repositories"""
202 + can_create = org_data.get("members_can_create_repositories", True)
203 +
204 + return AuditCheck(
205 + check_id="org-member-repo-creation",
206 + check_name="Member Repository Creation",
207 + category="organization",
208 + status=AuditStatus.WARNING if can_create else AuditStatus.PASS,
209 + severity=SeverityLevel.LOW,
210 + description="Members can create repositories" if can_create else "Members cannot create repositories",
211 + recommendation="Consider restricting repository creation to admins for better governance" if can_create else None,
212 + resource_name=self.organization,
213 + resource_type="organization",
214 + )
215 +
216 + async def _check_public_repo_creation(self, org_data: Dict[str, Any]) -> AuditCheck:
217 + """Check if members can create public repositories"""
218 + can_create_public = org_data.get("members_can_create_public_repositories", True)
219 +
220 + return AuditCheck(
221 + check_id="org-public-repo-creation",
222 + check_name="Public Repository Creation",
223 + category="organization",
224 + status=AuditStatus.FAIL if can_create_public else AuditStatus.PASS,
225 + severity=SeverityLevel.HIGH,
226 + description="Members can create public repositories" if can_create_public else "Members cannot create public repositories",
227 + recommendation="Restrict public repository creation to prevent accidental exposure of internal code"
228 + if can_create_public
229 + else None,
230 + resource_name=self.organization,
231 + resource_type="organization",
232 + )
233 +
234 + async def _check_verified_domains(self) -> AuditCheck:
235 + """Check if organization has verified domains"""
236 + domains, status = await self._request("GET", f"/orgs/{self.organization}/domains")
237 +
238 + if status == 404:
239 + return AuditCheck(
240 + check_id="org-verified-domains",
241 + check_name="Verified Domains",
242 + category="organization",
243 + status=AuditStatus.NOT_APPLICABLE,
244 + severity=SeverityLevel.INFO,
245 + description="Domain verification not available (requires GitHub Enterprise)",
246 + resource_name=self.organization,
247 + resource_type="organization",
248 + )
249 +
250 + verified_domains = []
251 + if domains and isinstance(domains, list):
252 + verified_domains = [d for d in domains if d.get("is_verified", False)]
253 +
254 + has_verified = len(verified_domains) > 0
255 +
256 + return AuditCheck(
257 + check_id="org-verified-domains",
258 + check_name="Verified Domains",
259 + category="organization",
260 + status=AuditStatus.PASS if has_verified else AuditStatus.WARNING,
261 + severity=SeverityLevel.MEDIUM,
262 + description=f"Organization has {len(verified_domains)} verified domain(s)"
263 + if has_verified
264 + else "Organization has no verified domains",
265 + recommendation="Add and verify your organization's domain to improve trust and enable additional features"
266 + if not has_verified
267 + else None,
268 + details={"verified_domains": [d.get("domain") for d in verified_domains]} if verified_domains else None,
269 + resource_name=self.organization,
270 + resource_type="organization",
271 + )
272 +
273 + async def _check_sso_enforcement(self) -> AuditCheck:
274 + """Check if SAML SSO is enforced"""
275 + # This requires enterprise API access
276 + saml_data, status = await self._request("GET", f"/orgs/{self.organization}/saml")
277 +
278 + if status == 404:
279 + return AuditCheck(
280 + check_id="org-sso-enforcement",
281 + check_name="SAML SSO Enforcement",
282 + category="organization",
283 + status=AuditStatus.NOT_APPLICABLE,
284 + severity=SeverityLevel.INFO,
285 + description="SAML SSO configuration not available (requires GitHub Enterprise Cloud)",
286 + resource_name=self.organization,
287 + resource_type="organization",
288 + )
289 +
290 + enforced = saml_data.get("enforced", False) if saml_data else False
291 +
292 + return AuditCheck(
293 + check_id="org-sso-enforcement",
294 + check_name="SAML SSO Enforcement",
295 + category="organization",
296 + status=AuditStatus.PASS if enforced else AuditStatus.WARNING,
297 + severity=SeverityLevel.HIGH,
298 + description="SAML SSO is enforced" if enforced else "SAML SSO is not enforced",
299 + recommendation="Enable SAML SSO enforcement for centralized authentication" if not enforced else None,
300 + resource_name=self.organization,
301 + resource_type="organization",
302 + )
303 +
304 + # ==================== Repository Checks ====================
305 +
306 + async def audit_repositories(
307 + self,
308 + repo_filter: Optional[List[str]] = None,
309 + ) -> List[RepositoryAuditResult]:
310 + """Audit all repositories in the organization"""
311 + logger.info(f"Auditing repositories for organization: {self.organization}")
312 +
313 + # Get all repositories
314 + repos = await self._paginate(f"/orgs/{self.organization}/repos")
315 +
316 + if not repos:
317 + logger.warning("No repositories found or unable to fetch repositories")
318 + return []
319 +
320 + # Filter repos if specified
321 + if repo_filter:
322 + repos = [r for r in repos if r.get("name") in repo_filter]
323 +
324 + results = []
325 +
326 + # Audit repos concurrently in batches
327 + batch_size = 10
328 + for i in range(0, len(repos), batch_size):
329 + batch = repos[i : i + batch_size]
330 + batch_results = await asyncio.gather(*[self._audit_single_repo(repo) for repo in batch])
331 + results.extend(batch_results)
332 +
333 + return results
334 +
335 + async def _audit_single_repo(self, repo: Dict[str, Any]) -> RepositoryAuditResult:
336 + """Audit a single repository"""
337 + repo_name = repo.get("name", "unknown")
338 + full_name = repo.get("full_name", f"{self.organization}/{repo_name}")
339 +
340 + logger.debug(f"Auditing repository: {full_name}")
341 +
342 + checks: List[AuditCheck] = []
343 +
344 + # Skip archived repos
345 + if repo.get("archived", False):
346 + return RepositoryAuditResult(
347 + repo_name=repo_name,
348 + repo_full_name=full_name,
349 + repo_url=repo.get("html_url", ""),
350 + is_private=repo.get("private", True),
351 + is_archived=True,
352 + default_branch=repo.get("default_branch", "main"),
353 + checks=[
354 + AuditCheck(
355 + check_id="repo-archived",
356 + check_name="Repository Archived",
357 + category="repository",
358 + status=AuditStatus.NOT_APPLICABLE,
359 + severity=SeverityLevel.INFO,
360 + description="Repository is archived - skipping security checks",
361 + resource_name=repo_name,
362 + resource_type="repository",
363 + ),
364 + ],
365 + )
366 +
367 + # Check: Branch protection
368 + checks.append(await self._check_branch_protection(repo))
369 +
370 + # Check: Secret scanning
371 + checks.append(await self._check_secret_scanning(repo))
372 +
373 + # Check: Dependabot alerts
374 + checks.append(await self._check_dependabot_alerts(repo))
375 +
376 + # Check: Code scanning
377 + checks.append(await self._check_code_scanning(repo))
378 +
379 + # Check: Private vulnerability reporting
380 + checks.append(await self._check_private_vulnerability_reporting(repo))
381 +
382 + # Check: License
383 + checks.append(await self._check_license(repo))
384 +
385 + # Check: Default branch protection
386 + checks.append(await self._check_default_branch_deletion_protection(repo))
387 +
388 + # Calculate counts
389 + passed = sum(1 for c in checks if c.status == AuditStatus.PASS)
390 + failed = sum(1 for c in checks if c.status == AuditStatus.FAIL)
391 + warnings = sum(1 for c in checks if c.status == AuditStatus.WARNING)
392 +
393 + return RepositoryAuditResult(
394 + repo_name=repo_name,
395 + repo_full_name=full_name,
396 + repo_url=repo.get("html_url", ""),
397 + is_private=repo.get("private", True),
398 + is_archived=False,
399 + default_branch=repo.get("default_branch", "main"),
400 + checks=checks,
401 + passed_count=passed,
402 + failed_count=failed,
403 + warning_count=warnings,
404 + )
405 +
406 + async def _check_branch_protection(self, repo: Dict[str, Any]) -> AuditCheck:
407 + """Check if default branch has protection rules"""
408 + repo_name = repo.get("name")
409 + default_branch = repo.get("default_branch", "main")
410 +
411 + protection, status = await self._request(
412 + "GET",
413 + f"/repos/{self.organization}/{repo_name}/branches/{default_branch}/protection",
414 + )
415 +
416 + if status == 404:
417 + return AuditCheck(
418 + check_id="repo-branch-protection",
419 + check_name="Default Branch Protection",
420 + category="repository",
421 + status=AuditStatus.FAIL,
422 + severity=SeverityLevel.HIGH,
423 + description=f"Default branch '{default_branch}' has no protection rules",
424 + recommendation="Enable branch protection rules to prevent direct pushes and require reviews",
425 + resource_name=repo_name,
426 + resource_type="repository",
427 + )
428 +
429 + # Check specific protection settings
430 + details = {}
431 + issues = []
432 +
433 + if protection:
434 + required_reviews = protection.get("required_pull_request_reviews")
435 + if not required_reviews:
436 + issues.append("Pull request reviews not required")
437 + else:
438 + details["required_approving_reviews"] = required_reviews.get("required_approving_review_count", 0)
439 +
440 + if not protection.get("enforce_admins", {}).get("enabled", False):
441 + issues.append("Admins can bypass protection")
442 +
443 + if not protection.get("required_status_checks"):
444 + issues.append("No required status checks")
445 +
446 + details["dismiss_stale_reviews"] = (required_reviews or {}).get("dismiss_stale_reviews", False)
447 + details["require_code_owner_reviews"] = (required_reviews or {}).get("require_code_owner_reviews", False)
448 +
449 + if issues:
450 + return AuditCheck(
451 + check_id="repo-branch-protection",
452 + check_name="Default Branch Protection",
453 + category="repository",
454 + status=AuditStatus.WARNING,
455 + severity=SeverityLevel.MEDIUM,
456 + description=f"Branch protection enabled but with gaps: {', '.join(issues)}",
457 + recommendation="Strengthen branch protection by requiring reviews and enforcing for admins",
458 + details=details,
459 + resource_name=repo_name,
460 + resource_type="repository",
461 + )
462 +
463 + return AuditCheck(
464 + check_id="repo-branch-protection",
465 + check_name="Default Branch Protection",
466 + category="repository",
467 + status=AuditStatus.PASS,
468 + severity=SeverityLevel.HIGH,
469 + description=f"Default branch '{default_branch}' has protection rules enabled",
470 + details=details,
471 + resource_name=repo_name,
472 + resource_type="repository",
473 + )
474 +
475 + async def _check_secret_scanning(self, repo: Dict[str, Any]) -> AuditCheck:
476 + """Check if secret scanning is enabled"""
477 + repo_name = repo.get("name")
478 +
479 + security_config, status = await self._request(
480 + "GET",
481 + f"/repos/{self.organization}/{repo_name}",
482 + )
483 +
484 + if status != 200:
485 + return AuditCheck(
486 + check_id="repo-secret-scanning",
487 + check_name="Secret Scanning",
488 + category="repository",
489 + status=AuditStatus.NOT_APPLICABLE,
490 + severity=SeverityLevel.INFO,
491 + description="Unable to check secret scanning status",
492 + resource_name=repo_name,
493 + resource_type="repository",
494 + )
495 +
496 + security_and_analysis = security_config.get("security_and_analysis", {}) if security_config else {}
497 + secret_scanning = security_and_analysis.get("secret_scanning", {})
498 + secret_scanning_push = security_and_analysis.get("secret_scanning_push_protection", {})
499 +
500 + scanning_enabled = secret_scanning.get("status") == "enabled"
501 + push_protection_enabled = secret_scanning_push.get("status") == "enabled"
502 +
503 + if scanning_enabled and push_protection_enabled:
504 + return AuditCheck(
505 + check_id="repo-secret-scanning",
506 + check_name="Secret Scanning",
507 + category="repository",
508 + status=AuditStatus.PASS,
509 + severity=SeverityLevel.HIGH,
510 + description="Secret scanning and push protection are enabled",
511 + details={"secret_scanning": True, "push_protection": True},
512 + resource_name=repo_name,
513 + resource_type="repository",
514 + )
515 + elif scanning_enabled:
516 + return AuditCheck(
517 + check_id="repo-secret-scanning",
518 + check_name="Secret Scanning",
519 + category="repository",
520 + status=AuditStatus.WARNING,
521 + severity=SeverityLevel.MEDIUM,
522 + description="Secret scanning enabled but push protection is disabled",
523 + recommendation="Enable secret scanning push protection to block secrets before they're committed",
524 + details={"secret_scanning": True, "push_protection": False},
525 + resource_name=repo_name,
526 + resource_type="repository",
527 + )
528 + else:
529 + return AuditCheck(
530 + check_id="repo-secret-scanning",
531 + check_name="Secret Scanning",
532 + category="repository",
533 + status=AuditStatus.FAIL,
534 + severity=SeverityLevel.HIGH,
535 + description="Secret scanning is not enabled",
536 + recommendation="Enable secret scanning in repository Security settings",
537 + details={"secret_scanning": False, "push_protection": False},
538 + resource_name=repo_name,
539 + resource_type="repository",
540 + )
541 +
542 + async def _check_dependabot_alerts(self, repo: Dict[str, Any]) -> AuditCheck:
543 + """Check if Dependabot alerts are enabled"""
544 + repo_name = repo.get("name")
545 +
546 + # Check vulnerability alerts status
547 + vuln_alerts, status = await self._request(
548 + "GET",
549 + f"/repos/{self.organization}/{repo_name}/vulnerability-alerts",
550 + )
551 +
552 + if status == 204: # 204 means enabled
553 + return AuditCheck(
554 + check_id="repo-dependabot-alerts",
555 + check_name="Dependabot Alerts",
556 + category="repository",
557 + status=AuditStatus.PASS,
558 + severity=SeverityLevel.HIGH,
559 + description="Dependabot vulnerability alerts are enabled",
560 + resource_name=repo_name,
561 + resource_type="repository",
562 + )
563 + elif status == 404:
564 + return AuditCheck(
565 + check_id="repo-dependabot-alerts",
566 + check_name="Dependabot Alerts",
567 + category="repository",
568 + status=AuditStatus.FAIL,
569 + severity=SeverityLevel.HIGH,
570 + description="Dependabot vulnerability alerts are not enabled",
571 + recommendation="Enable Dependabot alerts in repository Security settings",
572 + resource_name=repo_name,
573 + resource_type="repository",
574 + )
575 + else:
576 + return AuditCheck(
577 + check_id="repo-dependabot-alerts",
578 + check_name="Dependabot Alerts",
579 + category="repository",
580 + status=AuditStatus.NOT_APPLICABLE,
581 + severity=SeverityLevel.INFO,
582 + description="Unable to determine Dependabot alerts status",
583 + resource_name=repo_name,
584 + resource_type="repository",
585 + )
586 +
587 + async def _check_code_scanning(self, repo: Dict[str, Any]) -> AuditCheck:
588 + """Check if code scanning is enabled"""
589 + repo_name = repo.get("name")
590 +
591 + # Check for code scanning alerts
592 + alerts, status = await self._request(
593 + "GET",
594 + f"/repos/{self.organization}/{repo_name}/code-scanning/alerts",
595 + params={"per_page": 1},
596 + )
597 +
598 + if status == 200:
599 + return AuditCheck(
600 + check_id="repo-code-scanning",
601 + check_name="Code Scanning",
602 + category="repository",
603 + status=AuditStatus.PASS,
604 + severity=SeverityLevel.MEDIUM,
605 + description="Code scanning is enabled",
606 + resource_name=repo_name,
607 + resource_type="repository",
608 + )
609 + elif status == 404:
610 + return AuditCheck(
611 + check_id="repo-code-scanning",
612 + check_name="Code Scanning",
613 + category="repository",
614 + status=AuditStatus.WARNING,
615 + severity=SeverityLevel.MEDIUM,
616 + description="Code scanning is not configured",
617 + recommendation="Enable GitHub Advanced Security and configure CodeQL analysis",
618 + resource_name=repo_name,
619 + resource_type="repository",
620 + )
621 + else:
622 + return AuditCheck(
623 + check_id="repo-code-scanning",
624 + check_name="Code Scanning",
625 + category="repository",
626 + status=AuditStatus.NOT_APPLICABLE,
627 + severity=SeverityLevel.INFO,
628 + description="Unable to determine code scanning status",
629 + resource_name=repo_name,
630 + resource_type="repository",
631 + )
632 +
633 + async def _check_private_vulnerability_reporting(self, repo: Dict[str, Any]) -> AuditCheck:
634 + """Check if private vulnerability reporting is enabled"""
635 + repo_name = repo.get("name")
636 +
637 + # This is available in the repo data
638 + pvr_enabled = repo.get("private_vulnerability_reporting_enabled", False)
639 +
640 + return AuditCheck(
641 + check_id="repo-private-vuln-reporting",
642 + check_name="Private Vulnerability Reporting",
643 + category="repository",
644 + status=AuditStatus.PASS if pvr_enabled else AuditStatus.WARNING,
645 + severity=SeverityLevel.LOW,
646 + description="Private vulnerability reporting is enabled" if pvr_enabled else "Private vulnerability reporting is not enabled",
647 + recommendation="Enable private vulnerability reporting to allow security researchers to report issues confidentially"
648 + if not pvr_enabled
649 + else None,
650 + resource_name=repo_name,
651 + resource_type="repository",
652 + )
653 +
654 + async def _check_license(self, repo: Dict[str, Any]) -> AuditCheck:
655 + """Check if repository has a license"""
656 + repo_name = repo.get("name")
657 + license_info = repo.get("license")
658 + is_private = repo.get("private", True)
659 +
660 + if is_private:
661 + return AuditCheck(
662 + check_id="repo-license",
663 + check_name="Repository License",
664 + category="repository",
665 + status=AuditStatus.NOT_APPLICABLE,
666 + severity=SeverityLevel.INFO,
667 + description="License check not applicable for private repositories",
668 + resource_name=repo_name,
669 + resource_type="repository",
670 + )
671 +
672 + has_license = license_info is not None
673 +
674 + return AuditCheck(
675 + check_id="repo-license",
676 + check_name="Repository License",
677 + category="repository",
678 + status=AuditStatus.PASS if has_license else AuditStatus.WARNING,
679 + severity=SeverityLevel.LOW,
680 + description=f"Repository has license: {license_info.get('name', 'Unknown')}"
681 + if has_license
682 + else "Public repository has no license",
683 + recommendation="Add a LICENSE file to clarify usage terms for public repositories" if not has_license else None,
684 + details={"license": license_info.get("spdx_id") if license_info else None},
685 + resource_name=repo_name,
686 + resource_type="repository",
687 + )
688 +
689 + async def _check_default_branch_deletion_protection(self, repo: Dict[str, Any]) -> AuditCheck:
690 + """Check if default branch deletion is protected"""
691 + repo_name = repo.get("name")
692 + default_branch = repo.get("default_branch", "main")
693 +
694 + branch_info, status = await self._request(
695 + "GET",
696 + f"/repos/{self.organization}/{repo_name}/branches/{default_branch}",
697 + )
698 +
699 + if status != 200 or not branch_info:
700 + return AuditCheck(
701 + check_id="repo-branch-deletion",
702 + check_name="Default Branch Deletion Protection",
703 + category="repository",
704 + status=AuditStatus.NOT_APPLICABLE,
705 + severity=SeverityLevel.INFO,
706 + description="Unable to check branch deletion protection",
707 + resource_name=repo_name,
708 + resource_type="repository",
709 + )
710 +
711 + protected = branch_info.get("protected", False)
712 +
713 + return AuditCheck(
714 + check_id="repo-branch-deletion",
715 + check_name="Default Branch Deletion Protection",
716 + category="repository",
717 + status=AuditStatus.PASS if protected else AuditStatus.FAIL,
718 + severity=SeverityLevel.HIGH,
719 + description=f"Default branch '{default_branch}' is protected from deletion"
720 + if protected
721 + else f"Default branch '{default_branch}' can be deleted",
722 + recommendation="Enable branch protection to prevent accidental deletion of default branch" if not protected else None,
723 + resource_name=repo_name,
724 + resource_type="repository",
725 + )
726 +
727 + # ==================== Workflow/Actions Checks ====================
728 +
729 + async def audit_workflows(self) -> List[WorkflowAuditResult]:
730 + """Audit GitHub Actions settings and workflows"""
731 + logger.info(f"Auditing GitHub Actions for organization: {self.organization}")
732 +
733 + results: List[WorkflowAuditResult] = []
734 +
735 + # Get org-level actions permissions
736 + actions_perms, status = await self._request("GET", f"/orgs/{self.organization}/actions/permissions")
737 +
738 + if status == 200 and actions_perms:
739 + checks = []
740 +
741 + # Check allowed actions
742 + allowed_actions = actions_perms.get("allowed_actions", "all")
743 + if allowed_actions == "all":
744 + checks.append(
745 + AuditCheck(
746 + check_id="actions-allowed-all",
747 + check_name="Actions Permission Policy",
748 + category="workflow",
749 + status=AuditStatus.WARNING,
750 + severity=SeverityLevel.MEDIUM,
751 + description="All GitHub Actions are allowed to run",
752 + recommendation="Restrict to verified creators or specific allowed actions",
753 + resource_name=self.organization,
754 + resource_type="organization",
755 + ),
756 + )
757 + elif allowed_actions == "selected":
758 + checks.append(
759 + AuditCheck(
760 + check_id="actions-allowed-selected",
761 + check_name="Actions Permission Policy",
762 + category="workflow",
763 + status=AuditStatus.PASS,
764 + severity=SeverityLevel.MEDIUM,
765 + description="Only selected GitHub Actions are allowed",
766 + resource_name=self.organization,
767 + resource_type="organization",
768 + ),
769 + )
770 +
771 + # Check default workflow permissions
772 + default_perms, _ = await self._request("GET", f"/orgs/{self.organization}/actions/permissions/workflow")
773 +
774 + if default_perms:
775 + default_token_perms = default_perms.get("default_workflow_permissions", "write")
776 + if default_token_perms == "write":
777 + checks.append(
778 + AuditCheck(
779 + check_id="actions-default-token-perms",
780 + check_name="Default Workflow Token Permissions",
781 + category="workflow",
782 + status=AuditStatus.WARNING,
783 + severity=SeverityLevel.MEDIUM,
784 + description="Default workflow token has write permissions",
785 + recommendation="Set default workflow permissions to 'read' and grant write access explicitly where needed",
786 + resource_name=self.organization,
787 + resource_type="organization",
788 + ),
789 + )
790 + else:
791 + checks.append(
792 + AuditCheck(
793 + check_id="actions-default-token-perms",
794 + check_name="Default Workflow Token Permissions",
795 + category="workflow",
796 + status=AuditStatus.PASS,
797 + severity=SeverityLevel.MEDIUM,
798 + description="Default workflow token has read-only permissions",
799 + resource_name=self.organization,
800 + resource_type="organization",
801 + ),
802 + )
803 +
804 + results.append(
805 + WorkflowAuditResult(
806 + repo_name=self.organization,
807 + workflow_name="Organization Actions Settings",
808 + workflow_path="N/A",
809 + checks=checks,
810 + ),
811 + )
812 +
813 + return results
814 +
815 + # ==================== Member Checks ====================
816 +
817 + async def audit_members(self) -> List[MemberAuditResult]:
818 + """Audit organization members"""
819 + logger.info(f"Auditing members for organization: {self.organization}")
820 +
821 + results: List[MemberAuditResult] = []
822 +
823 + # Get all members
824 + members = await self._paginate(f"/orgs/{self.organization}/members")
825 +
826 + # Get admins
827 + admins = await self._paginate(f"/orgs/{self.organization}/members", params={"role": "admin"})
828 + admin_logins = set(m.get("login") for m in admins)
829 +
830 + for member in members:
831 + login = member.get("login", "unknown")
832 + is_admin = login in admin_logins
833 +
834 + checks = []
835 +
836 + # Check: Admin count
837 + if is_admin:
838 + checks.append(
839 + AuditCheck(
840 + check_id="member-is-admin",
841 + check_name="Admin Role",
842 + category="member",
843 + status=AuditStatus.WARNING,
844 + severity=SeverityLevel.LOW,
845 + description=f"User '{login}' has admin role",
846 + recommendation="Regularly review admin access and remove if not needed",
847 + resource_name=login,
848 + resource_type="member",
849 + ),
850 + )
851 +
852 + results.append(
853 + MemberAuditResult(
854 + username=login,
855 + role="admin" if is_admin else "member",
856 + checks=checks,
857 + ),
858 + )
859 +
860 + return results
861 +
862 + # ==================== Main Audit Function ====================
863 +
864 + async def run_full_audit(self, request: GitHubAuditRequest) -> GitHubAuditResponse:
865 + """Run a complete security audit"""
866 + logger.info(f"Starting full security audit for organization: {self.organization}")
867 +
868 + try:
869 + org_results = await self.audit_organization()
870 +
871 + repo_results = []
872 + if request.include_repos:
873 + repo_results = await self.audit_repositories(request.repo_filter)
874 +
875 + workflow_results = []
876 + if request.include_workflows:
877 + workflow_results = await self.audit_workflows()
878 +
879 + member_results = []
880 + if request.include_members:
881 + member_results = await self.audit_members()
882 +
883 + # Calculate summary
884 + all_checks: List[AuditCheck] = []
885 + all_checks.extend(org_results.checks)
886 + for repo in repo_results:
887 + all_checks.extend(repo.checks)
888 + for wf in workflow_results:
889 + all_checks.extend(wf.checks)
890 + for member in member_results:
891 + all_checks.extend(member.checks)
892 +
893 + # Filter out NOT_APPLICABLE checks for scoring
894 + scorable_checks = [c for c in all_checks if c.status != AuditStatus.NOT_APPLICABLE]
895 +
896 + total_checks = len(scorable_checks)
897 + passed = sum(1 for c in scorable_checks if c.status == AuditStatus.PASS)
898 + failed = sum(1 for c in scorable_checks if c.status == AuditStatus.FAIL)
899 + warnings = sum(1 for c in scorable_checks if c.status == AuditStatus.WARNING)
900 +
901 + # Count findings by severity (FAIL status only for severity counts)
902 + critical = sum(1 for c in scorable_checks if c.status == AuditStatus.FAIL and c.severity == SeverityLevel.CRITICAL)
903 + high = sum(1 for c in scorable_checks if c.status == AuditStatus.FAIL and c.severity == SeverityLevel.HIGH)
904 + medium = sum(
905 + 1 for c in scorable_checks if c.status in [AuditStatus.FAIL, AuditStatus.WARNING] and c.severity == SeverityLevel.MEDIUM
906 + )
907 + low = sum(1 for c in scorable_checks if c.status in [AuditStatus.FAIL, AuditStatus.WARNING] and c.severity == SeverityLevel.LOW)
908 +
909 + # Calculate score using weighted pass rate
910 + # Weight checks by severity: CRITICAL=4, HIGH=3, MEDIUM=2, LOW=1
911 + if total_checks > 0:
912 + total_weight = 0
913 + earned_weight = 0
914 +
915 + for check in scorable_checks:
916 + weight = 1 # default
917 + if check.severity == SeverityLevel.CRITICAL:
918 + weight = 4
919 + elif check.severity == SeverityLevel.HIGH:
920 + weight = 3
921 + elif check.severity == SeverityLevel.MEDIUM:
922 + weight = 2
923 + elif check.severity == SeverityLevel.LOW:
924 + weight = 1
925 +
926 + total_weight += weight
927 + if check.status == AuditStatus.PASS:
928 + earned_weight += weight
929 + elif check.status == AuditStatus.WARNING:
930 + # Warnings get partial credit
931 + earned_weight += weight * 0.5
932 +
933 + score = (earned_weight / total_weight) * 100 if total_weight > 0 else 100.0
934 + else:
935 + score = 100.0 # No checks = perfect score
936 +
937 + # Round to 1 decimal place
938 + score = round(score, 1)
939 +
940 + # Determine grade
941 + if score >= 90:
942 + grade = "A"
943 + elif score >= 80:
944 + grade = "B"
945 + elif score >= 70:
946 + grade = "C"
947 + elif score >= 60:
948 + grade = "D"
949 + else:
950 + grade = "F"
951 +
952 + logger.info(
953 + f"Audit complete - Total: {total_checks}, Passed: {passed}, "
954 + f"Failed: {failed}, Warnings: {warnings}, Score: {score}, Grade: {grade}",
955 + )
956 +
957 + summary = AuditSummary(
958 + organization=self.organization,
959 + audit_timestamp=datetime.now(timezone.utc).isoformat(),
960 + total_repos_audited=len(repo_results),
961 + total_checks=total_checks,
962 + passed_checks=passed,
963 + failed_checks=failed,
964 + warning_checks=warnings,
965 + critical_findings=critical,
966 + high_findings=high,
967 + medium_findings=medium,
968 + low_findings=low,
969 + score=score,
970 + grade=grade,
971 + )
972 +
973 + # Get top findings (failed checks sorted by severity)
974 + severity_order = {
975 + SeverityLevel.CRITICAL: 0,
976 + SeverityLevel.HIGH: 1,
977 + SeverityLevel.MEDIUM: 2,
978 + SeverityLevel.LOW: 3,
979 + SeverityLevel.INFO: 4,
980 + }
981 + failed_checks = [c for c in all_checks if c.status in [AuditStatus.FAIL, AuditStatus.WARNING]]
982 + top_findings = sorted(failed_checks, key=lambda c: severity_order.get(c.severity, 5))[:20]
983 +
984 + return GitHubAuditResponse(
985 + success=True,
986 + message=f"Audit completed successfully. Score: {score} ({grade},)",
987 + summary=summary,
988 + organization_results=org_results,
989 + repository_results=repo_results,
990 + workflow_results=workflow_results,
991 + member_results=member_results,
992 + top_findings=top_findings,
993 + )
994 +
995 + except Exception as e:
996 + logger.error(f"Audit failed: {e}")
997 + return GitHubAuditResponse(
998 + success=False,
999 + message=f"Audit failed: {e}",
1000 + summary=None,
1001 + organization_results=None,
1002 + repository_results=[],
1003 + workflow_results=[],
1004 + member_results=[],
1005 + top_findings=[],
1006 + )
1007 +
1008 +
1009 +# Service function wrappers
1010 +async def run_github_audit(
1011 + token: str,
1012 + request: GitHubAuditRequest,
1013 +) -> GitHubAuditResponse:
1014 + """Run a GitHub organization security audit"""
1015 + async with GitHubAuditService(token, request.organization) as service:
1016 + return await service.run_full_audit(request)
1017 +
1018 +
1019 +async def run_github_audit_summary(
1020 + token: str,
1021 + request: GitHubAuditRequest,
1022 +) -> GitHubAuditSummaryResponse:
1023 + """Run a GitHub audit and return summary only"""
1024 + response = await run_github_audit(token, request)
1025 +
1026 + return GitHubAuditSummaryResponse(
1027 + success=response.success,
1028 + message=response.message,
1029 + summary=response.summary,
1030 + top_findings=response.top_findings[:10],
1031 + )
backend/app/routers/github_audit.py new
+13
@@ -0,0 +1,13 @@
1 +from fastapi import APIRouter
2 +
3 +from app.integrations.github_audit.routes.github_audit import github_audit_router
4 +
5 +# Instantiate the APIRouter
6 +router = APIRouter()
7 +
8 +# Include the GitHub Audit related routes
9 +router.include_router(
10 + github_audit_router,
11 + prefix="/github-audit",
12 + tags=["GitHub Audit"],
13 +)
backend/app/version/services/version.py
+1 -1
@@ -7,7 +7,7 @@ from loguru import logger
7 from packaging.version import Version
8
9 # Current version - update this with each release
10 -CURRENT_VERSION = "0.1.40"
10 +CURRENT_VERSION = "0.1.41"
11 VERSION_CHECK_URL = "https://api.github.com/repos/socfortress/CoPilot/releases/latest"
12
13
backend/copilot.py
+2
@@ -51,6 +51,7 @@ from app.routers import defenderforendpoint
51 from app.routers import dfir_iris
52 from app.routers import dnstwist
53 from app.routers import duo
54 +from app.routers import github_audit
55 from app.routers import grafana
56 from app.routers import graylog
57 from app.routers import healthcheck
@@ -135,6 +136,7 @@ api_router.include_router(dfir_iris.router)
136 api_router.include_router(cortex.router)
137 api_router.include_router(velociraptor.router)
138 api_router.include_router(shuffle.router)
139 +api_router.include_router(github_audit.router)
140 api_router.include_router(sublime.router)
141 api_router.include_router(microsoft_patch_tuesday.router)
142 api_router.include_router(customers.router)
frontend/src/api/endpoints/githubAudit.ts new
+228
@@ -0,0 +1,228 @@
1 +import type {
2 + AvailableChecksResponse,
3 + DeleteResponse,
4 + GitHubAuditBaselineCreate,
5 + GitHubAuditBaselineResponse,
6 + GitHubAuditConfigCreate,
7 + GitHubAuditConfigResponse,
8 + GitHubAuditConfigUpdate,
9 + GitHubAuditExclusionCreate,
10 + GitHubAuditExclusionResponse,
11 + GitHubAuditExclusionUpdate,
12 + GitHubAuditReportListResponse,
13 + GitHubAuditReportResponse,
14 + GitHubAuditRequest,
15 + GitHubAuditResponse,
16 + GitHubAuditSummaryResponse
17 +} from "@/types/githubAudit.d"
18 +import { HttpClient } from "../httpClient"
19 +
20 +const BASE_PATH = "/github-audit"
21 +
22 +export default {
23 + // ==================== Configuration Endpoints ====================
24 +
25 + /**
26 + * Create a new GitHub Audit configuration
27 + */
28 + createConfig(config: GitHubAuditConfigCreate, signal?: AbortSignal) {
29 + return HttpClient.post<GitHubAuditConfigResponse>(`${BASE_PATH}/config`, config, { signal })
30 + },
31 +
32 + /**
33 + * Get all GitHub Audit configurations
34 + */
35 + getConfigs(customerCode?: string, signal?: AbortSignal) {
36 + const params = customerCode ? { customer_code: customerCode } : undefined
37 + return HttpClient.get<GitHubAuditConfigResponse>(`${BASE_PATH}/config`, { params, signal })
38 + },
39 +
40 + /**
41 + * Get a specific GitHub Audit configuration by ID
42 + */
43 + getConfig(configId: number, signal?: AbortSignal) {
44 + return HttpClient.get<GitHubAuditConfigResponse>(`${BASE_PATH}/config/${configId}`, {
45 + signal
46 + })
47 + },
48 +
49 + /**
50 + * Update a GitHub Audit configuration
51 + */
52 + updateConfig(configId: number, config: GitHubAuditConfigUpdate, signal?: AbortSignal) {
53 + return HttpClient.put<GitHubAuditConfigResponse>(`${BASE_PATH}/config/${configId}`, config, {
54 + signal
55 + })
56 + },
57 +
58 + /**
59 + * Delete a GitHub Audit configuration
60 + */
61 + deleteConfig(configId: number, signal?: AbortSignal) {
62 + return HttpClient.delete<DeleteResponse>(`${BASE_PATH}/config/${configId}`, { signal })
63 + },
64 +
65 + // ==================== Audit Execution Endpoints ====================
66 +
67 + /**
68 + * Run a GitHub audit using a saved configuration
69 + */
70 + runAuditFromConfig(configId: number, signal?: AbortSignal) {
71 + return HttpClient.post<GitHubAuditResponse>(`${BASE_PATH}/config/${configId}/audit`, {}, { signal })
72 + },
73 +
74 + /**
75 + * Run a one-time GitHub audit without saving config
76 + */
77 + runAuditAdhoc(request: GitHubAuditRequest, githubToken: string, signal?: AbortSignal) {
78 + return HttpClient.post<GitHubAuditResponse>(`${BASE_PATH}/audit`, request, {
79 + params: { github_token: githubToken },
80 + signal
81 + })
82 + },
83 +
84 + /**
85 + * Run a GitHub audit from config and return summary only
86 + */
87 + runAuditSummaryFromConfig(configId: number, signal?: AbortSignal) {
88 + return HttpClient.post<GitHubAuditSummaryResponse>(
89 + `${BASE_PATH}/config/${configId}/audit/summary`,
90 + {},
91 + { signal }
92 + )
93 + },
94 +
95 + // ==================== Report Endpoints ====================
96 +
97 + /**
98 + * Get list of GitHub audit reports with optional filters
99 + */
100 + getReports(
101 + options?: {
102 + customerCode?: string
103 + configId?: number
104 + organization?: string
105 + status?: string
106 + limit?: number
107 + offset?: number
108 + },
109 + signal?: AbortSignal
110 + ) {
111 + const params: Record<string, string | number> = {}
112 + if (options?.customerCode) params.customer_code = options.customerCode
113 + if (options?.configId) params.config_id = options.configId
114 + if (options?.organization) params.organization = options.organization
115 + if (options?.status) params.status = options.status
116 + if (options?.limit) params.limit = options.limit
117 + if (options?.offset) params.offset = options.offset
118 +
119 + return HttpClient.get<GitHubAuditReportListResponse>(`${BASE_PATH}/reports`, {
120 + params,
121 + signal
122 + })
123 + },
124 +
125 + /**
126 + * Get a specific GitHub audit report with full details
127 + */
128 + getReport(reportId: number, signal?: AbortSignal) {
129 + return HttpClient.get<GitHubAuditReportResponse>(`${BASE_PATH}/reports/${reportId}`, {
130 + signal
131 + })
132 + },
133 +
134 + /**
135 + * Delete a GitHub audit report
136 + */
137 + deleteReport(reportId: number, signal?: AbortSignal) {
138 + return HttpClient.delete<DeleteResponse>(`${BASE_PATH}/reports/${reportId}`, { signal })
139 + },
140 +
141 + // ==================== Exclusion Endpoints ====================
142 +
143 + /**
144 + * Create an exclusion rule for a specific check
145 + */
146 + createExclusion(configId: number, exclusion: GitHubAuditExclusionCreate, signal?: AbortSignal) {
147 + return HttpClient.post<GitHubAuditExclusionResponse>(
148 + `${BASE_PATH}/config/${configId}/exclusions`,
149 + exclusion,
150 + { signal }
151 + )
152 + },
153 +
154 + /**
155 + * Get all exclusion rules for a configuration
156 + */
157 + getExclusions(configId: number, includeExpired?: boolean, signal?: AbortSignal) {
158 + const params = includeExpired !== undefined ? { include_expired: includeExpired } : undefined
159 + return HttpClient.get<GitHubAuditExclusionResponse>(
160 + `${BASE_PATH}/config/${configId}/exclusions`,
161 + { params, signal }
162 + )
163 + },
164 +
165 + /**
166 + * Update an exclusion rule
167 + */
168 + updateExclusion(
169 + exclusionId: number,
170 + exclusion: GitHubAuditExclusionUpdate,
171 + signal?: AbortSignal
172 + ) {
173 + return HttpClient.put<GitHubAuditExclusionResponse>(
174 + `${BASE_PATH}/exclusions/${exclusionId}`,
175 + exclusion,
176 + { signal }
177 + )
178 + },
179 +
180 + /**
181 + * Delete an exclusion rule
182 + */
183 + deleteExclusion(exclusionId: number, signal?: AbortSignal) {
184 + return HttpClient.delete<DeleteResponse>(`${BASE_PATH}/exclusions/${exclusionId}`, {
185 + signal
186 + })
187 + },
188 +
189 + // ==================== Baseline Endpoints ====================
190 +
191 + /**
192 + * Create a baseline from a previous audit report
193 + */
194 + createBaseline(configId: number, baseline: GitHubAuditBaselineCreate, signal?: AbortSignal) {
195 + return HttpClient.post<GitHubAuditBaselineResponse>(
196 + `${BASE_PATH}/config/${configId}/baselines`,
197 + baseline,
198 + { signal }
199 + )
200 + },
201 +
202 + /**
203 + * Get all baselines for a configuration
204 + */
205 + getBaselines(configId: number, activeOnly?: boolean, signal?: AbortSignal) {
206 + const params = activeOnly !== undefined ? { active_only: activeOnly } : undefined
207 + return HttpClient.get<GitHubAuditBaselineResponse>(
208 + `${BASE_PATH}/config/${configId}/baselines`,
209 + { params, signal }
210 + )
211 + },
212 +
213 + /**
214 + * Delete a baseline
215 + */
216 + deleteBaseline(baselineId: number, signal?: AbortSignal) {
217 + return HttpClient.delete<DeleteResponse>(`${BASE_PATH}/baselines/${baselineId}`, { signal })
218 + },
219 +
220 + // ==================== Reference Endpoints ====================
221 +
222 + /**
223 + * Get list of all available audit checks
224 + */
225 + getAvailableChecks(signal?: AbortSignal) {
226 + return HttpClient.get<AvailableChecksResponse>(`${BASE_PATH}/checks`, { signal })
227 + }
228 +}
frontend/src/api/index.ts
+2
@@ -11,6 +11,7 @@ import copilotMCP from "./endpoints/copilotMCP"
11 import customerPortal from "./endpoints/customerPortal"
12 import customers from "./endpoints/customers"
13 import flow from "./endpoints/flow"
14 +import githubAudit from "./endpoints/githubAudit"
15 import graylog from "./endpoints/graylog"
16 import healthchecks from "./endpoints/healthchecks"
17 import incidentManagement from "./endpoints/incidentManagement"
@@ -61,6 +62,7 @@ export default {
62 stackProvisioning,
63 reporting,
64 license,
65 + githubAudit,
66 scheduler,
67 networkConnectors,
68 cloudSecurityAssessment,
frontend/src/components/githubAudit/GitHubAuditButton.vue new
+26
@@ -0,0 +1,26 @@
1 +<template>
2 + <n-button :size="size" :type="type" @click="goToGitHubAudit">
3 + <template #icon>
4 + <Icon :name="GitHubIcon" />
5 + </template>
6 + GitHub Audit
7 + </n-button>
8 +</template>
9 +
10 +<script setup lang="ts">
11 +import { NButton } from "naive-ui"
12 +import { useRouter } from "vue-router"
13 +import Icon from "@/components/common/Icon.vue"
14 +
15 +defineProps<{
16 + size?: "tiny" | "small" | "medium" | "large"
17 + type?: "default" | "primary" | "info" | "success" | "warning" | "error"
18 +}>()
19 +
20 +const GitHubIcon = "carbon:logo-github"
21 +const router = useRouter()
22 +
23 +function goToGitHubAudit() {
24 + router.push({ name: "GitHubAudit" })
25 +}
26 +</script>
frontend/src/components/githubAudit/GitHubAuditCard.vue new
+126
@@ -0,0 +1,126 @@
1 +<template>
2 + <n-card class="github-audit-card" hoverable @click="$emit('click', config)">
3 + <div class="flex justify-between items-start">
4 + <div class="flex-1">
5 + <div class="flex items-center gap-2 mb-2">
6 + <n-icon size="20" :color="config.enabled ? '#18a058' : '#999'">
7 + <Icon :name="GithubIcon" />
8 + </n-icon>
9 + <h3 class="text-lg font-semibold m-0">{{ config.organization }}</h3>
10 + <n-tag v-if="!config.enabled" type="warning" size="small">Disabled</n-tag>
11 + </div>
12 +
13 + <div class="text-secondary text-sm mb-3">
14 + <span>Customer: {{ config.customer_code }}</span>
15 + </div>
16 +
17 + <div class="flex gap-4 text-sm">
18 + <div v-if="config.last_audit_at" class="flex items-center gap-1">
19 + <n-icon><Icon :name="ClockIcon" /></n-icon>
20 + <span>Last audit: {{ formatDate(config.last_audit_at) }}</span>
21 + </div>
22 + <div v-if="config.last_audit_grade" class="flex items-center gap-1">
23 + <span>Grade:</span>
24 + <GitHubAuditGradeBadge :grade="config.last_audit_grade" :score="config.last_audit_score ?? undefined" />
25 + </div>
26 + <div v-if="config.auto_audit_enabled" class="flex items-center gap-1">
27 + <n-icon color="#18a058"><Icon :name="ScheduleIcon" /></n-icon>
28 + <span>Scheduled</span>
29 + </div>
30 + </div>
31 + </div>
32 +
33 + <div class="flex flex-col gap-2">
34 + <n-button type="primary" size="small" :loading="running" @click.stop="runAudit">
35 + <template #icon>
36 + <n-icon><Icon :name="PlayIcon" /></n-icon>
37 + </template>
38 + Run Audit
39 + </n-button>
40 + <n-button size="small" @click.stop="$emit('edit', config)">
41 + <template #icon>
42 + <n-icon><Icon :name="EditIcon" /></n-icon>
43 + </template>
44 + Edit
45 + </n-button>
46 + </div>
47 + </div>
48 +
49 + <n-divider v-if="config.last_audit_score !== null" style="margin: 12px 0" />
50 +
51 + <div v-if="config.last_audit_score !== null" class="audit-score-bar">
52 + <div class="flex justify-between mb-1">
53 + <span class="text-sm">Security Score</span>
54 + <span class="text-sm font-semibold">{{ config.last_audit_score?.toFixed(1) }}%</span>
55 + </div>
56 + <n-progress
57 + type="line"
58 + :percentage="config.last_audit_score ?? 0"
59 + :status="scoreStatus"
60 + :show-indicator="false"
61 + />
62 + </div>
63 + </n-card>
64 +</template>
65 +
66 +<script setup lang="ts">
67 +import type { GitHubAuditConfig } from "@/types/githubAudit.d"
68 +import { NButton, NCard, NDivider, NIcon, NProgress, NTag, useMessage } from "naive-ui"
69 +import { computed, ref } from "vue"
70 +import Api from "@/api"
71 +import Icon from "@/components/common/Icon.vue"
72 +import { formatDate } from "@/utils"
73 +import GitHubAuditGradeBadge from "./GitHubAuditGradeBadge.vue"
74 +
75 +const props = defineProps<{
76 + config: GitHubAuditConfig
77 +}>()
78 +const emit = defineEmits<{
79 + (e: "click", config: GitHubAuditConfig): void
80 + (e: "edit", config: GitHubAuditConfig): void
81 + (e: "audit-complete"): void
82 +}>()
83 +const GithubIcon = "carbon:logo-github"
84 +const ClockIcon = "carbon:time"
85 +const ScheduleIcon = "carbon:calendar"
86 +const PlayIcon = "carbon:play-filled"
87 +const EditIcon = "carbon:edit"
88 +
89 +const message = useMessage()
90 +const running = ref(false)
91 +
92 +const scoreStatus = computed(() => {
93 + const score = props.config.last_audit_score ?? 0
94 + if (score >= 80) return "success"
95 + if (score >= 60) return "warning"
96 + return "error"
97 +})
98 +
99 +async function runAudit() {
100 + running.value = true
101 + try {
102 + await Api.githubAudit.runAuditFromConfig(props.config.id)
103 + message.success("Audit completed successfully")
104 + emit("audit-complete")
105 + } catch (error: any) {
106 + message.error(error.response?.data?.detail || "Failed to run audit")
107 + } finally {
108 + running.value = false
109 + }
110 +}
111 +</script>
112 +
113 +<style scoped>
114 +.github-audit-card {
115 + cursor: pointer;
116 + transition: all 0.2s ease;
117 +}
118 +
119 +.github-audit-card:hover {
120 + transform: translateY(-2px);
121 +}
122 +
123 +.text-secondary {
124 + color: var(--text-color-3);
125 +}
126 +</style>
frontend/src/components/githubAudit/GitHubAuditConfigForm.vue new
+294
@@ -0,0 +1,294 @@
1 +<template>
2 + <n-drawer v-model:show="showDrawer" :width="600" placement="right">
3 + <n-drawer-content :title="isEdit ? 'Edit Configuration' : 'New GitHub Audit Configuration'" closable>
4 + <n-form ref="formRef" :model="formData" :rules="rules" label-placement="top">
5 + <n-divider title-placement="left">Basic Settings</n-divider>
6 +
7 + <n-form-item label="Customer" path="customer_code">
8 + <n-select
9 + v-model:value="formData.customer_code"
10 + placeholder="Select customer"
11 + :options="customerOptions"
12 + :disabled="isEdit"
13 + filterable
14 + />
15 + </n-form-item>
16 +
17 + <n-form-item label="GitHub Organization" path="organization">
18 + <n-input v-model:value="formData.organization" placeholder="e.g., my-org" />
19 + </n-form-item>
20 +
21 + <n-form-item label="GitHub Token" path="github_token">
22 + <n-input
23 + v-model:value="formData.github_token"
24 + type="password"
25 + show-password-on="click"
26 + :placeholder="isEdit ? 'Leave blank to keep existing token' : 'ghp_xxxxxxxxxxxx'"
27 + />
28 + </n-form-item>
29 +
30 + <n-form-item label="Token Type" path="token_type">
31 + <n-radio-group v-model:value="formData.token_type">
32 + <n-radio value="pat">Personal Access Token</n-radio>
33 + <n-radio value="app">GitHub App</n-radio>
34 + </n-radio-group>
35 + </n-form-item>
36 +
37 + <n-form-item label="Enabled">
38 + <n-switch v-model:value="formData.enabled" />
39 + </n-form-item>
40 +
41 + <n-divider title-placement="left">Audit Scope</n-divider>
42 +
43 + <n-grid :cols="2" :x-gap="16">
44 + <n-gi>
45 + <n-form-item label="Include Repositories">
46 + <n-switch v-model:value="formData.include_repos" />
47 + </n-form-item>
48 + </n-gi>
49 + <n-gi>
50 + <n-form-item label="Include Workflows">
51 + <n-switch v-model:value="formData.include_workflows" />
52 + </n-form-item>
53 + </n-gi>
54 + <n-gi>
55 + <n-form-item label="Include Members">
56 + <n-switch v-model:value="formData.include_members" />
57 + </n-form-item>
58 + </n-gi>
59 + <n-gi>
60 + <n-form-item label="Include Archived Repos">
61 + <n-switch v-model:value="formData.include_archived_repos" />
62 + </n-form-item>
63 + </n-gi>
64 + </n-grid>
65 +
66 + <n-form-item label="Repository Filter Mode">
67 + <n-radio-group v-model:value="formData.repo_filter_mode">
68 + <n-radio value="all">All Repositories</n-radio>
69 + <n-radio value="include">Include Only</n-radio>
70 + <n-radio value="exclude">Exclude</n-radio>
71 + </n-radio-group>
72 + </n-form-item>
73 +
74 + <n-form-item v-if="formData.repo_filter_mode !== 'all'" label="Repository List">
75 + <n-dynamic-tags v-model:value="formData.repo_filter_list" />
76 + <template #feedback>
77 + Enter repository names to {{ formData.repo_filter_mode }}
78 + </template>
79 + </n-form-item>
80 +
81 + <n-divider title-placement="left">Schedule</n-divider>
82 +
83 + <n-form-item label="Enable Scheduled Audits">
84 + <n-switch v-model:value="formData.auto_audit_enabled" />
85 + </n-form-item>
86 +
87 + <n-form-item v-if="formData.auto_audit_enabled" label="Schedule (Cron)" path="audit_schedule_cron">
88 + <n-input v-model:value="formData.audit_schedule_cron" placeholder="0 0 * * 1 (Weekly on Monday)" />
89 + <template #feedback>
90 + <n-text depth="3">Use cron format. Example: "0 0 * * 1" for weekly on Monday at midnight</n-text>
91 + </template>
92 + </n-form-item>
93 +
94 + <n-divider title-placement="left">Notifications</n-divider>
95 +
96 + <n-grid :cols="2" :x-gap="16">
97 + <n-gi>
98 + <n-form-item label="Notify on Critical">
99 + <n-switch v-model:value="formData.notify_on_critical" />
100 + </n-form-item>
101 + </n-gi>
102 + <n-gi>
103 + <n-form-item label="Notify on High">
104 + <n-switch v-model:value="formData.notify_on_high" />
105 + </n-form-item>
106 + </n-gi>
107 + </n-grid>
108 +
109 + <n-form-item label="Notification Webhook URL">
110 + <n-input v-model:value="formData.notification_webhook_url" placeholder="https://..." />
111 + </n-form-item>
112 +
113 + <n-form-item label="Notification Email">
114 + <n-input v-model:value="formData.notification_email" placeholder="security@example.com" />
115 + </n-form-item>
116 +
117 + <n-divider title-placement="left">Thresholds</n-divider>
118 +
119 + <n-form-item label="Minimum Passing Score">
120 + <n-slider v-model:value="formData.minimum_passing_score" :min="0" :max="100" :step="5" />
121 + <n-input-number v-model:value="formData.minimum_passing_score" :min="0" :max="100" style="width: 100px; margin-left: 16px" />
122 + </n-form-item>
123 + </n-form>
124 +
125 + <template #footer>
126 + <div class="flex justify-end gap-3">
127 + <n-button @click="showDrawer = false">Cancel</n-button>
128 + <n-button type="primary" :loading="saving" @click="handleSubmit">
129 + {{ isEdit ? "Update" : "Create" }}
130 + </n-button>
131 + </div>
132 + </template>
133 + </n-drawer-content>
134 + </n-drawer>
135 +</template>
136 +
137 +<script setup lang="ts">
138 +import type { FormInst, FormRules } from "naive-ui"
139 +import type { GitHubAuditConfig, GitHubAuditConfigCreate, GitHubAuditConfigUpdate } from "@/types/githubAudit.d"
140 +import {
141 +
142 + NButton,
143 + NDivider,
144 + NDrawer,
145 + NDrawerContent,
146 + NDynamicTags,
147 + NForm,
148 + NFormItem,
149 + NGi,
150 + NGrid,
151 + NInput,
152 + NInputNumber,
153 + NRadio,
154 + NRadioGroup,
155 + NSelect,
156 + NSlider,
157 + NSwitch,
158 + NText,
159 + useMessage
160 +} from "naive-ui"
161 +import { computed, onMounted, reactive, ref, watch } from "vue"
162 +import Api from "@/api"
163 +
164 +const props = defineProps<{
165 + show: boolean
166 + config?: GitHubAuditConfig | null
167 +}>()
168 +
169 +const emit = defineEmits<{
170 + (e: "update:show", value: boolean): void
171 + (e: "saved"): void
172 +}>()
173 +
174 +const message = useMessage()
175 +const formRef = ref<FormInst | null>(null)
176 +const saving = ref(false)
177 +const customerOptions = ref<{ label: string; value: string }[]>([])
178 +
179 +const showDrawer = computed({
180 + get: () => props.show,
181 + set: (value) => emit("update:show", value)
182 +})
183 +
184 +const isEdit = computed(() => !!props.config)
185 +
186 +function defaultFormData(): GitHubAuditConfigCreate {
187 + return {
188 + customer_code: "",
189 + github_token: "",
190 + organization: "",
191 + token_type: "pat",
192 + enabled: true,
193 + auto_audit_enabled: false,
194 + audit_schedule_cron: null,
195 + include_repos: true,
196 + include_workflows: true,
197 + include_members: true,
198 + include_archived_repos: false,
199 + repo_filter_mode: "all",
200 + repo_filter_list: [],
201 + notify_on_critical: true,
202 + notify_on_high: false,
203 + notification_webhook_url: null,
204 + notification_email: null,
205 + minimum_passing_score: 70
206 +}
207 +}
208 +
209 +const formData = reactive<GitHubAuditConfigCreate>(defaultFormData())
210 +
211 +const rules: FormRules = {
212 + customer_code: { required: true, message: "Customer is required", trigger: "blur" },
213 + organization: { required: true, message: "Organization is required", trigger: "blur" },
214 + github_token: {
215 + required: !isEdit.value,
216 + message: "GitHub token is required",
217 + trigger: "blur"
218 + }
219 +}
220 +
221 +watch(
222 + () => props.config,
223 + (config) => {
224 + if (config) {
225 + Object.assign(formData, {
226 + customer_code: config.customer_code,
227 + github_token: "",
228 + organization: config.organization,
229 + token_type: config.token_type,
230 + enabled: config.enabled,
231 + auto_audit_enabled: config.auto_audit_enabled,
232 + audit_schedule_cron: config.audit_schedule_cron,
233 + include_repos: config.include_repos,
234 + include_workflows: config.include_workflows,
235 + include_members: config.include_members,
236 + include_archived_repos: config.include_archived_repos,
237 + repo_filter_mode: config.repo_filter_mode,
238 + repo_filter_list: config.repo_filter_list || [],
239 + notify_on_critical: config.notify_on_critical,
240 + notify_on_high: config.notify_on_high,
241 + notification_webhook_url: config.notification_webhook_url,
242 + notification_email: config.notification_email,
243 + minimum_passing_score: config.minimum_passing_score
244 + })
245 + } else {
246 + Object.assign(formData, defaultFormData())
247 + }
248 + },
249 + { immediate: true }
250 +)
251 +
252 +async function handleSubmit() {
253 + try {
254 + await formRef.value?.validate()
255 + } catch {
256 + return
257 + }
258 +
259 + saving.value = true
260 + try {
261 + if (isEdit.value && props.config) {
262 + const updateData: GitHubAuditConfigUpdate = { ...formData }
263 + if (!updateData.github_token) {
264 + delete updateData.github_token
265 + }
266 + await Api.githubAudit.updateConfig(props.config.id, updateData)
267 + message.success("Configuration updated successfully")
268 + } else {
269 + await Api.githubAudit.createConfig(formData)
270 + message.success("Configuration created successfully")
271 + }
272 + emit("saved")
273 + showDrawer.value = false
274 + } catch (error: any) {
275 + message.error(error.response?.data?.detail || "Failed to save configuration")
276 + } finally {
277 + saving.value = false
278 + }
279 +}
280 +
281 +onMounted(async () => {
282 + try {
283 + const response = await Api.customers.getCustomers()
284 + if (response.data.customers) {
285 + customerOptions.value = response.data.customers.map((c: any) => ({
286 + label: `${c.customer_name} (${c.customer_code})`,
287 + value: c.customer_code
288 + }))
289 + }
290 + } catch (error) {
291 + console.error("Failed to load customers:", error)
292 + }
293 +})
294 +</script>
frontend/src/components/githubAudit/GitHubAuditDetail.vue new
+383
@@ -0,0 +1,383 @@
1 +<template>
2 + <n-drawer v-model:show="showDrawer" :width="800" placement="right">
3 + <n-drawer-content v-if="config" closable>
4 + <template #header>
5 + <div class="flex items-center gap-3">
6 + <n-icon size="24">
7 + <Icon :name="GithubIcon" />
8 + </n-icon>
9 + <span>{{ config.organization }}</span>
10 + <n-tag v-if="!config.enabled" type="warning" size="small">Disabled</n-tag>
11 + </div>
12 + </template>
13 +
14 + <n-tabs v-model:value="activeTab" type="line" animated>
15 + <n-tab-pane name="overview" tab="Overview">
16 + <div class="space-y-4">
17 + <n-descriptions :column="2" label-placement="top" bordered>
18 + <n-descriptions-item label="Customer">
19 + {{ config.customer_code }}
20 + </n-descriptions-item>
21 + <n-descriptions-item label="Organization">
22 + {{ config.organization }}
23 + </n-descriptions-item>
24 + <n-descriptions-item label="Token Type">
25 + {{ config.token_type === "pat" ? "Personal Access Token" : "GitHub App" }}
26 + </n-descriptions-item>
27 + <n-descriptions-item label="Enabled">
28 + <n-tag :type="config.enabled ? 'success' : 'warning'" size="small">
29 + {{ config.enabled ? "Yes" : "No" }}
30 + </n-tag>
31 + </n-descriptions-item>
32 + <n-descriptions-item label="Last Audit">
33 + {{ config.last_audit_at ? formatDate(config.last_audit_at) : "Never" }}
34 + </n-descriptions-item>
35 + <n-descriptions-item label="Last Score">
36 + <template v-if="config.last_audit_score !== null">
37 + {{ config.last_audit_score?.toFixed(1) }}%
38 + <GitHubAuditGradeBadge :grade="config.last_audit_grade || 'F'" />
39 + </template>
40 + <template v-else>N/A</template>
41 + </n-descriptions-item>
42 + <n-descriptions-item label="Scheduled Audits">
43 + <n-tag :type="config.auto_audit_enabled ? 'success' : 'default'" size="small">
44 + {{ config.auto_audit_enabled ? "Enabled" : "Disabled" }}
45 + </n-tag>
46 + <span v-if="config.auto_audit_enabled" class="ml-2 text-sm">
47 + {{ config.audit_schedule_cron }}
48 + </span>
49 + </n-descriptions-item>
50 + <n-descriptions-item label="Audit Scope">
51 + <n-space>
52 + <n-tag v-if="config.include_repos" size="small">Repos</n-tag>
53 + <n-tag v-if="config.include_workflows" size="small">Workflows</n-tag>
54 + <n-tag v-if="config.include_members" size="small">Members</n-tag>
55 + </n-space>
56 + </n-descriptions-item>
57 + </n-descriptions>
58 +
59 + <div class="flex gap-3">
60 + <n-button type="primary" :loading="running" @click="runAudit">
61 + <template #icon>
62 + <n-icon><Icon :name="PlayIcon" /></n-icon>
63 + </template>
64 + Run Audit Now
65 + </n-button>
66 + <n-button @click="handleEdit">
67 + <template #icon>
68 + <n-icon><Icon :name="EditIcon" /></n-icon>
69 + </template>
70 + Edit Configuration
71 + </n-button>
72 + <n-popconfirm @positive-click="deleteConfig">
73 + <template #trigger>
74 + <n-button type="error" ghost>
75 + <template #icon>
76 + <n-icon><Icon :name="DeleteIcon" /></n-icon>
77 + </template>
78 + Delete
79 + </n-button>
80 + </template>
81 + Are you sure you want to delete this configuration?
82 + </n-popconfirm>
83 + </div>
84 + </div>
85 + </n-tab-pane>
86 +
87 + <n-tab-pane name="reports" tab="Reports">
88 + <n-spin :show="loadingReports">
89 + <div v-if="reports.length === 0 && !loadingReports" class="text-center py-8">
90 + <n-empty description="No reports yet">
91 + <template #extra>
92 + <n-button type="primary" @click="runAudit">Run your first audit</n-button>
93 + </template>
94 + </n-empty>
95 + </div>
96 +
97 + <div v-else class="space-y-3">
98 + <GitHubAuditReportCard
99 + v-for="report in reports"
100 + :key="report.id"
101 + :report="report"
102 + @click="openReportDetail"
103 + />
104 +
105 + <n-pagination
106 + v-if="totalReports > pageSize"
107 + v-model:page="currentPage"
108 + :page-size="pageSize"
109 + :item-count="totalReports"
110 + @update:page="loadReports"
111 + />
112 + </div>
113 + </n-spin>
114 + </n-tab-pane>
115 +
116 + <n-tab-pane name="exclusions" tab="Exclusions">
117 + <div class="mb-4">
118 + <n-button type="primary" size="small" @click="showExclusionForm = true">
119 + <template #icon>
120 + <n-icon><Icon :name="AddIcon" /></n-icon>
121 + </template>
122 + Add Exclusion
123 + </n-button>
124 + </div>
125 +
126 + <n-spin :show="loadingExclusions">
127 + <div v-if="exclusions.length === 0 && !loadingExclusions" class="text-center py-8">
128 + <n-empty description="No exclusions configured" />
129 + </div>
130 +
131 + <n-table v-else :bordered="false" :single-line="false">
132 + <thead>
133 + <tr>
134 + <th>Check</th>
135 + <th>Resource</th>
136 + <th>Reason</th>
137 + <th>Expires</th>
138 + <th>Actions</th>
139 + </tr>
140 + </thead>
141 + <tbody>
142 + <tr v-for="exclusion in exclusions" :key="exclusion.id">
143 + <td>{{ exclusion.check_id }}</td>
144 + <td>{{ exclusion.resource_name || "All" }}</td>
145 + <td>{{ exclusion.reason }}</td>
146 + <td>{{ exclusion.expires_at ? formatDate(exclusion.expires_at) : "Never" }}</td>
147 + <td>
148 + <n-button text type="error" @click="deleteExclusion(exclusion.id)">
149 + <n-icon><Icon :name="DeleteIcon" /></n-icon>
150 + </n-button>
151 + </td>
152 + </tr>
153 + </tbody>
154 + </n-table>
155 + </n-spin>
156 + </n-tab-pane>
157 + </n-tabs>
158 +
159 + <GitHubAuditExclusionForm
160 + v-if="showExclusionForm"
161 + v-model:show="showExclusionForm"
162 + :config-id="config.id"
163 + @saved="loadExclusions"
164 + />
165 +
166 + <GitHubAuditReportDetail
167 + v-if="showReportDetail"
168 + v-model:show="showReportDetail"
169 + :report="selectedReport"
170 + />
171 + </n-drawer-content>
172 + </n-drawer>
173 +</template>
174 +
175 +<script setup lang="ts">
176 +import type { GitHubAuditCheckExclusion, GitHubAuditConfig, GitHubAuditReport, GitHubAuditReportSummary } from "@/types/githubAudit.d"
177 +import {
178 + NButton,
179 + NDescriptions,
180 + NDescriptionsItem,
181 + NDrawer,
182 + NDrawerContent,
183 + NEmpty,
184 + NIcon,
185 + NPagination,
186 + NPopconfirm,
187 + NSpace,
188 + NSpin,
189 + NTable,
190 + NTabPane,
191 + NTabs,
192 + NTag,
193 + useMessage
194 +} from "naive-ui"
195 +import { computed, ref, watch } from "vue"
196 +import Api from "@/api"
197 +import Icon from "@/components/common/Icon.vue"
198 +import { formatDate } from "@/utils"
199 +import GitHubAuditExclusionForm from "./GitHubAuditExclusionForm.vue"
200 +import GitHubAuditGradeBadge from "./GitHubAuditGradeBadge.vue"
201 +import GitHubAuditReportCard from "./GitHubAuditReportCard.vue"
202 +import GitHubAuditReportDetail from "./GitHubAuditReportDetail.vue"
203 +
204 +const props = defineProps<{
205 + show: boolean
206 + config: GitHubAuditConfig | null
207 +}>()
208 +const emit = defineEmits<{
209 + (e: "update:show", value: boolean): void
210 + (e: "updated"): void
211 + (e: "edit", config: GitHubAuditConfig): void
212 +}>()
213 +const GithubIcon = "mdi:github"
214 +const PlayIcon = "ion:play"
215 +const EditIcon = "ion:create-outline"
216 +const DeleteIcon = "ion:trash-outline"
217 +const AddIcon = "ion:add"
218 +
219 +const message = useMessage()
220 +const activeTab = ref("overview")
221 +const running = ref(false)
222 +
223 +// Reports
224 +const loadingReports = ref(false)
225 +const reports = ref<GitHubAuditReportSummary[]>([])
226 +const totalReports = ref(0)
227 +const currentPage = ref(1)
228 +const pageSize = 10
229 +const showReportDetail = ref(false)
230 +const selectedReport = ref<GitHubAuditReport | null>(null)
231 +
232 +// Exclusions
233 +const loadingExclusions = ref(false)
234 +const exclusions = ref<GitHubAuditCheckExclusion[]>([])
235 +const showExclusionForm = ref(false)
236 +
237 +const showDrawer = computed({
238 + get: () => props.show,
239 + set: (value) => emit("update:show", value)
240 +})
241 +
242 +// Watch for drawer opening
243 +watch(
244 + () => props.show,
245 + (show) => {
246 + if (show && props.config) {
247 + activeTab.value = "overview"
248 + // Reset state
249 + reports.value = []
250 + exclusions.value = []
251 + currentPage.value = 1
252 + // Load data
253 + loadReports()
254 + loadExclusions()
255 + }
256 + },
257 + { immediate: true }
258 +)
259 +
260 +// Watch for config changes while drawer is open
261 +watch(
262 + () => props.config?.id,
263 + (newId, oldId) => {
264 + if (newId && newId !== oldId && props.show) {
265 + reports.value = []
266 + exclusions.value = []
267 + currentPage.value = 1
268 + loadReports()
269 + loadExclusions()
270 + }
271 + }
272 +)
273 +
274 +async function loadReports() {
275 + if (!props.config) {
276 + console.warn("loadReports called but config is null")
277 + return
278 + }
279 + if (loadingReports.value) {
280 + return
281 + }
282 +
283 + loadingReports.value = true
284 + try {
285 + const response = await Api.githubAudit.getReports({
286 + configId: props.config.id,
287 + limit: pageSize,
288 + offset: (currentPage.value - 1) * pageSize
289 + })
290 + reports.value = response.data.reports || []
291 + totalReports.value = response.data.total_count || 0
292 + } catch (error: any) {
293 + console.error("Failed to load reports:", error)
294 + message.error("Failed to load reports")
295 + } finally {
296 + loadingReports.value = false
297 + }
298 +}
299 +
300 +async function loadExclusions() {
301 + if (!props.config || loadingExclusions.value) return
302 +
303 + loadingExclusions.value = true
304 + try {
305 + const response = await Api.githubAudit.getExclusions(props.config.id)
306 + exclusions.value = response.data.exclusions || []
307 + } catch (error: any) {
308 + console.error("Failed to load exclusions:", error)
309 + message.error("Failed to load exclusions")
310 + } finally {
311 + loadingExclusions.value = false
312 + }
313 +}
314 +
315 +async function runAudit() {
316 + if (!props.config) return
317 +
318 + running.value = true
319 + try {
320 + await Api.githubAudit.runAuditFromConfig(props.config.id)
321 + message.success("Audit completed successfully")
322 + // Reload reports after audit completes
323 + await loadReports()
324 + emit("updated")
325 + } catch (error: any) {
326 + message.error(error.response?.data?.detail || "Failed to run audit")
327 + } finally {
328 + running.value = false
329 + }
330 +}
331 +
332 +async function deleteConfig() {
333 + if (!props.config) return
334 +
335 + try {
336 + await Api.githubAudit.deleteConfig(props.config.id)
337 + message.success("Configuration deleted")
338 + showDrawer.value = false
339 + emit("updated")
340 + } catch (error: any) {
341 + message.error(error.response?.data?.detail || "Failed to delete configuration")
342 + }
343 +}
344 +
345 +async function deleteExclusion(exclusionId: number) {
346 + try {
347 + await Api.githubAudit.deleteExclusion(exclusionId)
348 + message.success("Exclusion deleted")
349 + loadExclusions()
350 + } catch (error: any) {
351 + message.error("Failed to delete exclusion")
352 + }
353 +}
354 +
355 +function handleEdit() {
356 + if (props.config) {
357 + showDrawer.value = false
358 + emit("edit", props.config)
359 + }
360 +}
361 +
362 +async function openReportDetail(report: GitHubAuditReportSummary) {
363 + try {
364 + const response = await Api.githubAudit.getReport(report.id)
365 + if (response.data.report) {
366 + selectedReport.value = response.data.report
367 + showReportDetail.value = true
368 + }
369 + } catch (error: any) {
370 + message.error("Failed to load report details")
371 + }
372 +}
373 +</script>
374 +
375 +<style scoped>
376 +.space-y-4 > * + * {
377 + margin-top: 1rem;
378 +}
379 +
380 +.space-y-3 > * + * {
381 + margin-top: 0.75rem;
382 +}
383 +</style>
frontend/src/components/githubAudit/GitHubAuditExclusionForm.vue new
+139
@@ -0,0 +1,139 @@
1 +<template>
2 + <n-modal v-model:show="showModal" preset="dialog" title="Add Exclusion" style="width: 500px">
3 + <n-form ref="formRef" :model="formData" :rules="rules" label-placement="top">
4 + <n-form-item label="Check to Exclude" path="check_id">
5 + <n-select
6 + v-model:value="formData.check_id"
7 + placeholder="Select a check"
8 + :options="checkOptions"
9 + filterable
10 + />
11 + </n-form-item>
12 +
13 + <n-form-item label="Resource Name (Optional)">
14 + <n-input
15 + v-model:value="formData.resource_name"
16 + placeholder="e.g., specific repository name (leave blank for all)"
17 + />
18 + </n-form-item>
19 +
20 + <n-form-item label="Reason" path="reason">
21 + <n-input
22 + v-model:value="formData.reason"
23 + type="textarea"
24 + placeholder="Why is this check being excluded?"
25 + :rows="3"
26 + />
27 + </n-form-item>
28 +
29 + <n-form-item label="Approved By">
30 + <n-input v-model:value="formData.approved_by" placeholder="Name of approver" />
31 + </n-form-item>
32 +
33 + <n-form-item label="Expires At">
34 + <n-date-picker v-model:value="expiresAtTimestamp" type="datetime" clearable />
35 + </n-form-item>
36 + </n-form>
37 +
38 + <template #action>
39 + <n-button @click="showModal = false">Cancel</n-button>
40 + <n-button type="primary" :loading="saving" @click="handleSubmit">Create</n-button>
41 + </template>
42 + </n-modal>
43 +</template>
44 +
45 +<script setup lang="ts">
46 +import type { FormInst, FormRules } from "naive-ui"
47 +import type { GitHubAuditExclusionCreate } from "@/types/githubAudit.d"
48 +import {
49 +
50 + NButton,
51 + NDatePicker,
52 + NForm,
53 + NFormItem,
54 + NInput,
55 + NModal,
56 + NSelect,
57 + useMessage
58 +} from "naive-ui"
59 +import { computed, onMounted, reactive, ref } from "vue"
60 +import Api from "@/api"
61 +
62 +const props = defineProps<{
63 + show: boolean
64 + configId: number
65 +}>()
66 +
67 +const emit = defineEmits<{
68 + (e: "update:show", value: boolean): void
69 + (e: "saved"): void
70 +}>()
71 +
72 +const message = useMessage()
73 +const formRef = ref<FormInst | null>(null)
74 +const saving = ref(false)
75 +const checkOptions = ref<{ label: string; value: string }[]>([])
76 +const expiresAtTimestamp = ref<number | null>(null)
77 +
78 +const showModal = computed({
79 + get: () => props.show,
80 + set: (value) => emit("update:show", value)
81 +})
82 +
83 +const formData = reactive<GitHubAuditExclusionCreate>({
84 + check_id: "",
85 + resource_name: null,
86 + reason: "",
87 + approved_by: null,
88 + expires_at: null,
89 + created_by: "current_user" // TODO: Get from auth context
90 +})
91 +
92 +const rules: FormRules = {
93 + check_id: { required: true, message: "Please select a check", trigger: "blur" },
94 + reason: { required: true, message: "Please provide a reason", trigger: "blur" }
95 +}
96 +
97 +async function handleSubmit() {
98 + try {
99 + await formRef.value?.validate()
100 + } catch {
101 + return
102 + }
103 +
104 + saving.value = true
105 + try {
106 + const data = {
107 + ...formData,
108 + expires_at: expiresAtTimestamp.value ? new Date(expiresAtTimestamp.value).toISOString() : null
109 + }
110 + await Api.githubAudit.createExclusion(props.configId, data)
111 + message.success("Exclusion created successfully")
112 + emit("saved")
113 + showModal.value = false
114 +
115 + // Reset form
116 + formData.check_id = ""
117 + formData.resource_name = null
118 + formData.reason = ""
119 + formData.approved_by = null
120 + expiresAtTimestamp.value = null
121 + } catch (error: any) {
122 + message.error(error.response?.data?.detail || "Failed to create exclusion")
123 + } finally {
124 + saving.value = false
125 + }
126 +}
127 +
128 +onMounted(async () => {
129 + try {
130 + const response = await Api.githubAudit.getAvailableChecks()
131 + checkOptions.value = response.data.checks.map((check) => ({
132 + label: `${check.name} (${check.severity})`,
133 + value: check.id
134 + }))
135 + } catch (error) {
136 + console.error("Failed to load available checks:", error)
137 + }
138 +})
139 +</script>
frontend/src/components/githubAudit/GitHubAuditFilters.vue new
+182
@@ -0,0 +1,182 @@
1 +<template>
2 + <div class="github-audit-list">
3 + <n-card>
4 + <div class="flex justify-between items-center mb-4">
5 + <h2 class="text-xl font-semibold m-0">GitHub Audit Configurations</h2>
6 + <n-button type="primary" @click="openCreateForm">
7 + <template #icon>
8 + <Icon :name="AddIcon" :size="16" />
9 + </template>
10 + New Configuration
11 + </n-button>
12 + </div>
13 +
14 + <div class="github-audit-filters flex gap-4 flex-wrap items-center mb-4">
15 + <n-select
16 + v-model:value="filterCustomerCode"
17 + placeholder="Filter by Customer"
18 + clearable
19 + :options="customerOptions"
20 + :loading="loadingCustomers"
21 + style="min-width: 200px"
22 + @update:value="loadConfigs"
23 + />
24 + <n-select
25 + v-model:value="filterStatus"
26 + placeholder="Filter by Status"
27 + clearable
28 + :options="statusOptions"
29 + style="min-width: 150px"
30 + @update:value="loadConfigs"
31 + />
32 + <n-input
33 + v-model:value="filterOrganization"
34 + placeholder="Search organization..."
35 + clearable
36 + style="min-width: 200px"
37 + @keyup.enter="loadConfigs"
38 + @clear="loadConfigs"
39 + >
40 + <template #prefix>
41 + <Icon :name="SearchIcon" :size="16" />
42 + </template>
43 + </n-input>
44 + </div>
45 +
46 + <n-spin :show="loading">
47 + <div v-if="configs.length === 0 && !loading" class="text-center py-8">
48 + <n-empty description="No configurations found">
49 + <template #extra>
50 + <n-button type="primary" @click="openCreateForm">Create your first configuration</n-button>
51 + </template>
52 + </n-empty>
53 + </div>
54 +
55 + <n-grid v-else :cols="2" :x-gap="16" :y-gap="16">
56 + <n-gi v-for="config in configs" :key="config.id">
57 + <GitHubAuditCard
58 + :config="config"
59 + @click="openDetail(config)"
60 + @edit="openEditForm"
61 + @audit-complete="loadConfigs"
62 + />
63 + </n-gi>
64 + </n-grid>
65 + </n-spin>
66 + </n-card>
67 +
68 + <GitHubAuditConfigForm
69 + v-if="showForm"
70 + v-model:show="showForm"
71 + :config="selectedConfig"
72 + @saved="onConfigSaved"
73 + />
74 +
75 + <GitHubAuditDetail
76 + v-if="showDetail"
77 + v-model:show="showDetail"
78 + :config="selectedConfig"
79 + @updated="loadConfigs"
80 + @edit="openEditForm"
81 + />
82 + </div>
83 +</template>
84 +
85 +<script setup lang="ts">
86 +import type { GitHubAuditConfig } from "@/types/githubAudit.d"
87 +import { NButton, NCard, NEmpty, NGi, NGrid, NInput, NSelect, NSpin, useMessage } from "naive-ui"
88 +import { onMounted, ref } from "vue"
89 +import Api from "@/api"
90 +import Icon from "@/components/common/Icon.vue"
91 +import GitHubAuditCard from "./GitHubAuditCard.vue"
92 +import GitHubAuditConfigForm from "./GitHubAuditConfigForm.vue"
93 +import GitHubAuditDetail from "./GitHubAuditDetail.vue"
94 +
95 +const AddIcon = "ion:add"
96 +const SearchIcon = "ion:search-outline"
97 +
98 +const message = useMessage()
99 +const loading = ref(false)
100 +const configs = ref<GitHubAuditConfig[]>([])
101 +const showForm = ref(false)
102 +const showDetail = ref(false)
103 +const selectedConfig = ref<GitHubAuditConfig | null>(null)
104 +
105 +// Filters - inline instead of separate component
106 +const filterCustomerCode = ref<string | null>(null)
107 +const filterStatus = ref<string | null>(null)
108 +const filterOrganization = ref<string | null>(null)
109 +const customerOptions = ref<{ label: string; value: string }[]>([])
110 +const loadingCustomers = ref(false)
111 +
112 +const statusOptions = [
113 + { label: "Enabled", value: "enabled" },
114 + { label: "Disabled", value: "disabled" }
115 +]
116 +
117 +async function loadCustomers() {
118 + if (loadingCustomers.value) return
119 +
120 + loadingCustomers.value = true
121 + try {
122 + const response = await Api.customers.getCustomers()
123 + if (response.data.customers) {
124 + customerOptions.value = response.data.customers.map((c: any) => ({
125 + label: `${c.customer_name} (${c.customer_code})`,
126 + value: c.customer_code
127 + }))
128 + }
129 + } catch (error) {
130 + console.error("Failed to load customers:", error)
131 + } finally {
132 + loadingCustomers.value = false
133 + }
134 +}
135 +
136 +async function loadConfigs() {
137 + if (loading.value) return
138 +
139 + loading.value = true
140 + try {
141 + const response = await Api.githubAudit.getConfigs({
142 + customerCode: filterCustomerCode.value || undefined,
143 + enabled: filterStatus.value === "enabled"
144 +? true :
145 + filterStatus.value === "disabled" ? false : undefined,
146 + organization: filterOrganization.value || undefined
147 + })
148 + configs.value = response.data.configs || []
149 + } catch (error: any) {
150 + message.error(error.response?.data?.detail || "Failed to load configurations")
151 + configs.value = []
152 + } finally {
153 + loading.value = false
154 + }
155 +}
156 +
157 +function openCreateForm() {
158 + selectedConfig.value = null
159 + showForm.value = true
160 +}
161 +
162 +function openEditForm(config: GitHubAuditConfig) {
163 + selectedConfig.value = config
164 + showDetail.value = false
165 + showForm.value = true
166 +}
167 +
168 +function openDetail(config: GitHubAuditConfig) {
169 + selectedConfig.value = config
170 + showDetail.value = true
171 +}
172 +
173 +function onConfigSaved() {
174 + showForm.value = false
175 + loadConfigs()
176 +}
177 +
178 +onMounted(() => {
179 + loadCustomers()
180 + loadConfigs()
181 +})
182 +</script>
frontend/src/components/githubAudit/GitHubAuditGradeBadge.vue new
+35
@@ -0,0 +1,35 @@
1 +<template>
2 + <n-tag :type="gradeType" :bordered="false" round size="small">
3 + {{ grade }}
4 + </n-tag>
5 +</template>
6 +
7 +<script setup lang="ts">
8 +import { NTag } from "naive-ui"
9 +import { computed } from "vue"
10 +
11 +const props = defineProps<{
12 + grade: string
13 + score?: number
14 +}>()
15 +
16 +const gradeType = computed(() => {
17 + switch (props.grade) {
18 + case "A":
19 + case "A+":
20 + return "success"
21 + case "B":
22 + case "B+":
23 + return "info"
24 + case "C":
25 + case "C+":
26 + return "warning"
27 + case "D":
28 + case "D+":
29 + case "F":
30 + return "error"
31 + default:
32 + return "default"
33 + }
34 +})
35 +</script>
frontend/src/components/githubAudit/GitHubAuditInfo.vue new
+405
@@ -0,0 +1,405 @@
1 +<template>
2 + <n-drawer v-model:show="showDrawer" :width="700" placement="right">
3 + <n-drawer-content closable>
4 + <template #header>
5 + <div class="flex items-center gap-3">
6 + <n-icon size="24">
7 + <Icon :name="InfoIcon" />
8 + </n-icon>
9 + <span>GitHub Audit Reference Guide</span>
10 + </div>
11 + </template>
12 +
13 + <div class="space-y-6">
14 + <!-- Status Legend -->
15 + <n-card size="small" title="Interpreting Results">
16 + <div class="grid grid-cols-2 gap-3">
17 + <div class="flex items-center gap-2">
18 + <n-tag type="success" size="small">PASS</n-tag>
19 + <span class="text-sm">Control meets baseline</span>
20 + </div>
21 + <div class="flex items-center gap-2">
22 + <n-tag type="error" size="small">FAIL</n-tag>
23 + <span class="text-sm">Remediation recommended</span>
24 + </div>
25 + <div class="flex items-center gap-2">
26 + <n-tag type="warning" size="small">WARN</n-tag>
27 + <span class="text-sm">Attention required</span>
28 + </div>
29 + <div class="flex items-center gap-2">
30 + <n-tag type="default" size="small">SKIP</n-tag>
31 + <span class="text-sm">Cannot evaluate</span>
32 + </div>
33 + </div>
34 + <n-divider />
35 + <div class="text-sm text-gray-500">
36 + <p class="mb-2"><strong>Skip Reasons:</strong></p>
37 + <ul class="list-disc list-inside space-y-1">
38 + <li><code>not_authorized</code> — Token/user missing permission</li>
39 + <li><code>not_supported</code> — Plan/feature not available</li>
40 + <li><code>error</code> — Transient/API error; retry or inspect details</li>
41 + </ul>
42 + </div>
43 + </n-card>
44 +
45 + <!-- Controls Coverage -->
46 + <n-collapse>
47 + <n-collapse-item title="Controls Coverage" name="controls">
48 + <template #header-extra>
49 + <n-tag size="small" type="info">What We Check</n-tag>
50 + </template>
51 +
52 + <n-card size="small" title="Organization-Level (Governance)" class="mb-3">
53 + <n-list>
54 + <n-list-item v-for="control in orgControls" :key="control.id">
55 + <template #prefix>
56 + <n-icon :color="control.critical ? '#e88080' : '#63e2b7'">
57 + <Icon :name="control.critical ? 'ion:alert-circle' : 'ion:checkmark-circle'" />
58 + </n-icon>
59 + </template>
60 + <div>
61 + <div class="font-medium">{{ control.name }}</div>
62 + <div class="text-sm text-gray-500">{{ control.description }}</div>
63 + </div>
64 + </n-list-item>
65 + </n-list>
66 + </n-card>
67 +
68 + <n-card size="small" title="Repository-Level (Posture)">
69 + <n-list>
70 + <n-list-item v-for="control in repoControls" :key="control.id">
71 + <template #prefix>
72 + <n-icon :color="control.critical ? '#e88080' : '#63e2b7'">
73 + <Icon :name="control.critical ? 'ion:alert-circle' : 'ion:checkmark-circle'" />
74 + </n-icon>
75 + </template>
76 + <div>
77 + <div class="font-medium">{{ control.name }}</div>
78 + <div class="text-sm text-gray-500">{{ control.description }}</div>
79 + </div>
80 + </n-list-item>
81 + </n-list>
82 + </n-card>
83 + </n-collapse-item>
84 +
85 + <!-- API Permissions -->
86 + <n-collapse-item title="Required API Permissions" name="permissions">
87 + <template #header-extra>
88 + <n-tag size="small" type="warning">Read-Only</n-tag>
89 + </template>
90 +
91 + <n-alert type="info" class="mb-4">
92 + This audit is intentionally <strong>read-only</strong>. No write or admin scopes are required.
93 + </n-alert>
94 +
95 + <n-tabs type="segment" animated>
96 + <n-tab-pane name="fine-grained" tab="Fine-Grained PAT (Recommended)">
97 + <div class="space-y-4">
98 + <p class="text-sm">
99 + Create a fine-grained PAT restricted to only the target organization and repos you intend to
100 + audit.
101 + </p>
102 +
103 + <n-card size="small" title="Organization Permissions (READ)">
104 + <n-list>
105 + <n-list-item v-for="perm in fineGrainedOrgPerms" :key="perm.name">
106 + <template #prefix>
107 + <n-tag :type="perm.required ? 'error' : 'default'" size="small">
108 + {{ perm.required ? "Required" : "Optional" }}
109 + </n-tag>
110 + </template>
111 + <div>
112 + <div class="font-medium">{{ perm.name }}</div>
113 + <div class="text-sm text-gray-500">{{ perm.description }}</div>
114 + </div>
115 + </n-list-item>
116 + </n-list>
117 + </n-card>
118 +
119 + <n-card size="small" title="Repository Permissions (READ)">
120 + <n-list>
121 + <n-list-item v-for="perm in fineGrainedRepoPerms" :key="perm.name">
122 + <template #prefix>
123 + <n-tag :type="perm.required ? 'error' : 'default'" size="small">
124 + {{ perm.required ? "Required" : "Optional" }}
125 + </n-tag>
126 + </template>
127 + <div>
128 + <div class="font-medium">{{ perm.name }}</div>
129 + <div class="text-sm text-gray-500">{{ perm.description }}</div>
130 + </div>
131 + </n-list-item>
132 + </n-list>
133 + </n-card>
134 + </div>
135 + </n-tab-pane>
136 +
137 + <n-tab-pane name="classic" tab="Classic PAT (Fallback)">
138 + <div class="space-y-4">
139 + <n-alert type="warning" class="mb-4">
140 + Classic PATs have broader scope. Use fine-grained PATs when possible.
141 + </n-alert>
142 +
143 + <n-card size="small" title="Required Scopes">
144 + <n-list>
145 + <n-list-item v-for="scope in classicScopes" :key="scope.name">
146 + <template #prefix>
147 + <n-tag :type="scope.required ? 'error' : 'default'" size="small">
148 + {{ scope.required ? "Required" : "Optional" }}
149 + </n-tag>
150 + </template>
151 + <div>
152 + <div class="font-mono font-medium">{{ scope.name }}</div>
153 + <div class="text-sm text-gray-500">{{ scope.description }}</div>
154 + </div>
155 + </n-list-item>
156 + </n-list>
157 + </n-card>
158 + </div>
159 + </n-tab-pane>
160 + </n-tabs>
161 + </n-collapse-item>
162 +
163 + <!-- API Endpoints -->
164 + <n-collapse-item title="API Endpoints Used" name="endpoints">
165 + <template #header-extra>
166 + <n-tag size="small">GET Only</n-tag>
167 + </template>
168 +
169 + <n-card size="small" title="Organization Endpoints" class="mb-3">
170 + <n-list>
171 + <n-list-item v-for="endpoint in orgEndpoints" :key="endpoint.path">
172 + <div class="font-mono text-sm">
173 + <span class="text-green-500">GET</span>
174 + {{ endpoint.path }}
175 + </div>
176 + <div v-if="endpoint.note" class="text-xs text-gray-500 mt-1">{{ endpoint.note }}</div>
177 + </n-list-item>
178 + </n-list>
179 + </n-card>
180 +
181 + <n-card size="small" title="Repository Endpoints">
182 + <n-list>
183 + <n-list-item v-for="endpoint in repoEndpoints" :key="endpoint.path">
184 + <div class="font-mono text-sm">
185 + <span class="text-green-500">GET</span>
186 + {{ endpoint.path }}
187 + </div>
188 + <div v-if="endpoint.note" class="text-xs text-gray-500 mt-1">{{ endpoint.note }}</div>
189 + </n-list-item>
190 + </n-list>
191 + </n-card>
192 + </n-collapse-item>
193 + </n-collapse>
194 + </div>
195 + </n-drawer-content>
196 + </n-drawer>
197 +</template>
198 +
199 +<script setup lang="ts">
200 +import {
201 + NAlert,
202 + NCard,
203 + NCollapse,
204 + NCollapseItem,
205 + NDivider,
206 + NDrawer,
207 + NDrawerContent,
208 + NIcon,
209 + NList,
210 + NListItem,
211 + NTabPane,
212 + NTabs,
213 + NTag
214 +} from "naive-ui"
215 +import { computed } from "vue"
216 +import Icon from "@/components/common/Icon.vue"
217 +
218 +const props = defineProps<{
219 + show: boolean
220 +}>()
221 +
222 +const emit = defineEmits<{
223 + (e: "update:show", value: boolean): void
224 +}>()
225 +
226 +const InfoIcon = "ion:information-circle-outline"
227 +
228 +const showDrawer = computed({
229 + get: () => props.show,
230 + set: (value) => emit("update:show", value)
231 +})
232 +
233 +// Controls data
234 +const orgControls = [
235 + {
236 + id: "mfa",
237 + name: "MFA Enforcement",
238 + description: "Require two-factor authentication for all organization members",
239 + critical: true
240 + },
241 + {
242 + id: "org-owners",
243 + name: "Org Owner Minimization",
244 + description: "Limit the number of organization owners to reduce risk",
245 + critical: false
246 + },
247 + {
248 + id: "outside-collab",
249 + name: "Outside Collaborator Monitoring",
250 + description: "Track external collaborators with access to repositories",
251 + critical: false
252 + },
253 + {
254 + id: "saml-sso",
255 + name: "SAML SSO Enforced",
256 + description: "Enforce single sign-on for centralized authentication",
257 + critical: true
258 + },
259 + {
260 + id: "pat-expiry",
261 + name: "PAT Expiration Enforced",
262 + description: "Require personal access tokens to have expiration dates",
263 + critical: false
264 + },
265 + {
266 + id: "actions-policy",
267 + name: "GitHub Actions Policy",
268 + description: "Control allowed actions, default permissions, and fork PR approvals",
269 + critical: false
270 + },
271 + {
272 + id: "audit-log",
273 + name: "Audit Log Monitoring",
274 + description: "Monitor control-plane changes via audit log detection pack",
275 + critical: false
276 + }
277 +]
278 +
279 +const repoControls = [
280 + {
281 + id: "visibility",
282 + name: "Repository Visibility",
283 + description: "Ensure appropriate public/private visibility settings",
284 + critical: false
285 + },
286 + {
287 + id: "branch-protection",
288 + name: "Branch Protection",
289 + description: "Enforce approvals, status checks, and prevent force pushes",
290 + critical: true
291 + },
292 + {
293 + id: "secret-scanning",
294 + name: "Secret Scanning + Push Protection",
295 + description: "Detect and block secrets in code before they're exposed",
296 + critical: true
297 + },
298 + {
299 + id: "dependabot",
300 + name: "Dependabot Alerts",
301 + description: "Monitor dependencies for known vulnerabilities",
302 + critical: true
303 + },
304 + {
305 + id: "code-scanning",
306 + name: "Code Scanning",
307 + description: "Identify security vulnerabilities in source code",
308 + critical: false
309 + },
310 + {
311 + id: "environments",
312 + name: "Deployment Environments",
313 + description: "Protect deployment environments with reviewers and rules",
314 + critical: false
315 + }
316 +]
317 +
318 +// Fine-grained PAT permissions
319 +const fineGrainedOrgPerms = [
320 + {
321 + name: "Administration",
322 + description: "Required for org policy, Actions settings, SSO indicator, PAT policy",
323 + required: true
324 + },
325 + {
326 + name: "Members",
327 + description: "Required for outside collaborator visibility and membership endpoints",
328 + required: true
329 + },
330 + {
331 + name: "Audit Log",
332 + description: "Required for audit-log detections; otherwise those checks will SKIP",
333 + required: false
334 + }
335 +]
336 +
337 +const fineGrainedRepoPerms = [
338 + {
339 + name: "Administration",
340 + description: "Required for branch protection, environments, and repo settings",
341 + required: true
342 + },
343 + {
344 + name: "Contents",
345 + description: "Often required for workflow-related metadata visibility",
346 + required: true
347 + },
348 + {
349 + name: "Actions",
350 + description: "Recommended for Actions-related repo metadata",
351 + required: false
352 + },
353 + {
354 + name: "Security events",
355 + description: "Required for secret scanning, Dependabot alerts, code scanning status",
356 + required: true
357 + }
358 +]
359 +
360 +// Classic PAT scopes
361 +const classicScopes = [
362 + {
363 + name: "read:org",
364 + description: "Needed for org membership and org settings",
365 + required: true
366 + },
367 + {
368 + name: "repo",
369 + description: "Required to read branch protection and repo config on private repos",
370 + required: true
371 + }
372 +]
373 +
374 +// API endpoints
375 +const orgEndpoints = [
376 + { path: "/orgs/{org}", note: null },
377 + { path: "/orgs/{org}/repos", note: null },
378 + { path: "/orgs/{org}/actions/permissions", note: null },
379 + { path: "/orgs/{org}/actions/permissions/workflow", note: null },
380 + { path: "/orgs/{org}/actions/permissions/selected-actions", note: null },
381 + { path: "/orgs/{org}/personal-access-tokens/policies", note: "May be plan/role gated" },
382 + { path: "/orgs/{org}/audit-log", note: "Detections; plan/role gated" }
383 +]
384 +
385 +const repoEndpoints = [
386 + { path: "/repos/{org}/{repo}", note: "Includes security_and_analysis where available" },
387 + { path: "/repos/{org}/{repo}/branches/{branch}/protection", note: null },
388 + { path: "/repos/{org}/{repo}/environments", note: null }
389 +]
390 +</script>
391 +
392 +<style scoped>
393 +.space-y-6 > * + * {
394 + margin-top: 1.5rem;
395 +}
396 +
397 +.space-y-4 > * + * {
398 + margin-top: 1rem;
399 +}
400 +
401 +.grid-cols-2 {
402 + display: grid;
403 + grid-template-columns: repeat(2, 1fr);
404 +}
405 +</style>
frontend/src/components/githubAudit/GitHubAuditList.vue new
+205
@@ -0,0 +1,205 @@
1 +<template>
2 + <div class="github-audit-list">
3 + <n-card>
4 + <div class="flex justify-between items-center mb-4">
5 + <h2 class="text-xl font-semibold m-0">GitHub Audit Configurations</h2>
6 + <n-space>
7 + <n-button quaternary @click="showInfo = true">
8 + <template #icon>
9 + <n-icon><Icon :name="InfoIcon" /></n-icon>
10 + </template>
11 + Reference Guide
12 + </n-button>
13 + <n-button type="primary" @click="openCreateForm">
14 + <template #icon>
15 + <n-icon><Icon :name="AddIcon" /></n-icon>
16 + </template>
17 + New Configuration
18 + </n-button>
19 + </n-space>
20 + </div>
21 +
22 + <div class="github-audit-filters flex gap-4 flex-wrap items-center mb-4">
23 + <n-select
24 + v-model:value="filterCustomerCode"
25 + placeholder="Filter by Customer"
26 + clearable
27 + :options="customerOptions"
28 + :loading="loadingCustomers"
29 + style="min-width: 200px"
30 + @update:value="loadConfigs"
31 + />
32 + <n-select
33 + v-model:value="filterStatus"
34 + placeholder="Filter by Status"
35 + clearable
36 + :options="statusOptions"
37 + style="min-width: 150px"
38 + @update:value="loadConfigs"
39 + />
40 + <n-input
41 + v-model:value="filterOrganization"
42 + placeholder="Search organization..."
43 + clearable
44 + style="min-width: 200px"
45 + @keyup.enter="loadConfigs"
46 + @clear="loadConfigs"
47 + >
48 + <template #prefix>
49 + <n-icon><Icon :name="SearchIcon" /></n-icon>
50 + </template>
51 + </n-input>
52 + </div>
53 +
54 + <n-spin :show="loading">
55 + <div v-if="configs.length === 0 && !loading" class="text-center py-8">
56 + <n-empty description="No configurations found">
57 + <template #extra>
58 + <n-button type="primary" @click="openCreateForm">Create your first configuration</n-button>
59 + </template>
60 + </n-empty>
61 + </div>
62 +
63 + <n-grid v-else :cols="2" :x-gap="16" :y-gap="16">
64 + <n-gi v-for="config in configs" :key="config.id">
65 + <GitHubAuditCard
66 + :config="config"
67 + @click="openDetail(config)"
68 + @edit="openEditForm"
69 + @audit-complete="loadConfigs"
70 + />
71 + </n-gi>
72 + </n-grid>
73 + </n-spin>
74 + </n-card>
75 +
76 + <GitHubAuditConfigForm
77 + v-if="showForm"
78 + v-model:show="showForm"
79 + :config="selectedConfig"
80 + @saved="onConfigSaved"
81 + />
82 +
83 + <GitHubAuditDetail
84 + v-if="showDetail"
85 + v-model:show="showDetail"
86 + :config="selectedConfig"
87 + @updated="loadConfigs"
88 + @edit="openEditForm"
89 + />
90 +
91 + <GitHubAuditInfo v-model:show="showInfo" />
92 + </div>
93 +</template>
94 +
95 +<script setup lang="ts">
96 +import type { GitHubAuditConfig } from "@/types/githubAudit.d"
97 +import { NButton, NCard, NEmpty, NGi, NGrid, NIcon, NInput, NSelect, NSpace, NSpin, useMessage } from "naive-ui"
98 +import { onMounted, ref } from "vue"
99 +import Api from "@/api"
100 +import Icon from "@/components/common/Icon.vue"
101 +import GitHubAuditCard from "./GitHubAuditCard.vue"
102 +import GitHubAuditConfigForm from "./GitHubAuditConfigForm.vue"
103 +import GitHubAuditDetail from "./GitHubAuditDetail.vue"
104 +import GitHubAuditInfo from "./GitHubAuditInfo.vue"
105 +
106 +const AddIcon = "ion:add"
107 +const SearchIcon = "ion:search-outline"
108 +const InfoIcon = "ion:information-circle-outline"
109 +
110 +const message = useMessage()
111 +const loading = ref(false)
112 +const configs = ref<GitHubAuditConfig[]>([])
113 +const showForm = ref(false)
114 +const showDetail = ref(false)
115 +const showInfo = ref(false)
116 +const selectedConfig = ref<GitHubAuditConfig | null>(null)
117 +
118 +// Filters
119 +const filterCustomerCode = ref<string | null>(null)
120 +const filterStatus = ref<string | null>(null)
121 +const filterOrganization = ref<string | null>(null)
122 +const customerOptions = ref<{ label: string; value: string }[]>([])
123 +const loadingCustomers = ref(false)
124 +
125 +const statusOptions = [
126 + { label: "Enabled", value: "enabled" },
127 + { label: "Disabled", value: "disabled" }
128 +]
129 +
130 +async function loadCustomers() {
131 + if (loadingCustomers.value) return
132 +
133 + loadingCustomers.value = true
134 + try {
135 + const response = await Api.customers.getCustomers()
136 + if (response.data.customers) {
137 + customerOptions.value = response.data.customers.map((c: any) => ({
138 + label: `${c.customer_name} (${c.customer_code})`,
139 + value: c.customer_code
140 + }))
141 + }
142 + } catch (error) {
143 + console.error("Failed to load customers:", error)
144 + } finally {
145 + loadingCustomers.value = false
146 + }
147 +}
148 +
149 +async function loadConfigs() {
150 + if (loading.value) return
151 +
152 + loading.value = true
153 + try {
154 + const response = await Api.githubAudit.getConfigs(filterCustomerCode.value || undefined)
155 + if (response.data.configs) {
156 + let filteredConfigs = response.data.configs
157 +
158 + if (filterStatus.value) {
159 + const isEnabled = filterStatus.value === "enabled"
160 + filteredConfigs = filteredConfigs.filter((c: GitHubAuditConfig) => c.enabled === isEnabled)
161 + }
162 +
163 + if (filterOrganization.value) {
164 + const searchTerm = filterOrganization.value.toLowerCase()
165 + filteredConfigs = filteredConfigs.filter((c: GitHubAuditConfig) =>
166 + c.organization.toLowerCase().includes(searchTerm)
167 + )
168 + }
169 +
170 + configs.value = filteredConfigs
171 + }
172 + } catch (error: any) {
173 + message.error(error.response?.data?.detail || "Failed to load configurations")
174 + configs.value = []
175 + } finally {
176 + loading.value = false
177 + }
178 +}
179 +
180 +function openCreateForm() {
181 + selectedConfig.value = null
182 + showForm.value = true
183 +}
184 +
185 +function openEditForm(config: GitHubAuditConfig) {
186 + selectedConfig.value = config
187 + showDetail.value = false
188 + showForm.value = true
189 +}
190 +
191 +function openDetail(config: GitHubAuditConfig) {
192 + selectedConfig.value = config
193 + showDetail.value = true
194 +}
195 +
196 +function onConfigSaved() {
197 + showForm.value = false
198 + loadConfigs()
199 +}
200 +
201 +onMounted(() => {
202 + loadCustomers()
203 + loadConfigs()
204 +})
205 +</script>
frontend/src/components/githubAudit/GitHubAuditReportCard.vue new
+99
@@ -0,0 +1,99 @@
1 +<template>
2 + <n-card class="github-audit-report-card" hoverable size="small" @click="$emit('click', report)">
3 + <div class="flex justify-between items-center">
4 + <div class="flex-1">
5 + <div class="flex items-center gap-2 mb-1">
6 + <span class="font-medium">{{ report.report_name }}</span>
7 + <n-tag :type="statusType" size="small">{{ report.status }}</n-tag>
8 + </div>
9 + <div class="text-secondary text-xs">
10 + {{ formatDate(report.audit_started_at) }}
11 + <span v-if="report.audit_duration_seconds">
12 + • {{ report.audit_duration_seconds.toFixed(1) }}s
13 + </span>
14 + </div>
15 + </div>
16 +
17 + <div class="flex items-center gap-4">
18 + <div class="text-center">
19 + <div class="text-2xl font-bold" :class="scoreClass">{{ report.score.toFixed(0) }}%</div>
20 + <GitHubAuditGradeBadge :grade="report.grade" />
21 + </div>
22 +
23 + <div class="findings-summary text-xs">
24 + <div v-if="report.critical_findings" class="text-error">
25 + {{ report.critical_findings }} Critical
26 + </div>
27 + <div v-if="report.high_findings" class="text-warning">
28 + {{ report.high_findings }} High
29 + </div>
30 + <div class="text-secondary">
31 + {{ report.passed_checks }}/{{ report.total_checks }} Passed
32 + </div>
33 + </div>
34 + </div>
35 + </div>
36 + </n-card>
37 +</template>
38 +
39 +<script setup lang="ts">
40 +import type { GitHubAuditReportSummary } from "@/types/githubAudit.d"
41 +import { NCard, NTag } from "naive-ui"
42 +import { computed } from "vue"
43 +import { formatDate } from "@/utils"
44 +import GitHubAuditGradeBadge from "./GitHubAuditGradeBadge.vue"
45 +
46 +const props = defineProps<{
47 + report: GitHubAuditReportSummary
48 +}>()
49 +
50 +defineEmits<{
51 + (e: "click", report: GitHubAuditReportSummary): void
52 +}>()
53 +
54 +const statusType = computed(() => {
55 + switch (props.report.status) {
56 + case "completed":
57 + return "success"
58 + case "running":
59 + return "info"
60 + case "failed":
61 + return "error"
62 + default:
63 + return "default"
64 + }
65 +})
66 +
67 +const scoreClass = computed(() => {
68 + if (props.report.score >= 80) return "text-success"
69 + if (props.report.score >= 60) return "text-warning"
70 + return "text-error"
71 +})
72 +</script>
73 +
74 +<style scoped>
75 +.github-audit-report-card {
76 + cursor: pointer;
77 + transition: all 0.2s ease;
78 +}
79 +
80 +.github-audit-report-card:hover {
81 + transform: translateY(-1px);
82 +}
83 +
84 +.text-secondary {
85 + color: var(--text-color-3);
86 +}
87 +
88 +.text-success {
89 + color: var(--success-color);
90 +}
91 +
92 +.text-warning {
93 + color: var(--warning-color);
94 +}
95 +
96 +.text-error {
97 + color: var(--error-color);
98 +}
99 +</style>
frontend/src/components/githubAudit/GitHubAuditReportDetail.vue new
+366
@@ -0,0 +1,366 @@
1 +<template>
2 + <n-drawer v-model:show="showDrawer" :width="900" placement="right">
3 + <n-drawer-content v-if="report" closable>
4 + <template #header>
5 + <div class="flex items-center gap-3">
6 + <span>{{ report.report_name }}</span>
7 + <n-tag :type="statusType" size="small">{{ report.status }}</n-tag>
8 + </div>
9 + </template>
10 +
11 + <div class="space-y-6">
12 + <!-- Summary Section -->
13 + <n-card title="Summary" size="small">
14 + <n-grid :cols="4" :x-gap="16" :y-gap="16">
15 + <n-gi>
16 + <n-statistic label="Score">
17 + <template #default>
18 + <span :class="scoreClass">{{ report.score.toFixed(1) }}%</span>
19 + </template>
20 + <template #suffix>
21 + <GitHubAuditGradeBadge :grade="report.grade" />
22 + </template>
23 + </n-statistic>
24 + </n-gi>
25 + <n-gi>
26 + <n-statistic label="Repos Audited" :value="report.total_repos_audited" />
27 + </n-gi>
28 + <n-gi>
29 + <n-statistic label="Checks Passed">
30 + <template #default>
31 + {{ report.passed_checks }} / {{ report.total_checks }}
32 + </template>
33 + </n-statistic>
34 + </n-gi>
35 + <n-gi>
36 + <n-statistic label="Duration">
37 + <template #default>
38 + {{ report.audit_duration_seconds?.toFixed(1) || "N/A" }}s
39 + </template>
40 + </n-statistic>
41 + </n-gi>
42 + </n-grid>
43 + </n-card>
44 +
45 + <!-- Findings Summary -->
46 + <n-card title="Findings by Severity" size="small">
47 + <n-grid :cols="4" :x-gap="16">
48 + <n-gi>
49 + <div class="finding-stat critical">
50 + <div class="number">{{ report.critical_findings }}</div>
51 + <div class="label">Critical</div>
52 + </div>
53 + </n-gi>
54 + <n-gi>
55 + <div class="finding-stat high">
56 + <div class="number">{{ report.high_findings }}</div>
57 + <div class="label">High</div>
58 + </div>
59 + </n-gi>
60 + <n-gi>
61 + <div class="finding-stat medium">
62 + <div class="number">{{ report.medium_findings }}</div>
63 + <div class="label">Medium</div>
64 + </div>
65 + </n-gi>
66 + <n-gi>
67 + <div class="finding-stat low">
68 + <div class="number">{{ report.low_findings }}</div>
69 + <div class="label">Low</div>
70 + </div>
71 + </n-gi>
72 + </n-grid>
73 + </n-card>
74 +
75 + <!-- Top Findings -->
76 + <n-card v-if="report.top_findings && report.top_findings.length > 0" title="Top Findings" size="small">
77 + <n-table :bordered="false" :single-line="false" size="small">
78 + <thead>
79 + <tr>
80 + <th>Severity</th>
81 + <th>Check</th>
82 + <th>Resource</th>
83 + <th>Description</th>
84 + </tr>
85 + </thead>
86 + <tbody>
87 + <tr v-for="(finding, index) in report.top_findings" :key="index">
88 + <td>
89 + <n-tag :type="getSeverityType(finding.severity)" size="small">
90 + {{ finding.severity }}
91 + </n-tag>
92 + </td>
93 + <td>{{ finding.check_name }}</td>
94 + <td>{{ finding.resource_name || "N/A" }}</td>
95 + <td>{{ finding.description }}</td>
96 + </tr>
97 + </tbody>
98 + </n-table>
99 + </n-card>
100 +
101 + <!-- Organization Results -->
102 + <n-card
103 + v-if="report.full_report?.organization_results"
104 + title="Organization Settings"
105 + size="small"
106 + >
107 + <n-table :bordered="false" :single-line="false" size="small">
108 + <thead>
109 + <tr>
110 + <th>Status</th>
111 + <th>Check</th>
112 + <th>Description</th>
113 + </tr>
114 + </thead>
115 + <tbody>
116 + <tr v-for="check in report.full_report.organization_results.checks" :key="check.check_id">
117 + <td>
118 + <n-tag :type="getStatusType(check.status)" size="small">
119 + {{ check.status }}
120 + </n-tag>
121 + </td>
122 + <td>{{ check.check_name }}</td>
123 + <td>{{ check.description }}</td>
124 + </tr>
125 + </tbody>
126 + </n-table>
127 + </n-card>
128 +
129 + <!-- Repository Results -->
130 + <n-card
131 + v-if="report.full_report?.repository_results?.length"
132 + title="Repository Results"
133 + size="small"
134 + >
135 + <n-collapse>
136 + <n-collapse-item
137 + v-for="repo in report.full_report.repository_results"
138 + :key="repo.repo_name"
139 + :title="repo.repo_name"
140 + >
141 + <template #header-extra>
142 + <n-space>
143 + <n-tag type="success" size="small">{{ repo.passed_count }} passed</n-tag>
144 + <n-tag v-if="repo.failed_count > 0" type="error" size="small">
145 + {{ repo.failed_count }} failed
146 + </n-tag>
147 + </n-space>
148 + </template>
149 +
150 + <n-table :bordered="false" :single-line="false" size="small">
151 + <thead>
152 + <tr>
153 + <th>Status</th>
154 + <th>Check</th>
155 + <th>Description</th>
156 + </tr>
157 + </thead>
158 + <tbody>
159 + <tr v-for="check in repo.checks" :key="check.check_id">
160 + <td>
161 + <n-tag :type="getStatusType(check.status)" size="small">
162 + {{ check.status }}
163 + </n-tag>
164 + </td>
165 + <td>{{ check.check_name }}</td>
166 + <td>{{ check.description }}</td>
167 + </tr>
168 + </tbody>
169 + </n-table>
170 + </n-collapse-item>
171 + </n-collapse>
172 + </n-card>
173 +
174 + <!-- Error Message -->
175 + <n-alert v-if="report.error_message" title="Error" type="error">
176 + {{ report.error_message }}
177 + </n-alert>
178 + </div>
179 +
180 + <template #footer>
181 + <div class="flex justify-between">
182 + <n-popconfirm @positive-click="deleteReport">
183 + <template #trigger>
184 + <n-button type="error" ghost>Delete Report</n-button>
185 + </template>
186 + Are you sure you want to delete this report?
187 + </n-popconfirm>
188 + <n-button @click="showDrawer = false">Close</n-button>
189 + </div>
190 + </template>
191 + </n-drawer-content>
192 + </n-drawer>
193 +</template>
194 +
195 +<script setup lang="ts">
196 +import type { GitHubAuditReport } from "@/types/githubAudit.d"
197 +import {
198 + NAlert,
199 + NButton,
200 + NCard,
201 + NCollapse,
202 + NCollapseItem,
203 + NDrawer,
204 + NDrawerContent,
205 + NGi,
206 + NGrid,
207 + NPopconfirm,
208 + NSpace,
209 + NStatistic,
210 + NTable,
211 + NTag,
212 + useMessage
213 +} from "naive-ui"
214 +import { computed } from "vue"
215 +import Api from "@/api"
216 +import { AuditStatus, SeverityLevel } from "@/types/githubAudit.d"
217 +import GitHubAuditGradeBadge from "./GitHubAuditGradeBadge.vue"
218 +
219 +const props = defineProps<{
220 + show: boolean
221 + report: GitHubAuditReport | null
222 +}>()
223 +
224 +const emit = defineEmits<{
225 + (e: "update:show", value: boolean): void
226 + (e: "deleted"): void
227 +}>()
228 +
229 +const message = useMessage()
230 +
231 +const showDrawer = computed({
232 + get: () => props.show,
233 + set: (value) => emit("update:show", value)
234 +})
235 +
236 +const statusType = computed(() => {
237 + switch (props.report?.status) {
238 + case "completed":
239 + return "success"
240 + case "running":
241 + return "info"
242 + case "failed":
243 + return "error"
244 + default:
245 + return "default"
246 + }
247 +})
248 +
249 +const scoreClass = computed(() => {
250 + const score = props.report?.score ?? 0
251 + if (score >= 80) return "text-success"
252 + if (score >= 60) return "text-warning"
253 + return "text-error"
254 +})
255 +
256 +function getStatusType(status: AuditStatus | string) {
257 + switch (status) {
258 + case AuditStatus.PASS:
259 + case "pass":
260 + return "success"
261 + case AuditStatus.FAIL:
262 + case "fail":
263 + return "error"
264 + case AuditStatus.WARNING:
265 + case "warning":
266 + return "warning"
267 + default:
268 + return "default"
269 + }
270 +}
271 +
272 +function getSeverityType(severity: SeverityLevel | string) {
273 + switch (severity) {
274 + case SeverityLevel.CRITICAL:
275 + case "critical":
276 + return "error"
277 + case SeverityLevel.HIGH:
278 + case "high":
279 + return "warning"
280 + case SeverityLevel.MEDIUM:
281 + case "medium":
282 + return "info"
283 + default:
284 + return "default"
285 + }
286 +}
287 +
288 +async function deleteReport() {
289 + if (!props.report) return
290 +
291 + try {
292 + await Api.githubAudit.deleteReport(props.report.id)
293 + message.success("Report deleted")
294 + showDrawer.value = false
295 + emit("deleted")
296 + } catch (error: any) {
297 + message.error("Failed to delete report")
298 + }
299 +}
300 +</script>
301 +
302 +<style scoped>
303 +.space-y-6 > * + * {
304 + margin-top: 1.5rem;
305 +}
306 +
307 +.finding-stat {
308 + text-align: center;
309 + padding: 16px;
310 + border-radius: 8px;
311 +}
312 +
313 +.finding-stat .number {
314 + font-size: 2rem;
315 + font-weight: bold;
316 +}
317 +
318 +.finding-stat .label {
319 + font-size: 0.875rem;
320 + color: var(--text-color-3);
321 +}
322 +
323 +.finding-stat.critical {
324 + background: rgba(208, 48, 80, 0.1);
325 +}
326 +
327 +.finding-stat.critical .number {
328 + color: #d03050;
329 +}
330 +
331 +.finding-stat.high {
332 + background: rgba(240, 160, 32, 0.1);
333 +}
334 +
335 +.finding-stat.high .number {
336 + color: #f0a020;
337 +}
338 +
339 +.finding-stat.medium {
340 + background: rgba(32, 128, 240, 0.1);
341 +}
342 +
343 +.finding-stat.medium .number {
344 + color: #2080f0;
345 +}
346 +
347 +.finding-stat.low {
348 + background: rgba(24, 160, 88, 0.1);
349 +}
350 +
351 +.finding-stat.low .number {
352 + color: #18a058;
353 +}
354 +
355 +.text-success {
356 + color: var(--success-color);
357 +}
358 +
359 +.text-warning {
360 + color: var(--warning-color);
361 +}
362 +
363 +.text-error {
364 + color: var(--error-color);
365 +}
366 +</style>
frontend/src/components/githubAudit/GitHubAuditStats.vue new
+54
@@ -0,0 +1,54 @@
1 +<template>
2 + <div class="github-audit-stats">
3 + <n-grid :cols="4" :x-gap="16" :y-gap="16">
4 + <n-gi>
5 + <n-statistic label="Total Configs" :value="stats.totalConfigs" />
6 + </n-gi>
7 + <n-gi>
8 + <n-statistic label="Active Configs" :value="stats.activeConfigs" />
9 + </n-gi>
10 + <n-gi>
11 + <n-statistic label="Total Reports" :value="stats.totalReports" />
12 + </n-gi>
13 + <n-gi>
14 + <n-statistic label="Avg Score">
15 + <template #default>
16 + <span :class="scoreClass">{{ stats.avgScore.toFixed(1) }}%</span>
17 + </template>
18 + </n-statistic>
19 + </n-gi>
20 + </n-grid>
21 + </div>
22 +</template>
23 +
24 +<script setup lang="ts">
25 +import { NGi, NGrid, NStatistic } from "naive-ui"
26 +import { computed } from "vue"
27 +
28 +const props = defineProps<{
29 + stats: {
30 + totalConfigs: number
31 + activeConfigs: number
32 + totalReports: number
33 + avgScore: number
34 + }
35 +}>()
36 +
37 +const scoreClass = computed(() => {
38 + if (props.stats.avgScore >= 80) return "text-success"
39 + if (props.stats.avgScore >= 60) return "text-warning"
40 + return "text-error"
41 +})
42 +</script>
43 +
44 +<style scoped>
45 +.text-success {
46 + color: var(--success-color);
47 +}
48 +.text-warning {
49 + color: var(--warning-color);
50 +}
51 +.text-error {
52 + color: var(--error-color);
53 +}
54 +</style>
frontend/src/components/githubAudit/index.ts new
+11
@@ -0,0 +1,11 @@
1 +export { default as GitHubAuditButton } from "./GitHubAuditButton.vue"
2 +export { default as GitHubAuditCard } from "./GitHubAuditCard.vue"
3 +export { default as GitHubAuditConfigForm } from "./GitHubAuditConfigForm.vue"
4 +export { default as GitHubAuditDetail } from "./GitHubAuditDetail.vue"
5 +export { default as GitHubAuditExclusionForm } from "./GitHubAuditExclusionForm.vue"
6 +export { default as GitHubAuditFilters } from "./GitHubAuditFilters.vue"
7 +export { default as GitHubAuditGradeBadge } from "./GitHubAuditGradeBadge.vue"
8 +export { default as GitHubAuditList } from "./GitHubAuditList.vue"
9 +export { default as GitHubAuditReportCard } from "./GitHubAuditReportCard.vue"
10 +export { default as GitHubAuditReportDetail } from "./GitHubAuditReportDetail.vue"
11 +export { default as GitHubAuditStats } from "./GitHubAuditStats.vue"
frontend/src/router/index.ts
+10
@@ -346,6 +346,16 @@ const router = createRouter({
346 component: () => import("@/views/WebVulnerabilityAssessment.vue"),
347 meta: { title: "Web Vuln. Assess.", auth: true, roles: RouteRole.All }
348 },
349 + {
350 + path: "/github-audit",
351 + name: "GitHubAudit",
352 + component: () => import("@/views/GitHubAuditOverview.vue"),
353 + meta: {
354 + title: "GitHub Audit",
355 + auth: true,
356 + roles: RouteRole.All
357 + }
358 + },
359 {
360 path: "/customer-portal",
361 name: "CustomerPortal",
frontend/src/types/githubAudit.d.ts new
+333
@@ -0,0 +1,333 @@
1 +export enum AuditStatus {
2 + PASS = "pass",
3 + FAIL = "fail",
4 + WARNING = "warning",
5 + NOT_APPLICABLE = "not_applicable"
6 +}
7 +
8 +export enum SeverityLevel {
9 + CRITICAL = "critical",
10 + HIGH = "high",
11 + MEDIUM = "medium",
12 + LOW = "low",
13 + INFO = "info"
14 +}
15 +
16 +// ==================== Request Types ====================
17 +
18 +export interface GitHubAuditRequest {
19 + organization: string
20 + include_repos?: boolean
21 + include_workflows?: boolean
22 + include_members?: boolean
23 + repo_filter?: string[]
24 +}
25 +
26 +export interface GitHubAuditConfigCreate {
27 + customer_code: string
28 + github_token: string
29 + organization: string
30 + token_type?: string
31 + token_expires_at?: string | null
32 + enabled?: boolean
33 + auto_audit_enabled?: boolean
34 + audit_schedule_cron?: string | null
35 + include_repos?: boolean
36 + include_workflows?: boolean
37 + include_members?: boolean
38 + include_archived_repos?: boolean
39 + repo_filter_mode?: string
40 + repo_filter_list?: string[] | null
41 + notify_on_critical?: boolean
42 + notify_on_high?: boolean
43 + notification_webhook_url?: string | null
44 + notification_email?: string | null
45 + minimum_passing_score?: number
46 + created_by?: string | null
47 +}
48 +
49 +export interface GitHubAuditConfigUpdate {
50 + github_token?: string | null
51 + organization?: string | null
52 + token_type?: string | null
53 + token_expires_at?: string | null
54 + enabled?: boolean | null
55 + auto_audit_enabled?: boolean | null
56 + audit_schedule_cron?: string | null
57 + include_repos?: boolean | null
58 + include_workflows?: boolean | null
59 + include_members?: boolean | null
60 + include_archived_repos?: boolean | null
61 + repo_filter_mode?: string | null
62 + repo_filter_list?: string[] | null
63 + notify_on_critical?: boolean | null
64 + notify_on_high?: boolean | null
65 + notification_webhook_url?: string | null
66 + notification_email?: string | null
67 + minimum_passing_score?: number | null
68 + updated_by?: string | null
69 +}
70 +
71 +export interface GitHubAuditExclusionCreate {
72 + check_id: string
73 + resource_name?: string | null
74 + resource_type?: string | null
75 + reason: string
76 + approved_by?: string | null
77 + expires_at?: string | null
78 + created_by: string
79 +}
80 +
81 +export interface GitHubAuditExclusionUpdate {
82 + reason?: string | null
83 + approved_by?: string | null
84 + expires_at?: string | null
85 + enabled?: boolean | null
86 +}
87 +
88 +export interface GitHubAuditBaselineCreate {
89 + name: string
90 + description?: string | null
91 + expected_checks?: Record<string, string> | null
92 + baseline_report_id?: number | null
93 + is_active?: boolean
94 + created_by: string
95 +}
96 +
97 +export interface GitHubAuditBaselineUpdate {
98 + name?: string | null
99 + description?: string | null
100 + is_active?: boolean | null
101 +}
102 +
103 +// ==================== Model Types ====================
104 +
105 +export interface GitHubAuditConfig {
106 + id: number
107 + customer_code: string
108 + github_token: string
109 + organization: string
110 + token_type: string
111 + token_expires_at: string | null
112 + enabled: boolean
113 + auto_audit_enabled: boolean
114 + audit_schedule_cron: string | null
115 + include_repos: boolean
116 + include_workflows: boolean
117 + include_members: boolean
118 + include_archived_repos: boolean
119 + repo_filter_mode: string
120 + repo_filter_list: string[] | null
121 + notify_on_critical: boolean
122 + notify_on_high: boolean
123 + notification_webhook_url: string | null
124 + notification_email: string | null
125 + minimum_passing_score: number
126 + created_at: string
127 + updated_at: string
128 + created_by: string | null
129 + updated_by: string | null
130 + last_audit_at: string | null
131 + last_audit_score: number | null
132 + last_audit_grade: string | null
133 +}
134 +
135 +export interface GitHubAuditReportSummary {
136 + id: number
137 + config_id: number
138 + customer_code: string
139 + report_name: string
140 + organization: string
141 + audit_started_at: string
142 + audit_completed_at: string | null
143 + audit_duration_seconds: number | null
144 + total_repos_audited: number
145 + total_checks: number
146 + passed_checks: number
147 + failed_checks: number
148 + warning_checks: number
149 + critical_findings: number
150 + high_findings: number
151 + medium_findings: number
152 + low_findings: number
153 + score: number
154 + grade: string
155 + status: string
156 + triggered_by: string
157 + triggered_by_user: string | null
158 +}
159 +
160 +export interface GitHubAuditReport extends GitHubAuditReportSummary {
161 + full_report: GitHubAuditResponse | null
162 + top_findings: AuditCheck[] | null
163 + error_message: string | null
164 +}
165 +
166 +export interface GitHubAuditCheckExclusion {
167 + id: number
168 + config_id: number
169 + customer_code: string
170 + check_id: string
171 + resource_name: string | null
172 + resource_type: string | null
173 + reason: string
174 + approved_by: string | null
175 + approved_at: string | null
176 + expires_at: string | null
177 + enabled: boolean
178 + created_at: string
179 + created_by: string
180 +}
181 +
182 +export interface GitHubAuditBaseline {
183 + id: number
184 + config_id: number
185 + customer_code: string
186 + name: string
187 + description: string | null
188 + expected_checks: Record<string, string> | null
189 + baseline_report_id: number | null
190 + is_active: boolean
191 + created_at: string
192 + created_by: string
193 +}
194 +
195 +export interface AvailableCheck {
196 + id: string
197 + name: string
198 + category: string
199 + severity: string
200 + description: string
201 +}
202 +
203 +// ==================== Audit Result Types ====================
204 +
205 +export interface AuditCheck {
206 + check_id: string
207 + check_name: string
208 + category: string
209 + status: AuditStatus
210 + severity: SeverityLevel
211 + description: string
212 + recommendation?: string | null
213 + details?: Record<string, unknown> | null
214 + resource_name?: string | null
215 + resource_type?: string | null
216 +}
217 +
218 +export interface RepositoryAuditResult {
219 + repo_name: string
220 + repo_full_name: string
221 + repo_url: string
222 + is_private: boolean
223 + is_archived: boolean
224 + default_branch: string
225 + checks: AuditCheck[]
226 + passed_count: number
227 + failed_count: number
228 + warning_count: number
229 +}
230 +
231 +export interface OrganizationAuditResult {
232 + org_name: string
233 + org_url: string
234 + checks: AuditCheck[]
235 + passed_count: number
236 + failed_count: number
237 + warning_count: number
238 +}
239 +
240 +export interface WorkflowAuditResult {
241 + repo_name: string
242 + workflow_name: string
243 + workflow_path: string
244 + checks: AuditCheck[]
245 +}
246 +
247 +export interface MemberAuditResult {
248 + username: string
249 + role: string
250 + has_2fa?: boolean | null
251 + checks: AuditCheck[]
252 +}
253 +
254 +export interface AuditSummary {
255 + organization: string
256 + audit_timestamp: string
257 + total_repos_audited: number
258 + total_checks: number
259 + passed_checks: number
260 + failed_checks: number
261 + warning_checks: number
262 + critical_findings: number
263 + high_findings: number
264 + medium_findings: number
265 + low_findings: number
266 + score: number
267 + grade: string
268 +}
269 +
270 +// ==================== Response Types ====================
271 +
272 +export interface GitHubAuditResponse {
273 + success: boolean
274 + message: string
275 + summary: AuditSummary | null
276 + organization_results: OrganizationAuditResult | null
277 + repository_results: RepositoryAuditResult[]
278 + workflow_results: WorkflowAuditResult[]
279 + member_results: MemberAuditResult[]
280 + top_findings: AuditCheck[]
281 +}
282 +
283 +export interface GitHubAuditSummaryResponse {
284 + success: boolean
285 + message: string
286 + summary: AuditSummary | null
287 + top_findings: AuditCheck[]
288 +}
289 +
290 +export interface GitHubAuditConfigResponse {
291 + success: boolean
292 + message: string
293 + config?: GitHubAuditConfig | null
294 + configs?: GitHubAuditConfig[] | null
295 +}
296 +
297 +export interface GitHubAuditReportListResponse {
298 + success: boolean
299 + message: string
300 + reports: GitHubAuditReportSummary[]
301 + total_count: number
302 +}
303 +
304 +export interface GitHubAuditReportResponse {
305 + success: boolean
306 + message: string
307 + report: GitHubAuditReport | null
308 +}
309 +
310 +export interface GitHubAuditExclusionResponse {
311 + success: boolean
312 + message: string
313 + exclusion?: GitHubAuditCheckExclusion | null
314 + exclusions?: GitHubAuditCheckExclusion[] | null
315 +}
316 +
317 +export interface GitHubAuditBaselineResponse {
318 + success: boolean
319 + message: string
320 + baseline?: GitHubAuditBaseline | null
321 + baselines?: GitHubAuditBaseline[] | null
322 +}
323 +
324 +export interface AvailableChecksResponse {
325 + success: boolean
326 + message: string
327 + checks: AvailableCheck[]
328 +}
329 +
330 +export interface DeleteResponse {
331 + success: boolean
332 + message: string
333 +}
frontend/src/views/GitHubAuditOverview.vue new
+9
@@ -0,0 +1,9 @@
1 +<template>
2 + <div class="page">
3 + <GitHubAuditList />
4 + </div>
5 +</template>
6 +
7 +<script setup lang="ts">
8 +import GitHubAuditList from "@/components/githubAudit/GitHubAuditList.vue"
9 +</script>
frontend/src/views/Overview.vue
+16 -13
@@ -10,6 +10,7 @@
10 <StackProvisioningButton size="small" type="primary" />
11 <CloudSecurityAssessmentButton size="small" type="primary" />
12 <WebVulnerabilityAssessmentButton size="small" type="primary" />
13 + <GitHubAuditButton size="small" type="primary" />
14 </div>
15 <div class="right-box hidden gap-3 min-[70rem]:flex">
16 <ActiveResponseWizardButton size="small" type="primary" />
@@ -72,6 +73,7 @@
73 <StackProvisioningButton size="small" type="primary" />
74 <CloudSecurityAssessmentButton size="small" type="primary" />
75 <WebVulnerabilityAssessmentButton size="small" type="primary" />
76 + <GitHubAuditButton size="small" type="primary" />
77 <ActiveResponseWizardButton size="small" type="primary" />
78 <ThreatIntelButton size="small" type="primary" />
79 </div>
@@ -88,6 +90,7 @@ import ActiveResponseWizardButton from "@/components/activeResponse/ActiveRespon
90 import CloudSecurityAssessmentButton from "@/components/cloudSecurityAssessment/CloudSecurityAssessmentButton.vue"
91 import Icon from "@/components/common/Icon.vue"
92 import VersionUpdateBanner from "@/components/common/VersionUpdateBanner.vue"
93 +import GitHubAuditButton from "@/components/githubAudit/GitHubAuditButton.vue"
94 import PipeList from "@/components/graylog/Pipelines/PipeList.vue"
95 import ClusterHealth from "@/components/indices/ClusterHealth.vue"
96 import IndicesMarquee from "@/components/indices/Marquee.vue"
@@ -110,26 +113,26 @@ const showQuickActions = ref(false)
113 const { gotoIndex, gotoGraylogPipelines } = useGoto()
114
115 useResizeObserver(page, entries => {
113 - const entry = entries[0]
114 - const { width } = entry.contentRect
116 + const entry = entries[0]
117 + const { width } = entry.contentRect
118
116 - cardDirection.value = width > 500 ? "horizontal" : "vertical"
119 + cardDirection.value = width > 500 ? "horizontal" : "vertical"
120 })
121 </script>
122
123 <style lang="scss" scoped>
124 .page {
122 - .section {
123 - margin-bottom: calc(var(--spacing) * 6);
125 + .section {
126 + margin-bottom: calc(var(--spacing) * 6);
127
125 - .columns {
126 - display: flex;
127 - gap: calc(var(--spacing) * 6);
128 + .columns {
129 + display: flex;
130 + gap: calc(var(--spacing) * 6);
131
129 - .stretchy {
130 - height: 100%;
131 - }
132 - }
133 - }
132 + .stretchy {
133 + height: 100%;
134 + }
135 + }
136 + }
137 }
138 </style>