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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/image-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

# ── Trivy scan → SARIF ───────────────────────────────────────────────────
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@0.28.0
uses: aquasecurity/trivy-action@v0.28.0

Check warning

Code scanning / Semgrep OSS

Semgrep Finding: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag Warning

GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.
with:
image-ref: apexchainx-backend:scan
format: sarif
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/semgrep.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
# p/owasp-top-ten – OWASP Top 10 coverage
- name: Run Semgrep
run: |
semgrep ci \
semgrep scan \
--config p/security-audit \
--config p/python \
--config p/owasp-top-ten \
Expand Down
6 changes: 2 additions & 4 deletions app/api/exception_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from pydantic import BaseModel, Field
from starlette.exceptions import HTTPException as StarletteHTTPException

from app.utils.correlation_ctx import get_correlation_id, get_or_generate_correlation_id
from app.utils.correlation_ctx import get_or_generate_correlation_id


class ProblemDetail(BaseModel):
Expand Down Expand Up @@ -73,9 +73,7 @@ async def http_exception_handler(request: Request, exc: StarletteHTTPException)
)


async def validation_exception_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
"""Handle Pydantic validation errors with field-level ``errors[].pointer``."""
errors = []
for e in exc.errors():
Expand Down
3 changes: 1 addition & 2 deletions app/api/v1/endpoints/api_keys.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
from datetime import datetime
from typing import Optional

from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session

from app.core.security import require_admin
from app.db.session import get_db
from app.core.security import require_admin
from app.models.auth import AuthUser
from app.services.api_key_store import create_api_key, list_api_keys, revoke_key
from app.services.audit_log import audit_log
Expand Down
18 changes: 7 additions & 11 deletions app/api/v1/endpoints/auth.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from fastapi import APIRouter, Header, HTTPException, status, Depends, Request
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session

from app.core.config import settings
from app.core.rate_limiter import rate_limiter
from app.core.security import get_current_user, hash_token, require_admin
from app.db.session import get_db
Expand All @@ -17,7 +18,6 @@
SessionInventoryResponse,
)
from app.repositories.user_repository import UserRepository, user_orm_to_pydantic
from app.core.config import settings
from app.services.auth_store import AuthStore
from app.services.credential_stuffing_detector import credential_stuffing_detector
from app.services.token_revocation import revoke
Expand Down Expand Up @@ -385,17 +385,15 @@ def impersonate_user(

def _generate_impersonation_token(target_orm, admin_user: AuthUser) -> str:
"""Generate a short-lived impersonation access token."""
import time
import hmac
import hashlib
import base64
import hashlib
import hmac
import json
import time

from app.core.config import settings as app_settings

header = base64.urlsafe_b64encode(
json.dumps({"alg": "HS256", "typ": "JWT"}).encode()
).rstrip(b"=").decode()
header = base64.urlsafe_b64encode(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()).rstrip(b"=").decode()

now = int(time.time())
payload_dict = {
Expand All @@ -406,9 +404,7 @@ def _generate_impersonation_token(target_orm, admin_user: AuthUser) -> str:
"exp": now + 900, # 15 minutes
"scope": "impersonate",
}
payload = base64.urlsafe_b64encode(
json.dumps(payload_dict).encode()
).rstrip(b"=").decode()
payload = base64.urlsafe_b64encode(json.dumps(payload_dict).encode()).rstrip(b"=").decode()

signing_key = (app_settings.SECRET_KEY or "apexchainx-dev-secret").encode()
signature = hmac.new(
Expand Down
17 changes: 9 additions & 8 deletions app/api/v1/endpoints/jobs.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import json
from datetime import datetime, timezone
from typing import List, Optional
from datetime import UTC, datetime
from uuid import UUID

from celery.result import AsyncResult
Expand All @@ -15,7 +14,7 @@
from app.services.job_cleanup import JobCleanupService
from app.services.metrics import increment_counter, timer
from app.tasks.celery_app import celery_app
from app.tasks.sla_tasks import enqueue_sla_computation, enqueue_bulk_sla_computation
from app.tasks.sla_tasks import enqueue_bulk_sla_computation, enqueue_sla_computation
from app.utils.correlation_ctx import get_correlation_id
from app.utils.logging import get_structured_logger

Expand Down Expand Up @@ -74,8 +73,8 @@ class JobRetentionStatsResponse(BaseModel):
class JobCleanupRequest(BaseModel):
"""Request parameters for job cleanup."""

successful_retention_days: Optional[int] = None
failed_retention_days: Optional[int] = None
successful_retention_days: int | None = None
failed_retention_days: int | None = None
dry_run: bool = False


Expand Down Expand Up @@ -429,7 +428,7 @@ def retry_job(

# Increment retry count and update status
job.retry_count += 1
job.last_retried_at = datetime.now(timezone.utc)
job.last_retried_at = datetime.now(UTC)
job.error = None # Clear previous error
job.status = JobStatus.PENDING
job.progress = 0.0
Expand Down Expand Up @@ -503,10 +502,12 @@ def retry_job(

except (ValueError, KeyError, TypeError) as e:
db.rollback()
logger.error("Failed to retry job due to data issue", job_id=str(job.id), error=str(e), correlation_id=correlation_id)
logger.error(
"Failed to retry job due to data issue", job_id=str(job.id), error=str(e), correlation_id=correlation_id
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to retry job due to invalid payload: {str(e)}",
detail=f"Failed to retry job due to invalid payload: {e!s}",
)
except Exception as e:
db.rollback()
Expand Down
26 changes: 10 additions & 16 deletions app/api/v1/endpoints/metrics.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Response, Depends
from app.services.metrics import metrics, _DEFAULT_LATENCY_BUCKETS
from datetime import UTC, datetime

from fastapi import APIRouter, Depends, Response

from app.core.security import require_engineer
from app.services.metrics import _DEFAULT_LATENCY_BUCKETS, metrics

router = APIRouter(prefix="/metrics", tags=["Metrics"])

Expand Down Expand Up @@ -91,12 +93,8 @@ def get_prometheus_metrics(current_user=Depends(require_engineer)):
cumulative = 0
for bucket_bound in sorted(buckets.keys()):
cumulative += buckets[bucket_bound]
prometheus_lines.append(
f'{metric_name}_bucket{{{base_labels}le="{bucket_bound}"}} {cumulative}'
)
prometheus_lines.append(
f'{metric_name}_bucket{{{base_labels}le="+Inf"}} {stats["count"]}'
)
prometheus_lines.append(f'{metric_name}_bucket{{{base_labels}le="{bucket_bound}"}} {cumulative}')
prometheus_lines.append(f'{metric_name}_bucket{{{base_labels}le="+Inf"}} {stats["count"]}')

# ── Timers (exported as histograms with percentile estimation) ───────
for key, stats in metrics_data["timers"].items():
Expand Down Expand Up @@ -128,18 +126,14 @@ def get_prometheus_metrics(current_user=Depends(require_engineer)):
else:
count = stats["count"]

prometheus_lines.append(
f'{metric_name}_seconds_bucket{{{base_labels}le="{bucket}"}} {count}'
)
prometheus_lines.append(f'{metric_name}_seconds_bucket{{{base_labels}le="{bucket}"}} {count}')

prometheus_lines.append(
f'{metric_name}_seconds_bucket{{{base_labels}le="+Inf"}} {stats["count"]}'
)
prometheus_lines.append(f'{metric_name}_seconds_bucket{{{base_labels}le="+Inf"}} {stats["count"]}')

# ── Process metadata ──────────────────────────────────────────────────
prometheus_lines.append("# HELP app_metrics_timestamp Timestamp of metrics collection")
prometheus_lines.append("# TYPE app_metrics_timestamp gauge")
prometheus_lines.append(f"app_metrics_timestamp {datetime.now(timezone.utc).timestamp()}")
prometheus_lines.append(f"app_metrics_timestamp {datetime.now(UTC).timestamp()}")

return Response(
content="\n".join(prometheus_lines) + "\n",
Expand Down
8 changes: 4 additions & 4 deletions app/api/v1/endpoints/oauth.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"""OAuth 2.0 authorization endpoints with PKCE and exact-match redirect_uri validation."""

from typing import Optional

import secrets

from fastapi import APIRouter, HTTPException, Query

from app.core.config import settings
from app.services.audit_log import audit_log
from app.services.oauth_session import oauth_state_repo
Expand Down Expand Up @@ -31,8 +31,8 @@ def authorize(provider: str, redirect_uri: str = Query(...), code_challenge: str
def callback(
provider: str,
state: str = Query(...),
code: Optional[str] = Query(None),
code_verifier: Optional[str] = Query(None),
code: str | None = Query(None),
code_verifier: str | None = Query(None),
):
if provider not in PROVIDERS:
raise HTTPException(status_code=400, detail=f"Unsupported provider: {provider}")
Expand Down
8 changes: 6 additions & 2 deletions app/api/v1/endpoints/outages.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,13 @@ def list_outages(
search: str | None = None,
start_date: datetime | None = None,
end_date: datetime | None = None,
page: int = Query(default=1, ge=1, description="Page number (offset pagination). Not used when cursor is provided."),
page: int = Query(
default=1, ge=1, description="Page number (offset pagination). Not used when cursor is provided."
),
page_size: int = Query(default=20, ge=1, le=100, description="Items per page."),
cursor: str | None = Query(default=None, description="Cursor for cursor-based pagination. Overrides page/page_size."),
cursor: str | None = Query(
default=None, description="Cursor for cursor-based pagination. Overrides page/page_size."
),
limit: int = Query(default=20, ge=1, le=100, description="Limit for cursor-based pagination (used with cursor)."),
sort_by: OutageSortField = Query(
default=OutageSortField.detected_at,
Expand Down
9 changes: 6 additions & 3 deletions app/api/v1/endpoints/payments.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
from app.models.payment import PaginatedPayments, PaymentTransaction, PaymentTransitionError
from app.repositories.payment_repository import PaymentRepository
from app.services.audit_log import audit_log
from app.core.security import require_admin, require_engineer

router = APIRouter()

Expand Down Expand Up @@ -61,9 +60,13 @@ class ReconciliationHistoryResponse(BaseModel):

@router.get("/")
def list_payments(
page: int = Query(default=1, ge=1, description="Page number (offset pagination). Not used when cursor is provided."),
page: int = Query(
default=1, ge=1, description="Page number (offset pagination). Not used when cursor is provided."
),
page_size: int = Query(default=20, ge=1, le=100, description="Items per page."),
cursor: str | None = Query(default=None, description="Cursor for cursor-based pagination. Overrides page/page_size."),
cursor: str | None = Query(
default=None, description="Cursor for cursor-based pagination. Overrides page/page_size."
),
limit: int = Query(default=20, ge=1, le=100, description="Limit for cursor-based pagination (used with cursor)."),
status: str | None = None,
type: str | None = None,
Expand Down
12 changes: 6 additions & 6 deletions app/api/v1/endpoints/sla.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import datetime, timezone
from datetime import UTC, datetime

from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy.orm import Session
Expand Down Expand Up @@ -26,13 +26,13 @@
publish_config_for_severity,
update_config_for_severity,
)
from app.utils.cache import TTLCache
from app.utils.analytics_exporter import (
export_analytics_summary,
export_dashboard_kpi,
export_performance_aggregation,
export_trends,
)
from app.utils.cache import TTLCache

router = APIRouter()

Expand Down Expand Up @@ -220,9 +220,9 @@ def aggregate_sla_performance(
"""Get SLA performance aggregation with optional date range filtering (BE-009)."""
resolved_site = site_id or site
if start_date and start_date.tzinfo is not None:
start_date = start_date.astimezone(timezone.utc).replace(tzinfo=None)
start_date = start_date.astimezone(UTC).replace(tzinfo=None)
if end_date and end_date.tzinfo is not None:
end_date = end_date.astimezone(timezone.utc).replace(tzinfo=None)
end_date = end_date.astimezone(UTC).replace(tzinfo=None)

if start_date and end_date and start_date > end_date:
raise HTTPException(status_code=400, detail="start_date cannot be after end_date")
Expand Down Expand Up @@ -384,9 +384,9 @@ def export_performance_aggregation_endpoint(
"""Export performance aggregation data in JSON or CSV format."""
resolved_site = site_id or site
if start_date and start_date.tzinfo is not None:
start_date = start_date.astimezone(timezone.utc).replace(tzinfo=None)
start_date = start_date.astimezone(UTC).replace(tzinfo=None)
if end_date and end_date.tzinfo is not None:
end_date = end_date.astimezone(timezone.utc).replace(tzinfo=None)
end_date = end_date.astimezone(UTC).replace(tzinfo=None)

if start_date and end_date and start_date > end_date:
raise HTTPException(status_code=400, detail="start_date cannot be after end_date")
Expand Down
9 changes: 3 additions & 6 deletions app/api/v1/endpoints/sla_dispute.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from datetime import datetime, timezone
import json
from datetime import datetime
from datetime import UTC, datetime

from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
Expand All @@ -9,18 +8,16 @@
from app.db.session import get_db
from app.models.orm.sla import SLAResultORM
from app.models.sla_dispute import DisputeAuditLog, DisputeStatus, SLADispute
from app.repositories.sla_repository import SLARepository
from app.schemas.sla_dispute import (
CreateProposedSLARequest,
DisputeAuditLogResponse,
DisputeFlagRequest,
DisputeResolveRequest,
DisputeResponse,
)
from app.core.security import require_engineer, require_admin
from app.services.metrics import (
increment_counter,
SLADISPUTE_NOTIFICATION_ATTEMPT_TOTAL,
increment_counter,
)
from app.services.sla.sla_calculator import SLACalculator

Expand Down Expand Up @@ -207,7 +204,7 @@ def resolve_dispute(
dispute.status = payload.status
dispute.resolved_by = payload.resolved_by
dispute.resolution_notes = payload.resolution_notes
dispute.resolved_at = datetime.now(timezone.utc)
dispute.resolved_at = datetime.now(UTC)

# If resolving and apply_proposed is true, mark the proposed SLA as latest
if payload.status == DisputeStatus.RESOLVED and payload.apply_proposed:
Expand Down
6 changes: 3 additions & 3 deletions app/api/v1/endpoints/wallets.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
All handlers now pass a request-scoped database session to WalletRegistry.
"""

from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session

from app.core.exceptions import ApexConflictError
from app.core.security import require_engineer
from app.db.session import get_db
from app.models.wallet import (
Wallet,
Expand All @@ -17,8 +19,6 @@
WalletStatusResponse,
WalletTrustlineResponse,
)
from app.core.exceptions import ApexConflictError
from app.core.security import require_engineer
from app.services.wallet_registry import WalletRegistry

router = APIRouter()
Expand Down
Loading
Loading