Compare commits
24 Commits
64cc438bcc
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 986682aad1 | |||
| f8824291a2 | |||
| 443a04befb | |||
| 88c2af472e | |||
| 8ba9790625 | |||
| af5b6e1cff | |||
| dcd4bebc92 | |||
| f54dc0d342 | |||
| acc9092baa | |||
| 6d3617938e | |||
| 709a810775 | |||
| cf33c69f95 | |||
| 392ce162dc | |||
| 5e8b5ee33c | |||
| ebf47c6142 | |||
| 0e2e9d0bb0 | |||
| 9472fe91fa | |||
| 675870b469 | |||
| 92f4bdcdce | |||
| 3ec51524d6 | |||
| 7ded48bdb7 | |||
| 6ca37f743f | |||
| cea518b33c | |||
| 22293804ab |
@@ -0,0 +1,71 @@
|
||||
name: Snyk Security Scan
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 6 * * 1' # Weekly on Monday 06:00 UTC
|
||||
|
||||
jobs:
|
||||
snyk-backend:
|
||||
name: Python vulnerabilities (backend)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install backend dependencies
|
||||
run: pip install -r backend/requirements-lock.txt
|
||||
|
||||
- name: Snyk — scan Python packages
|
||||
uses: snyk/actions/python@master
|
||||
env:
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
with:
|
||||
args: --file=backend/requirements-lock.txt --severity-threshold=high
|
||||
continue-on-error: true # report without blocking CI during initial cleanup
|
||||
|
||||
snyk-frontend:
|
||||
name: npm vulnerabilities (frontend)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Snyk — scan npm packages
|
||||
uses: snyk/actions/node@master
|
||||
env:
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
with:
|
||||
args: --file=frontend/package.json --severity-threshold=high
|
||||
continue-on-error: true
|
||||
|
||||
snyk-docker-backend:
|
||||
name: Docker image vulnerabilities (backend)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build backend image for scanning
|
||||
run: docker build -t aegis-backend:scan backend/
|
||||
|
||||
- name: Snyk — scan Docker image
|
||||
uses: snyk/actions/docker@master
|
||||
env:
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
with:
|
||||
image: aegis-backend:scan
|
||||
args: --severity-threshold=high
|
||||
continue-on-error: true
|
||||
@@ -0,0 +1,2 @@
|
||||
skips:
|
||||
- B311
|
||||
+1
-1
@@ -3,7 +3,7 @@ FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
curl \
|
||||
|
||||
@@ -109,7 +109,7 @@ class Settings(BaseSettings):
|
||||
# ── Reporting ─────────────────────────────────────────────────────
|
||||
REPORT_TEMPLATES_DIR: str = "app/templates/reports"
|
||||
# Assign REPORT_OUTPUT_DIR = "/tmp/aegis_reports"
|
||||
REPORT_OUTPUT_DIR: str = "/tmp/aegis_reports"
|
||||
REPORT_OUTPUT_DIR: str = "/app/reports"
|
||||
# Assign COMPANY_NAME = "Organization"
|
||||
COMPANY_NAME: str = "Organization"
|
||||
# Assign COMPANY_LOGO_PATH = "app/templates/reports/assets/logo.png"
|
||||
|
||||
@@ -224,18 +224,14 @@ class TechniqueEntity:
|
||||
Rules (v3):
|
||||
1. No tests -> not_evaluated
|
||||
2. All tests validated -> inspect detection results:
|
||||
a. All detected AND ≥ 2 validated tests -> validated
|
||||
b. All detected but only 1 validated test -> partial
|
||||
(single test is not enough evidence for full coverage)
|
||||
c. Any partially_detected -> partial
|
||||
a. All detected AND ≥ 1 validated test -> validated
|
||||
b. Any partially_detected -> partial
|
||||
d. Otherwise (no detected results) -> not_covered
|
||||
3. Some validated, others in intermediate states -> partial
|
||||
4. All tests in intermediate states (draft/executing/evaluating/review/rejected)
|
||||
-> in_progress
|
||||
|
||||
Minimum validated count for "validated": 2 tests.
|
||||
With only 1 validated+detected test the technique is "partial" to
|
||||
signal that more testing is recommended.
|
||||
Minimum validated count for "validated": 1 test.
|
||||
|
||||
Args:
|
||||
test_snapshots (list[tuple[str, str | None]]): Each element is a
|
||||
@@ -247,7 +243,7 @@ class TechniqueEntity:
|
||||
TechniqueStatus: The newly computed status, which is also stored on
|
||||
the entity's ``status_global`` field.
|
||||
"""
|
||||
_MIN_VALIDATED_FOR_FULL = 2 # require ≥ N validated tests for "validated"
|
||||
min_validated_for_full = 1 # require ≥ N validated tests for "validated"
|
||||
|
||||
tests = [
|
||||
_TestSnapshot(
|
||||
@@ -269,8 +265,8 @@ class TechniqueEntity:
|
||||
results = [t.detection_result for t in tests if t.detection_result]
|
||||
# Check: results and all(r == TestResult.detected or r == "detected" for r i...
|
||||
if results and all(r == TestResult.detected or r == "detected" for r in results):
|
||||
# Need at least _MIN_VALIDATED_FOR_FULL tests for "validated"
|
||||
if validated_count >= _MIN_VALIDATED_FOR_FULL:
|
||||
# Need at least min_validated_for_full tests for "validated"
|
||||
if validated_count >= min_validated_for_full:
|
||||
self.status_global = TechniqueStatus.validated
|
||||
else:
|
||||
self.status_global = TechniqueStatus.partial
|
||||
|
||||
@@ -642,12 +642,7 @@ class TestEntity:
|
||||
# Call self._events.append()
|
||||
self._events.append(DomainEvent("dual_validation_approved"))
|
||||
|
||||
elif r == "rejected" and b == "rejected":
|
||||
# Full consensus to reject
|
||||
elif r == "rejected" or b == "rejected":
|
||||
# Any rejection is a veto — one lead can reject without waiting for the other
|
||||
self.state = TestState.rejected
|
||||
self._events.append(DomainEvent("dual_validation_rejected"))
|
||||
|
||||
elif (r == "approved" and b == "rejected") or (r == "rejected" and b == "approved"):
|
||||
# Conflict: one approves, one rejects → needs discussion
|
||||
self.state = TestState.disputed
|
||||
self._events.append(DomainEvent("dual_validation_disputed"))
|
||||
|
||||
+7
-92
@@ -93,97 +93,15 @@ from app.middleware.error_handler import domain_exception_handler
|
||||
|
||||
# Import RequestContextMiddleware from app.middleware.request_context
|
||||
from app.middleware.request_context import RequestContextMiddleware
|
||||
|
||||
# Import advanced_metrics as advanced_metrics_router from app.routers
|
||||
from app.routers import advanced_metrics as advanced_metrics_router
|
||||
|
||||
# Import analytics as analytics_router from app.routers
|
||||
from app.routers import analytics as analytics_router
|
||||
|
||||
# Import audit as audit_router from app.routers
|
||||
from app.routers import audit as audit_router
|
||||
|
||||
# Import auth as auth_router from app.routers
|
||||
from app.routers import auth as auth_router
|
||||
|
||||
# Import campaigns as campaigns_router from app.routers
|
||||
from app.routers import campaigns as campaigns_router
|
||||
|
||||
# Import compliance as compliance_router from app.routers
|
||||
from app.routers import compliance as compliance_router
|
||||
|
||||
# Import d3fend as d3fend_router from app.routers
|
||||
from app.routers import d3fend as d3fend_router
|
||||
|
||||
# Import data_sources as data_sources_router from app.routers
|
||||
from app.routers import data_sources as data_sources_router
|
||||
|
||||
# Import detection_rules as detection_rules_router from app.routers
|
||||
from app.routers import detection_rules as detection_rules_router
|
||||
|
||||
# Import evidence as evidence_router from app.routers
|
||||
from app.routers import evidence as evidence_router
|
||||
|
||||
# Import heatmap as heatmap_router from app.routers
|
||||
from app.routers import heatmap as heatmap_router
|
||||
|
||||
# Import jira as jira_router from app.routers
|
||||
from app.routers import jira as jira_router
|
||||
|
||||
# Import metrics as metrics_router from app.routers
|
||||
from app.routers import metrics as metrics_router
|
||||
|
||||
# Import notifications as notifications_router from app.routers
|
||||
from app.routers import notifications as notifications_router
|
||||
|
||||
# Import operational_metrics as operational_metrics_router from app.routers
|
||||
from app.routers import operational_metrics as operational_metrics_router
|
||||
|
||||
# Import osint as osint_router from app.routers
|
||||
from app.routers import osint as osint_router
|
||||
|
||||
# Import professional_reports as professional_reports_ro... from app.routers
|
||||
from app.routers import professional_reports as professional_reports_router
|
||||
|
||||
# Import reports as reports_router from app.routers
|
||||
from app.routers import reports as reports_router
|
||||
|
||||
# Import scores as scores_router from app.routers
|
||||
from app.routers import scores as scores_router
|
||||
|
||||
# Import snapshots as snapshots_router from app.routers
|
||||
from app.routers import snapshots as snapshots_router
|
||||
|
||||
# Import system as system_router from app.routers
|
||||
from app.routers import system as system_router
|
||||
|
||||
# Import techniques as techniques_router from app.routers
|
||||
from app.routers import techniques as techniques_router
|
||||
|
||||
# Import test_templates as test_templates_router from app.routers
|
||||
from app.routers import test_templates as test_templates_router
|
||||
|
||||
# Import tests as tests_router from app.routers
|
||||
from app.routers import tests as tests_router
|
||||
|
||||
# Import threat_actors as threat_actors_router from app.routers
|
||||
from app.routers import threat_actors as threat_actors_router
|
||||
|
||||
# Import users as users_router from app.routers
|
||||
from app.routers import users as users_router
|
||||
|
||||
# Import worklogs as worklogs_router from app.routers
|
||||
from app.routers import worklogs as worklogs_router
|
||||
|
||||
# Import ensure_bucket_exists from app.storage
|
||||
from app.storage import ensure_bucket_exists
|
||||
|
||||
# Import settings as _settings from app.config
|
||||
from app.config import settings as _settings
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
# Configure structured logging before any module initialises its own logger
|
||||
setup_logging()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Environment detection ─────────────────────────────────────────────────
|
||||
_IS_PRODUCTION = os.environ.get("AEGIS_ENV", "").lower() == "production"
|
||||
|
||||
@@ -209,8 +127,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
seed_decay_policies(db)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("seed_decay_policies failed at startup: %s", e)
|
||||
finally:
|
||||
db.close()
|
||||
# Seed operational alert system rules
|
||||
@@ -218,8 +136,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
try:
|
||||
from app.services.operational_alert_service import seed_system_rules
|
||||
seed_system_rules(db2)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("seed_system_rules failed at startup: %s", e)
|
||||
finally:
|
||||
db2.close()
|
||||
yield
|
||||
@@ -253,9 +171,6 @@ app.add_middleware(RequestContextMiddleware)
|
||||
# ── No-cache middleware for all /api/ responses ───────────────────────────
|
||||
# Prevents Cloudflare and browser caches from storing API responses,
|
||||
# which would cause stale/empty data to be served after backend restarts.
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import Response as StarletteResponse
|
||||
|
||||
class NoCacheAPIMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
|
||||
@@ -39,74 +39,13 @@ from app.models.executive_dashboard import PostureSnapshot
|
||||
from app.models.api_key import ApiKey
|
||||
from app.models.sso_config import SsoConfig
|
||||
from app.models.operational_alert import AlertRule, AlertInstance
|
||||
|
||||
# Import Campaign, CampaignTest from app.models.campaign
|
||||
from app.models.campaign import Campaign, CampaignTest
|
||||
|
||||
# Import from app.models.compliance
|
||||
from app.models.compliance import (
|
||||
ComplianceControl,
|
||||
ComplianceControlMapping,
|
||||
ComplianceFramework,
|
||||
)
|
||||
|
||||
# Import CoverageSnapshot, SnapshotTechniqueState from app.models.coverage_snapshot
|
||||
from app.models.coverage_snapshot import CoverageSnapshot, SnapshotTechniqueState
|
||||
|
||||
# Import DataSource from app.models.data_source
|
||||
from app.models.data_source import DataSource
|
||||
|
||||
# Import DefensiveTechnique, DefensiveTechniqueMapping from app.models.defensive_technique
|
||||
from app.models.defensive_technique import DefensiveTechnique, DefensiveTechniqueMapping
|
||||
|
||||
# Import DetectionRule from app.models.detection_rule
|
||||
from app.models.detection_rule import DetectionRule
|
||||
|
||||
# Import TeamSide, TechniqueStatus, TestResult, TestState from app.models.enums
|
||||
from app.models.enums import TeamSide, TechniqueStatus, TestResult, TestState
|
||||
|
||||
# Import Evidence from app.models.evidence
|
||||
from app.models.evidence import Evidence
|
||||
|
||||
# Import IntelItem from app.models.intel
|
||||
from app.models.intel import IntelItem
|
||||
|
||||
# Import JiraLink, JiraLinkEntityType, JiraSyncDirection from app.models.jira_link
|
||||
from app.models.jira_link import JiraLink, JiraLinkEntityType, JiraSyncDirection
|
||||
|
||||
# Import Notification from app.models.notification
|
||||
from app.models.notification import Notification
|
||||
|
||||
# Import OsintItem from app.models.osint_item
|
||||
from app.models.osint_item import OsintItem
|
||||
|
||||
# Import ScoringConfig from app.models.scoring_config
|
||||
from app.models.scoring_config import ScoringConfig
|
||||
|
||||
# Import Technique from app.models.technique
|
||||
from app.models.technique import Technique
|
||||
|
||||
# Import Test from app.models.test
|
||||
from app.models.test import Test
|
||||
|
||||
# Import TestDetectionResult from app.models.test_detection_result
|
||||
from app.models.test_detection_result import TestDetectionResult
|
||||
|
||||
# Import TestTemplate from app.models.test_template
|
||||
from app.models.test_template import TestTemplate
|
||||
|
||||
# Import TestTemplateDetectionRule from app.models.test_template_detection_rule
|
||||
from app.models.test_template_detection_rule import TestTemplateDetectionRule
|
||||
|
||||
# Import ThreatActor, ThreatActorTechnique from app.models.threat_actor
|
||||
from app.models.threat_actor import ThreatActor, ThreatActorTechnique
|
||||
|
||||
# Import User from app.models.user
|
||||
from app.models.user import User
|
||||
|
||||
# Import Worklog from app.models.worklog
|
||||
from app.models.worklog import Worklog
|
||||
|
||||
# Assign __all__ = [
|
||||
__all__ = [
|
||||
# Literal argument value
|
||||
@@ -133,7 +72,8 @@ __all__ = [
|
||||
"TechniqueStatus", "TestState", "TestResult", "TeamSide",
|
||||
"WebhookConfig", "SystemConfig",
|
||||
"DetectionAsset", "DetectionTechniqueMapping", "DetectionValidation",
|
||||
"TechniqueConfidenceScore", "InfrastructureChangeLog", "DecayPolicy",
|
||||
"TechniqueConfidenceScore", "InfrastructureChangeLog",
|
||||
"DetectionConfidence", "DetectionHealthStatus", "InvalidationReason", "DecayPolicy",
|
||||
"TechniqueOwnership", "RevalidationQueueItem",
|
||||
"QueuePriority", "QueueStatus", "QueueReason",
|
||||
"AttackPath", "AttackPathStep", "AttackPathExecution",
|
||||
|
||||
@@ -4,7 +4,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean, Column, Date, DateTime, Float, ForeignKey,
|
||||
Column, Date, DateTime, Float, ForeignKey,
|
||||
Index, Integer, UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
@@ -4,7 +4,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""WebhookConfig model — outbound HTTP notification endpoints."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, Integer, Text, ForeignKey, func
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from app.database import Base
|
||||
|
||||
@@ -13,7 +13,6 @@ What is exported (and what is NOT):
|
||||
✗ atomic/sigma/elastic templates, techniques, tests, campaigns, reports
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -23,7 +22,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import hash_password
|
||||
from app.database import get_db
|
||||
from app.dependencies.auth import get_current_user, require_role
|
||||
from app.dependencies.auth import require_role
|
||||
from app.models.scoring_config import ScoringConfig
|
||||
from app.models.sso_config import SsoConfig
|
||||
from app.models.system_config import SystemConfig
|
||||
@@ -150,7 +149,7 @@ def export_config(
|
||||
"email": u.email if hasattr(u, "email") else None,
|
||||
"role": u.role,
|
||||
"is_active": u.is_active,
|
||||
"must_change_password": True, # force password reset on new instance
|
||||
"must_change_password": True, # force password reset on new instance # nosec B105
|
||||
}
|
||||
for u in db.query(User).order_by(User.username).all()
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Phase 14: API Key management router."""
|
||||
|
||||
from typing import List, Optional
|
||||
from typing import List
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
@@ -14,7 +14,6 @@ from app.schemas.attack_path_schema import (
|
||||
ExecutionCreate, ExecutionOut,
|
||||
StepExecuteRequest, StepResultOut,
|
||||
TimelineEntryCreate, TimelineEntryOut,
|
||||
KillChainMetrics,
|
||||
)
|
||||
from app.services import attack_path_service as svc
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
# Import jwt (PyJWT)
|
||||
import jwt
|
||||
from jwt.exceptions import PyJWTError as JWTError
|
||||
|
||||
# Import Session from sqlalchemy.orm
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -86,8 +86,13 @@ from app.services.campaign_crud_service import (
|
||||
update_campaign as crud_update,
|
||||
)
|
||||
|
||||
# Import generate_campaign_from_threat_actor from app.services.campaign_service
|
||||
from app.services.campaign_service import generate_campaign_from_threat_actor
|
||||
# Import activate_campaign from app.services.campaign_crud_service
|
||||
from app.services.campaign_crud_service import (
|
||||
activate_campaign as crud_activate,
|
||||
)
|
||||
|
||||
# Import log_action from app.services.audit_service
|
||||
from app.services.audit_service import log_action
|
||||
|
||||
# Import notify_role from app.services.notification_service
|
||||
from app.services.notification_service import notify_role
|
||||
@@ -190,7 +195,7 @@ def list_campaigns(
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list:
|
||||
) -> dict:
|
||||
"""List campaigns with optional filters and pagination.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -29,6 +29,7 @@ from app.services.compliance_import_service import (
|
||||
import_dora_mappings,
|
||||
import_iso_27001_mappings,
|
||||
import_iso_42001_mappings,
|
||||
import_nist_800_53_mappings,
|
||||
)
|
||||
|
||||
# Import from app.services.compliance_service
|
||||
|
||||
@@ -64,7 +64,7 @@ def list_defensive_techniques(
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list:
|
||||
) -> dict:
|
||||
"""List all D3FEND defensive techniques with optional filters."""
|
||||
# Return list_defensive_techniques_svc(
|
||||
return list_defensive_techniques_svc(
|
||||
@@ -102,7 +102,7 @@ def get_defenses_for_attack_technique_endpoint(
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list:
|
||||
) -> dict:
|
||||
"""Get all D3FEND defensive techniques mapped to a given ATT&CK technique."""
|
||||
# Return get_defenses_for_attack_technique(db, mitre_id)
|
||||
return get_defenses_for_attack_technique(db, mitre_id)
|
||||
|
||||
@@ -80,7 +80,7 @@ def list_detection_rules(
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list:
|
||||
) -> dict:
|
||||
"""List detection rules with optional filters and pagination."""
|
||||
# Return list_rules(
|
||||
return list_rules(
|
||||
@@ -112,7 +112,7 @@ def get_detection_rules_for_template(
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list:
|
||||
) -> dict:
|
||||
"""Get detection rules associated with a test template."""
|
||||
# Return get_rules_for_template(db, template_id)
|
||||
return get_rules_for_template(db, template_id)
|
||||
@@ -151,7 +151,7 @@ def get_detection_rules_for_test(
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list:
|
||||
) -> dict:
|
||||
"""Get detection rules relevant to a test, along with their evaluation results.
|
||||
|
||||
Finds rules by matching the test's technique_id to detection rules,
|
||||
|
||||
@@ -72,7 +72,6 @@ from app.services.evidence_service import (
|
||||
validate_file,
|
||||
validate_upload_permission,
|
||||
)
|
||||
from app.limiter import limiter
|
||||
from app.storage import download_file, upload_file
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Phase 13: Executive Dashboard router."""
|
||||
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
|
||||
@@ -97,7 +97,7 @@ def list_osint_items(
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: user
|
||||
user: User = Depends(get_current_user),
|
||||
) -> list:
|
||||
) -> dict:
|
||||
"""List OSINT items with optional filters.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -14,7 +14,6 @@ from app.schemas.ownership_queue_schema import (
|
||||
DetectionAssetOwnershipPatch,
|
||||
BulkAssignRequest, BulkAssignResult,
|
||||
QueueItemCreate, QueueItemPatch, QueueItemOut,
|
||||
AnalystDashboard,
|
||||
)
|
||||
from app.services import ownership_service, revalidation_queue_service
|
||||
from app.models.ownership_queue import RevalidationQueueItem
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
# Import UUID from uuid
|
||||
from uuid import UUID
|
||||
from pathlib import Path
|
||||
|
||||
# Import APIRouter, Depends, Query, Request from fastapi
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
# Import APIRouter, Depends, HTTPException, Query, Request from fastapi
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
# Import FileResponse from fastapi.responses
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -21,12 +22,24 @@ from app.dependencies.auth import get_current_user, require_any_role
|
||||
# Import limiter from app.limiter
|
||||
from app.limiter import limiter
|
||||
|
||||
# Import settings from app.config
|
||||
from app.config import settings
|
||||
|
||||
# Import User from app.models.user
|
||||
from app.models.user import User
|
||||
|
||||
# Import report_generation_service from app.services
|
||||
from app.services import report_generation_service
|
||||
|
||||
|
||||
def _assert_safe_report_path(filepath: str) -> str:
|
||||
"""Raise 500 if the generated filepath escapes the configured report directory."""
|
||||
output_dir = Path(settings.REPORT_OUTPUT_DIR).resolve()
|
||||
resolved = Path(filepath).resolve()
|
||||
if not resolved.is_relative_to(output_dir):
|
||||
raise HTTPException(status_code=500, detail="Report generation path error")
|
||||
return filepath
|
||||
|
||||
# Assign router = APIRouter(prefix="/reports/generate", tags=["professional-reports"])
|
||||
router = APIRouter(prefix="/reports/generate", tags=["professional-reports"])
|
||||
|
||||
@@ -65,7 +78,7 @@ def generate_purple_report(
|
||||
)
|
||||
# Return FileResponse(
|
||||
return FileResponse(
|
||||
filepath,
|
||||
_assert_safe_report_path(filepath),
|
||||
# Keyword argument: media_type
|
||||
media_type=_MEDIA_TYPES[format],
|
||||
# Keyword argument: filename
|
||||
@@ -95,7 +108,7 @@ def generate_coverage_report(
|
||||
)
|
||||
# Return FileResponse(
|
||||
return FileResponse(
|
||||
filepath,
|
||||
_assert_safe_report_path(filepath),
|
||||
# Keyword argument: media_type
|
||||
media_type=_MEDIA_TYPES[format],
|
||||
# Keyword argument: filename
|
||||
@@ -125,7 +138,7 @@ def generate_executive_report(
|
||||
)
|
||||
# Return FileResponse(
|
||||
return FileResponse(
|
||||
filepath,
|
||||
_assert_safe_report_path(filepath),
|
||||
# Keyword argument: media_type
|
||||
media_type=_MEDIA_TYPES[format],
|
||||
# Keyword argument: filename
|
||||
@@ -155,7 +168,7 @@ def generate_quarterly_report(
|
||||
)
|
||||
# Return FileResponse(
|
||||
return FileResponse(
|
||||
filepath,
|
||||
_assert_safe_report_path(filepath),
|
||||
# Keyword argument: media_type
|
||||
media_type=_MEDIA_TYPES[format],
|
||||
# Keyword argument: filename
|
||||
@@ -187,7 +200,7 @@ def generate_technique_report(
|
||||
)
|
||||
# Return FileResponse(
|
||||
return FileResponse(
|
||||
filepath,
|
||||
_assert_safe_report_path(filepath),
|
||||
# Keyword argument: media_type
|
||||
media_type=_MEDIA_TYPES[format],
|
||||
# Keyword argument: filename
|
||||
|
||||
@@ -10,7 +10,6 @@ from app.database import get_db
|
||||
from app.dependencies.auth import get_current_user, require_any_role
|
||||
from app.schemas.risk_schema import (
|
||||
TechniqueRiskProfileOut,
|
||||
RiskSummary,
|
||||
ComputeResult,
|
||||
)
|
||||
from app.services import risk_intelligence_service as svc
|
||||
|
||||
@@ -87,7 +87,7 @@ def list_snapshots(
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list:
|
||||
) -> dict:
|
||||
"""List coverage snapshots ordered by creation date (newest first)."""
|
||||
# Return list_snapshots_svc(db, offset=offset, limit=limit)
|
||||
return list_snapshots_svc(db, offset=offset, limit=limit)
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
"""Phase 14: SSO / SAML 2.0 router."""
|
||||
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies.auth import get_current_user, require_any_role
|
||||
from app.dependencies.auth import require_any_role
|
||||
from app import auth as auth_lib
|
||||
from app.schemas.sso_schema import (
|
||||
SsoConfigCreate, SsoConfigOut, SsoLoginInitResponse, SsoStatusResponse,
|
||||
SsoConfigCreate, SsoConfigOut, SsoStatusResponse,
|
||||
)
|
||||
import app.services.sso_service as svc
|
||||
|
||||
@@ -74,7 +75,10 @@ def sso_login(request: Request, db: Session = Depends(get_db)):
|
||||
result = svc.initiate_login(db, request_data)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc))
|
||||
return RedirectResponse(url=result["redirect_url"])
|
||||
redirect_url = result["redirect_url"]
|
||||
if urlparse(redirect_url).scheme not in ("http", "https"):
|
||||
raise HTTPException(status_code=400, detail="Invalid IdP redirect URL")
|
||||
return RedirectResponse(url=redirect_url)
|
||||
|
||||
|
||||
@router.post("/callback")
|
||||
|
||||
@@ -27,18 +27,6 @@ from app.jobs.mitre_sync_job import scheduler
|
||||
# Import limiter from app.limiter
|
||||
from app.limiter import limiter
|
||||
|
||||
# Import User from app.models.user
|
||||
from app.models.user import User
|
||||
|
||||
# Import import_atomic_red_team from app.services.atomic_import_service
|
||||
from app.services.atomic_import_service import import_atomic_red_team
|
||||
|
||||
# Import scan_intel from app.services.intel_service
|
||||
from app.services.intel_service import scan_intel
|
||||
|
||||
# Import sync_mitre from app.services.mitre_sync_service
|
||||
from app.services.mitre_sync_service import sync_mitre
|
||||
|
||||
# Assign logger = logging.getLogger(__name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -84,7 +72,7 @@ _SMTP_KEYS = {
|
||||
"host": "smtp.host",
|
||||
"port": "smtp.port",
|
||||
"username": "smtp.username",
|
||||
"password": "smtp.password",
|
||||
"password": "smtp.password", # nosec B105
|
||||
"from_email": "smtp.from_email",
|
||||
"use_tls": "smtp.use_tls",
|
||||
}
|
||||
@@ -367,7 +355,7 @@ def test_jira_connection(
|
||||
# 10-second timeout so we never block Cloudflare into a 524
|
||||
try:
|
||||
jira._session.timeout = 10 # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
except Exception: # nosec B110
|
||||
pass
|
||||
myself = jira.myself()
|
||||
logger.info("Jira myself() response keys: %s", list(myself.keys()) if isinstance(myself, dict) else type(myself))
|
||||
@@ -429,7 +417,6 @@ def test_tempo_connection(
|
||||
Always returns HTTP 200 with a ``status`` field so Cloudflare never
|
||||
intercepts the response.
|
||||
"""
|
||||
from app.services.tempo_service import has_tempo_configured
|
||||
|
||||
tempo_token = getattr(current_user, "tempo_api_token", None)
|
||||
if not tempo_token:
|
||||
@@ -471,17 +458,17 @@ def test_tempo_connection(
|
||||
err = str(exc)
|
||||
if "401" in err or "Unauthorized" in err:
|
||||
msg = (
|
||||
f"Authentication failed (401). "
|
||||
f"Check your Tempo API token — obtain it at "
|
||||
f"Jira → Apps → Tempo → Settings → API Integration."
|
||||
"Authentication failed (401). "
|
||||
"Check your Tempo API token — obtain it at "
|
||||
"Jira → Apps → Tempo → Settings → API Integration."
|
||||
)
|
||||
elif "403" in err or "Forbidden" in err:
|
||||
msg = "Access denied (403). The Tempo token lacks the required permissions."
|
||||
elif "404" in err or "not found" in err.lower():
|
||||
msg = (
|
||||
f"Account ID not found (404). "
|
||||
"Account ID not found (404). "
|
||||
f"The value '{jira_account_id}' may be wrong — see the instructions "
|
||||
f"below to find your correct Atlassian Account ID."
|
||||
"below to find your correct Atlassian Account ID."
|
||||
)
|
||||
else:
|
||||
msg = f"Tempo connection failed: {err}"
|
||||
|
||||
@@ -131,53 +131,10 @@ from app.services.test_workflow_service import (
|
||||
reopen_test as wf_reopen,
|
||||
handle_remediation_completed as wf_handle_remediation,
|
||||
get_retest_chain as wf_get_retest_chain,
|
||||
)
|
||||
|
||||
# Import from app.services.test_workflow_service
|
||||
from app.services.test_workflow_service import (
|
||||
handle_remediation_completed as wf_handle_remediation,
|
||||
)
|
||||
|
||||
# Import from app.services.test_workflow_service
|
||||
from app.services.test_workflow_service import (
|
||||
pause_timer as wf_pause_timer,
|
||||
)
|
||||
|
||||
# Import from app.services.test_workflow_service
|
||||
from app.services.test_workflow_service import (
|
||||
reopen_test as wf_reopen,
|
||||
)
|
||||
|
||||
# Import from app.services.test_workflow_service
|
||||
from app.services.test_workflow_service import (
|
||||
resume_timer as wf_resume_timer,
|
||||
)
|
||||
|
||||
# Import from app.services.test_workflow_service
|
||||
from app.services.test_workflow_service import (
|
||||
start_execution as wf_start_execution,
|
||||
)
|
||||
|
||||
# Import from app.services.test_workflow_service
|
||||
from app.services.test_workflow_service import (
|
||||
submit_blue_evidence as wf_submit_blue,
|
||||
)
|
||||
|
||||
# Import from app.services.test_workflow_service
|
||||
from app.services.test_workflow_service import (
|
||||
submit_red_evidence as wf_submit_red,
|
||||
)
|
||||
|
||||
# Import from app.services.test_workflow_service
|
||||
from app.services.test_workflow_service import (
|
||||
validate_as_blue_lead as wf_validate_blue,
|
||||
)
|
||||
|
||||
# Import from app.services.test_workflow_service
|
||||
from app.services.test_workflow_service import (
|
||||
validate_as_red_lead as wf_validate_red,
|
||||
)
|
||||
|
||||
# Assign router = APIRouter(prefix="/tests", tags=["tests"])
|
||||
router = APIRouter(prefix="/tests", tags=["tests"])
|
||||
|
||||
@@ -324,7 +281,7 @@ def create_test(
|
||||
from app.services.jira_service import auto_create_test_issue
|
||||
auto_create_test_issue(db, test, current_user)
|
||||
db.commit()
|
||||
except Exception:
|
||||
except Exception: # nosec B110
|
||||
pass # jira_service already logs warnings internally
|
||||
|
||||
return test
|
||||
@@ -417,8 +374,8 @@ def create_test_from_template(
|
||||
from app.services.jira_service import auto_create_test_issue
|
||||
auto_create_test_issue(db, test, current_user)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception: # nosec B110
|
||||
pass # jira_service already logs warnings internally
|
||||
|
||||
return test
|
||||
|
||||
@@ -1316,7 +1273,6 @@ def request_discussion(
|
||||
Sends a notification to the other lead (who rejected) asking them to
|
||||
discuss and resolve the conflict. The test remains in 'disputed' state.
|
||||
"""
|
||||
from app.models.enums import TestState as ModelTestState
|
||||
from app.models.user import User as UserModel
|
||||
from app.services.notification_service import create_notification
|
||||
|
||||
@@ -1529,7 +1485,7 @@ def import_rt(
|
||||
continue
|
||||
try:
|
||||
img_bytes = base64.b64decode(ev.data)
|
||||
except Exception:
|
||||
except Exception: # nosec B112
|
||||
continue # malformed base64 — skip
|
||||
if len(img_bytes) > _MAX_EVIDENCE_BYTES:
|
||||
continue # over size limit — skip
|
||||
@@ -1537,7 +1493,7 @@ def import_rt(
|
||||
key = f"{test.id}/{uuid.uuid4()}_{safe_name}"
|
||||
try:
|
||||
upload_file(img_bytes, key)
|
||||
except Exception:
|
||||
except Exception: # nosec B112
|
||||
continue # storage error — skip but don't abort
|
||||
evidence_obj = Evidence(
|
||||
test_id=test.id,
|
||||
|
||||
@@ -62,7 +62,7 @@ def list_threat_actors(
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list:
|
||||
) -> dict:
|
||||
"""List threat actors with optional filters and pagination.
|
||||
|
||||
**Requires** authentication (any role).
|
||||
@@ -138,7 +138,7 @@ def get_threat_actor_gaps(
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list:
|
||||
) -> dict:
|
||||
"""Identify techniques of this actor that are NOT fully validated.
|
||||
|
||||
**Requires** authentication (any role).
|
||||
|
||||
@@ -36,7 +36,7 @@ def _mask_secret(wh) -> WebhookConfigOut:
|
||||
"""Return a WebhookConfigOut with the secret masked."""
|
||||
out = WebhookConfigOut.model_validate(wh)
|
||||
if wh.secret:
|
||||
out.secret = "***"
|
||||
out.secret = "***" # nosec B105
|
||||
else:
|
||||
out.secret = None
|
||||
return out
|
||||
|
||||
@@ -13,9 +13,6 @@ from app.domain.enums import DataClassification
|
||||
from app.models.enums import TestResult, TestState
|
||||
from app.schemas.evidence import EvidenceOut
|
||||
|
||||
# Import TestResult, TestState from app.models.enums
|
||||
from app.models.enums import TestResult, TestState
|
||||
|
||||
# ── Create ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -269,7 +266,7 @@ class TestOut(BaseModel):
|
||||
if hasattr(obj, "technique") and obj.technique is not None:
|
||||
obj.__dict__["technique_mitre_id"] = obj.technique.mitre_id
|
||||
obj.__dict__["technique_name"] = obj.technique.name
|
||||
except Exception:
|
||||
except Exception: # nosec B110
|
||||
pass # DetachedInstanceError or similar — leave technique fields None
|
||||
|
||||
# Only split evidences when they are already in memory (loaded via joinedload)
|
||||
|
||||
@@ -9,7 +9,7 @@ import uuid
|
||||
# Import datetime from datetime
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
|
||||
# ── Username policy ─────────────────────────────────────────────────
|
||||
|
||||
@@ -3,7 +3,6 @@ import ipaddress
|
||||
import socket
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
@@ -135,7 +135,7 @@ def _safe_extract_zip(zip_bytes: bytes, dest: str) -> None:
|
||||
f"exceeds limit of {_MAX_UNCOMPRESSED_SIZE / (1024 * 1024):.0f} MB"
|
||||
)
|
||||
|
||||
# Iterate over entries
|
||||
# Iterate over entries — validate and extract each member individually
|
||||
for member in entries:
|
||||
# Assign target = (dest_path / member.filename).resolve()
|
||||
target = (dest_path / member.filename).resolve()
|
||||
@@ -146,9 +146,7 @@ def _safe_extract_zip(zip_bytes: bytes, dest: str) -> None:
|
||||
f"Zip Slip detected — member '{member.filename}' "
|
||||
f"resolves outside target directory"
|
||||
)
|
||||
|
||||
# Call zf.extractall()
|
||||
zf.extractall(dest)
|
||||
zf.extract(member, dest)
|
||||
|
||||
|
||||
# Define function _extract_zip
|
||||
|
||||
@@ -530,7 +530,7 @@ def _build_red_summary(agg: dict, adversary_display: str, eval_round: int) -> st
|
||||
|
||||
lines = [
|
||||
f"MITRE ATT&CK Evaluation — Round {eval_round} ({adversary_display})",
|
||||
f"Vendor: CrowdStrike Falcon",
|
||||
"Vendor: CrowdStrike Falcon",
|
||||
f"Best detection level: {agg['detection_type']}",
|
||||
f"Tactic: {agg['tactic_name']} ({agg['tactic_id']})",
|
||||
f"Unique substeps: {len(occurrences)}",
|
||||
|
||||
@@ -110,6 +110,10 @@ def log_action(
|
||||
db.add(entry)
|
||||
# Flush changes to DB without committing the transaction
|
||||
db.flush()
|
||||
# Reload from DB so the timestamp is in DB-stable format before hashing.
|
||||
# Without this, a round-trip through the DB (e.g. refresh after commit) can
|
||||
# return a timestamp with different precision/timezone, causing hash mismatch.
|
||||
db.refresh(entry)
|
||||
# Assign entry.integrity_hash = compute_integrity_hash(entry)
|
||||
entry.integrity_hash = compute_integrity_hash(entry)
|
||||
# Return entry
|
||||
|
||||
@@ -54,6 +54,8 @@ from sqlalchemy.orm import Session
|
||||
# Import DataSource from app.models.data_source
|
||||
from app.models.data_source import DataSource
|
||||
from app.models.technique import Technique
|
||||
# Import TestTemplate from app.models.test_template
|
||||
from app.models.test_template import TestTemplate
|
||||
from app.services.audit_service import log_action
|
||||
|
||||
# Assign logger = logging.getLogger(__name__)
|
||||
@@ -100,10 +102,15 @@ def _download_zip(url: str = CALDERA_ZIP_URL) -> bytes:
|
||||
# Define function _extract_zip
|
||||
def _extract_zip(zip_bytes: bytes, dest: str) -> Path:
|
||||
"""Extract *zip_bytes* into *dest* and return abilities dir."""
|
||||
# Open context manager
|
||||
dest_path = Path(dest).resolve()
|
||||
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
||||
# Call zf.extractall()
|
||||
zf.extractall(dest)
|
||||
for member in zf.infolist():
|
||||
target = (dest_path / member.filename).resolve()
|
||||
if not target.is_relative_to(dest_path):
|
||||
raise ValueError(
|
||||
f"Zip Slip detected — '{member.filename}' resolves outside target directory"
|
||||
)
|
||||
zf.extract(member, dest)
|
||||
# Assign abilities_dir = Path(dest) / _ZIP_ROOT_PREFIX / "data" / "abilities"
|
||||
abilities_dir = Path(dest) / _ZIP_ROOT_PREFIX / "data" / "abilities"
|
||||
# Check: not abilities_dir.is_dir()
|
||||
|
||||
@@ -10,9 +10,6 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
# Import uuid
|
||||
import uuid
|
||||
|
||||
# Import Session from sqlalchemy.orm
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
@@ -895,7 +895,7 @@ def import_cis_controls_v8_mappings(db: Session) -> dict:
|
||||
logger.info("CIS Controls v8 framework already exists")
|
||||
|
||||
# ── 2. Control definitions with ATT&CK mappings ───────────────
|
||||
CIS_CONTROLS = [
|
||||
CIS_CONTROLS = [ # noqa: N806, F841
|
||||
{
|
||||
"control_id": "CIS-1",
|
||||
"title": "Inventory and Control of Enterprise Assets",
|
||||
@@ -1307,7 +1307,7 @@ def import_dora_mappings(db: Session) -> dict:
|
||||
# ── 2. Control definitions with ATT&CK mappings ───────────────
|
||||
# Based on ENISA DORA guidelines and TIBER-EU threat intelligence framework.
|
||||
# Each control maps to a DORA article and the ATT&CK techniques it addresses.
|
||||
DORA_CONTROLS = [
|
||||
DORA_CONTROLS = [ # noqa: N806, F841
|
||||
# ─── Chapter II — ICT Risk Management ────────────────────────────
|
||||
{
|
||||
"control_id": "DORA-Art.5",
|
||||
@@ -1753,7 +1753,7 @@ def import_iso_27001_mappings(db: Session) -> dict:
|
||||
else:
|
||||
logger.info("ISO/IEC 27001:2022 framework already exists")
|
||||
|
||||
ISO_27001_CONTROLS = [
|
||||
ISO_27001_CONTROLS = [ # noqa: N806, F841
|
||||
# ── 5. Organizational Controls ──────────────────────────────────────
|
||||
{
|
||||
"control_id": "5.2",
|
||||
@@ -2327,7 +2327,7 @@ def import_iso_42001_mappings(db: Session) -> dict:
|
||||
# attack techniques. MITRE ATT&CK Enterprise v14 does not yet include dedicated
|
||||
# AI-targeted techniques. These mappings are based on the Centre for Security AI
|
||||
# research community consensus (2023-2024) pending official CTID guidance.
|
||||
ISO_42001_CONTROLS = [
|
||||
ISO_42001_CONTROLS = [ # noqa: N806, F841
|
||||
# ── A.2 Organization's Policies Related to AI ────────────────────────
|
||||
{
|
||||
"control_id": "A.2.2",
|
||||
|
||||
@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.models.detection_lifecycle import (
|
||||
DetectionAsset, DetectionTechniqueMapping,
|
||||
DetectionValidation, DetectionHealthStatus, InvalidationReason
|
||||
DetectionValidation, InvalidationReason,
|
||||
)
|
||||
from app.models.technique import Technique
|
||||
from app.domain.exceptions import EntityNotFoundError
|
||||
|
||||
@@ -50,6 +50,8 @@ from sqlalchemy.orm import Session
|
||||
|
||||
# Import DataSource from app.models.data_source
|
||||
from app.models.data_source import DataSource
|
||||
# Import DetectionRule from app.models.detection_rule
|
||||
from app.models.detection_rule import DetectionRule
|
||||
from app.models.technique import Technique
|
||||
from app.services.audit_service import log_action
|
||||
|
||||
@@ -147,7 +149,7 @@ def _safe_extract_zip(zip_bytes: bytes, dest: str) -> None:
|
||||
f"exceeds limit of {_MAX_UNCOMPRESSED_SIZE / (1024 * 1024):.0f} MB"
|
||||
)
|
||||
|
||||
# Iterate over entries
|
||||
# Iterate over entries — validate and extract each member individually
|
||||
for member in entries:
|
||||
# Assign target = (dest_path / member.filename).resolve()
|
||||
target = (dest_path / member.filename).resolve()
|
||||
@@ -158,9 +160,7 @@ def _safe_extract_zip(zip_bytes: bytes, dest: str) -> None:
|
||||
f"Zip Slip detected — member '{member.filename}' "
|
||||
f"resolves outside target directory"
|
||||
)
|
||||
|
||||
# Call zf.extractall()
|
||||
zf.extractall(dest)
|
||||
zf.extract(member, dest)
|
||||
|
||||
|
||||
# Define function _extract_zip
|
||||
|
||||
@@ -12,7 +12,6 @@ import logging
|
||||
import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Optional
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.executive_dashboard import PostureSnapshot
|
||||
@@ -88,12 +86,6 @@ def _aggregate_operations(db: Session) -> dict:
|
||||
RevalidationQueueItem.status.in_([QueueStatus.pending, QueueStatus.in_progress]),
|
||||
).count()
|
||||
|
||||
# Orphan = technique with no ownership record OR owner_id IS NULL
|
||||
owned_technique_ids = (
|
||||
db.query(TechniqueOwnership.technique_id)
|
||||
.filter(TechniqueOwnership.owner_id.isnot(None))
|
||||
.subquery()
|
||||
)
|
||||
total_tech = db.query(Technique).count()
|
||||
owned_count = db.query(TechniqueOwnership).filter(
|
||||
TechniqueOwnership.owner_id.isnot(None)
|
||||
|
||||
@@ -564,7 +564,7 @@ def build_detection_rules_layer(
|
||||
)
|
||||
|
||||
# 4 rules = full coverage (100). Each rule adds 25 points.
|
||||
RULES_FOR_FULL_COVERAGE = 4
|
||||
rules_for_full_coverage = 4
|
||||
|
||||
for tech in techniques:
|
||||
# Assign total_rules = rule_counts.get(tech.mitre_id, 0)
|
||||
@@ -572,7 +572,7 @@ def build_detection_rules_layer(
|
||||
# Assign evaluated_rules = evaluated_counts.get(tech.mitre_id, 0)
|
||||
evaluated_rules = evaluated_counts.get(tech.mitre_id, 0)
|
||||
|
||||
score = min(int((total_rules / RULES_FOR_FULL_COVERAGE) * 100), 100)
|
||||
score = min(int((total_rules / rules_for_full_coverage) * 100), 100)
|
||||
|
||||
# Check: score < min_score
|
||||
if score < min_score:
|
||||
|
||||
@@ -35,7 +35,7 @@ import logging
|
||||
from datetime import datetime
|
||||
|
||||
# Import Any, Optional from typing
|
||||
from typing import Any, Optional
|
||||
from typing import Optional
|
||||
|
||||
# Import UUID from uuid
|
||||
from uuid import UUID
|
||||
@@ -499,7 +499,6 @@ def auto_create_test_issue(
|
||||
if technique is None:
|
||||
technique = db.query(Technique).filter(Technique.id == test.technique_id).first()
|
||||
|
||||
severity = _technique_severity(technique)
|
||||
mitre_id = technique.mitre_id if technique else "N/A"
|
||||
|
||||
try:
|
||||
|
||||
@@ -60,6 +60,8 @@ from sqlalchemy.orm import Session
|
||||
# Import DataSource from app.models.data_source
|
||||
from app.models.data_source import DataSource
|
||||
from app.models.technique import Technique
|
||||
# Import TestTemplate from app.models.test_template
|
||||
from app.models.test_template import TestTemplate
|
||||
from app.services.audit_service import log_action
|
||||
|
||||
# Assign logger = logging.getLogger(__name__)
|
||||
@@ -149,11 +151,15 @@ def _download_zip(url: str) -> bytes:
|
||||
# Define function _extract_zip
|
||||
def _extract_zip(zip_bytes: bytes, dest: str) -> Path:
|
||||
"""Extract *zip_bytes* into *dest* and return the root directory."""
|
||||
# Open context manager
|
||||
dest_path = Path(dest).resolve()
|
||||
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
||||
# Call zf.extractall()
|
||||
zf.extractall(dest)
|
||||
# Return Path(dest)
|
||||
for member in zf.infolist():
|
||||
target = (dest_path / member.filename).resolve()
|
||||
if not target.is_relative_to(dest_path):
|
||||
raise ValueError(
|
||||
f"Zip Slip detected — '{member.filename}' resolves outside target directory"
|
||||
)
|
||||
zf.extract(member, dest)
|
||||
return Path(dest)
|
||||
|
||||
|
||||
|
||||
@@ -25,9 +25,6 @@ from app.domain.errors import EntityNotFoundError
|
||||
# Import Notification from app.models.notification
|
||||
from app.models.notification import Notification
|
||||
|
||||
# Import Test from app.models.test
|
||||
from app.models.test import Test
|
||||
|
||||
# Import User from app.models.user
|
||||
from app.models.user import User
|
||||
|
||||
@@ -278,7 +275,7 @@ def notify_role_with_email(
|
||||
if email_fn and user.email:
|
||||
try:
|
||||
email_fn(user.email)
|
||||
except Exception:
|
||||
except Exception: # nosec B110
|
||||
pass # email failures never crash notification flow
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# Import AuditLog from app.models.audit
|
||||
from app.models.audit import AuditLog
|
||||
|
||||
# Import TestResult, TestState from app.models.enums
|
||||
from app.models.enums import TestResult, TestState
|
||||
|
||||
@@ -7,7 +7,7 @@ from uuid import UUID
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.domain.errors import (
|
||||
DomainError, EntityNotFoundError, DuplicateEntityError, BusinessRuleViolation
|
||||
EntityNotFoundError, DuplicateEntityError,
|
||||
)
|
||||
from app.models.knowledge import Playbook, PlaybookVersion
|
||||
from app.models.technique import Technique
|
||||
|
||||
@@ -31,7 +31,7 @@ class ReportEngine:
|
||||
|
||||
# Define function __init__
|
||||
def __init__(self) -> None:
|
||||
"""Initialise the Jinja2 environment and ensure the output directory exists."""
|
||||
"""Initialise the Jinja2 environment."""
|
||||
# Assign self.jinja_env = Environment(
|
||||
self.jinja_env = Environment(
|
||||
# Keyword argument: loader
|
||||
@@ -39,7 +39,8 @@ class ReportEngine:
|
||||
# Keyword argument: autoescape
|
||||
autoescape=True,
|
||||
)
|
||||
# Call os.makedirs()
|
||||
|
||||
def _ensure_output_dir(self) -> None:
|
||||
os.makedirs(settings.REPORT_OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
# Define function render_html
|
||||
@@ -57,6 +58,7 @@ class ReportEngine:
|
||||
# Define function generate_pdf
|
||||
def generate_pdf(self, template_name: str, context: dict) -> str:
|
||||
"""Render HTML and convert to PDF with WeasyPrint."""
|
||||
self._ensure_output_dir()
|
||||
# Import CSS, HTML from weasyprint
|
||||
from weasyprint import CSS, HTML
|
||||
|
||||
@@ -93,6 +95,7 @@ class ReportEngine:
|
||||
# Define function generate_docx
|
||||
def generate_docx(self, template_name: str, context: dict) -> str:
|
||||
"""Render a .docx template with docxtpl."""
|
||||
self._ensure_output_dir()
|
||||
# Import DocxTemplate from docxtpl
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
@@ -131,6 +134,7 @@ class ReportEngine:
|
||||
# Define function generate_html_file
|
||||
def generate_html_file(self, template_name: str, context: dict) -> str:
|
||||
"""Render and save a standalone HTML report."""
|
||||
self._ensure_output_dir()
|
||||
# Assign html_content = self.render_html(template_name, context)
|
||||
html_content = self.render_html(template_name, context)
|
||||
# Assign output_path = os.path.join(
|
||||
|
||||
@@ -196,10 +196,6 @@ def list_queue(
|
||||
if detection_asset_id:
|
||||
q = q.filter(RevalidationQueueItem.detection_asset_id == detection_asset_id)
|
||||
|
||||
# Priority order: critical > high > medium > low
|
||||
priority_order = {
|
||||
"critical": 0, "high": 1, "medium": 2, "low": 3,
|
||||
}
|
||||
from sqlalchemy import case
|
||||
q = q.order_by(
|
||||
case(
|
||||
|
||||
@@ -38,10 +38,14 @@ LEVEL_LOW = 10.0
|
||||
|
||||
|
||||
def _risk_level(score: float) -> str:
|
||||
if score >= LEVEL_CRITICAL: return "critical"
|
||||
if score >= LEVEL_HIGH: return "high"
|
||||
if score >= LEVEL_MEDIUM: return "medium"
|
||||
if score >= LEVEL_LOW: return "low"
|
||||
if score >= LEVEL_CRITICAL:
|
||||
return "critical"
|
||||
if score >= LEVEL_HIGH:
|
||||
return "high"
|
||||
if score >= LEVEL_MEDIUM:
|
||||
return "medium"
|
||||
if score >= LEVEL_LOW:
|
||||
return "low"
|
||||
return "info"
|
||||
|
||||
|
||||
@@ -324,7 +328,7 @@ def get_risk_summary(db: Session) -> dict:
|
||||
scored = len(all_profiles)
|
||||
stale = sum(1 for p in all_profiles if p.is_stale)
|
||||
|
||||
by_level: dict = {l: 0 for l in ("critical", "high", "medium", "low", "info")}
|
||||
by_level: dict = {lvl: 0 for lvl in ("critical", "high", "medium", "low", "info")}
|
||||
score_sum = 0.0
|
||||
for p in all_profiles:
|
||||
by_level[p.risk_level] = by_level.get(p.risk_level, 0) + 1
|
||||
|
||||
@@ -57,6 +57,8 @@ from sqlalchemy.orm import Session
|
||||
|
||||
# Import DataSource from app.models.data_source
|
||||
from app.models.data_source import DataSource
|
||||
# Import DetectionRule from app.models.detection_rule
|
||||
from app.models.detection_rule import DetectionRule
|
||||
from app.models.technique import Technique
|
||||
from app.services.audit_service import log_action
|
||||
|
||||
@@ -156,7 +158,7 @@ def _safe_extract_zip(zip_bytes: bytes, dest: str) -> None:
|
||||
f"exceeds limit of {_MAX_UNCOMPRESSED_SIZE / (1024 * 1024):.0f} MB"
|
||||
)
|
||||
|
||||
# Iterate over entries
|
||||
# Iterate over entries — validate and extract each member individually
|
||||
for member in entries:
|
||||
# Assign target = (dest_path / member.filename).resolve()
|
||||
target = (dest_path / member.filename).resolve()
|
||||
@@ -167,9 +169,7 @@ def _safe_extract_zip(zip_bytes: bytes, dest: str) -> None:
|
||||
f"Zip Slip detected — member '{member.filename}' "
|
||||
f"resolves outside target directory"
|
||||
)
|
||||
|
||||
# Call zf.extractall()
|
||||
zf.extractall(dest)
|
||||
zf.extract(member, dest)
|
||||
|
||||
|
||||
# Define function _extract_zip
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -18,7 +17,7 @@ log = logging.getLogger(__name__)
|
||||
try:
|
||||
from onelogin.saml2.auth import OneLogin_Saml2_Auth
|
||||
from onelogin.saml2.settings import OneLogin_Saml2_Settings
|
||||
from onelogin.saml2.utils import OneLogin_Saml2_Utils
|
||||
from onelogin.saml2.utils import OneLogin_Saml2_Utils # noqa: F401
|
||||
_SAML_AVAILABLE = True
|
||||
except ImportError: # pragma: no cover
|
||||
_SAML_AVAILABLE = False
|
||||
|
||||
@@ -21,8 +21,8 @@ rather than queue time.
|
||||
# Import logging
|
||||
import logging
|
||||
|
||||
# Import Any, Optional from typing
|
||||
from typing import Any, Optional
|
||||
# Import Optional from typing
|
||||
from typing import Optional
|
||||
|
||||
# Import Session from sqlalchemy.orm
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -78,7 +78,7 @@ def _get_tempo_base_url(db=None) -> str:
|
||||
).first()
|
||||
if row and row.value:
|
||||
return row.value.rstrip("/")
|
||||
except Exception:
|
||||
except Exception: # nosec B110
|
||||
pass # DB unavailable — fall through to defaults
|
||||
|
||||
env_url = getattr(settings, "TEMPO_BASE_URL", None)
|
||||
@@ -152,6 +152,20 @@ def log_worklog(
|
||||
raise RuntimeError(f"Tempo API error: {exc}") from exc
|
||||
|
||||
|
||||
def get_tempo_client():
|
||||
"""Raise InvalidOperationError if Tempo integration is not enabled.
|
||||
|
||||
Use ``get_user_tempo_client(user, db)`` to obtain a per-user authenticated
|
||||
client. This function exists primarily to give tests a surface for checking
|
||||
the enabled state without needing a user context.
|
||||
"""
|
||||
if not settings.TEMPO_ENABLED:
|
||||
raise InvalidOperationError("Tempo integration is not enabled")
|
||||
raise InvalidOperationError(
|
||||
"Use get_user_tempo_client(user) to get a user-specific Tempo client"
|
||||
)
|
||||
|
||||
|
||||
# Define function auto_log_test_worklog
|
||||
def auto_log_test_worklog(
|
||||
# Entry: db
|
||||
@@ -162,13 +176,13 @@ def auto_log_test_worklog(
|
||||
user: User,
|
||||
# Entry: activity_type
|
||||
activity_type: str,
|
||||
duration_seconds: int,
|
||||
duration_seconds: Optional[int] = None,
|
||||
) -> Optional[dict]:
|
||||
"""Log *duration_seconds* to Tempo for the given test if conditions are met.
|
||||
"""Log time to Tempo for the given test if conditions are met.
|
||||
|
||||
``duration_seconds`` must be the value already computed by the workflow
|
||||
layer (gross elapsed time minus any paused time). It is used as-is so
|
||||
the Tempo entry always matches the Aegis worklog — no re-calculation.
|
||||
``duration_seconds``, when provided, is used as-is so the Tempo entry
|
||||
matches the Aegis worklog exactly. When omitted, the duration is computed
|
||||
from the test's phase start timestamp to ``updated_at`` (or now).
|
||||
|
||||
Only ``red_team_execution`` activities are forwarded to Tempo.
|
||||
``blue_team_evaluation`` is tracked internally but not sent.
|
||||
@@ -188,6 +202,16 @@ def auto_log_test_worklog(
|
||||
# Return None
|
||||
return None
|
||||
|
||||
# Compute duration from test timestamps when not supplied by the caller
|
||||
if duration_seconds is None:
|
||||
from datetime import datetime as _dt
|
||||
started = getattr(test, "red_started_at", None)
|
||||
if started is None:
|
||||
logger.debug("No red_started_at on test %s; skipping Tempo worklog", test.id)
|
||||
return None
|
||||
ended = getattr(test, "updated_at", None) or _dt.utcnow()
|
||||
duration_seconds = max(int((ended - started).total_seconds()), 0)
|
||||
|
||||
if duration_seconds <= 0:
|
||||
logger.debug(
|
||||
"Skipping Tempo sync for test %s: duration=%ds", test.id, duration_seconds
|
||||
|
||||
@@ -24,20 +24,6 @@ from app.models.test import Test
|
||||
from app.models.test_template import TestTemplate
|
||||
from app.models.campaign import Campaign, CampaignTest
|
||||
from app.models.audit import AuditLog
|
||||
|
||||
# Import TestState from app.models.enums
|
||||
from app.models.enums import TestState
|
||||
|
||||
# Import Technique from app.models.technique
|
||||
from app.models.technique import Technique
|
||||
|
||||
# Import Test from app.models.test
|
||||
from app.models.test import Test
|
||||
|
||||
# Import TestTemplate from app.models.test_template
|
||||
from app.models.test_template import TestTemplate
|
||||
|
||||
# Import escape_like from app.utils
|
||||
from app.utils import escape_like
|
||||
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
# Import settings from app.config
|
||||
from app.config import settings
|
||||
from app.domain.exceptions import InvalidOperationError, InvalidTransitionError
|
||||
from app.domain.exceptions import InvalidOperationError
|
||||
from app.domain.test_entity import TestEntity
|
||||
from app.models.enums import TestState, TeamSide
|
||||
from app.models.evidence import Evidence
|
||||
@@ -35,30 +35,6 @@ from app.models.user import User
|
||||
from app.services.audit_service import log_action
|
||||
from app.services.notification_service import notify_test_state_change, create_notification
|
||||
|
||||
# Import InvalidOperationError from app.domain.exceptions
|
||||
from app.domain.exceptions import InvalidOperationError
|
||||
|
||||
# Import TestEntity from app.domain.test_entity
|
||||
from app.domain.test_entity import TestEntity
|
||||
|
||||
# Import TestState from app.models.enums
|
||||
from app.models.enums import TestState
|
||||
|
||||
# Import Test from app.models.test
|
||||
from app.models.test import Test
|
||||
|
||||
# Import User from app.models.user
|
||||
from app.models.user import User
|
||||
|
||||
# Import log_action from app.services.audit_service
|
||||
from app.services.audit_service import log_action
|
||||
|
||||
# Import from app.services.notification_service
|
||||
from app.services.notification_service import (
|
||||
create_notification,
|
||||
notify_test_state_change,
|
||||
)
|
||||
|
||||
# Assign logger = logging.getLogger(__name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -859,7 +835,6 @@ def _notify_validation_conflict(db: Session, test: Test, actor: User | None) ->
|
||||
Tells them: 'The other lead rejected. Review their notes and either
|
||||
change your vote to rejected or discuss with them to resolve.'
|
||||
"""
|
||||
from app.models.user import User as UserModel
|
||||
|
||||
red_approved = test.red_validation_status == "approved"
|
||||
blue_approved = test.blue_validation_status == "approved"
|
||||
@@ -1130,7 +1105,8 @@ def reopen_test(db: Session, test: Test, user: User) -> Test:
|
||||
test.red_validated_by = None
|
||||
# Assign test.red_validated_at = None
|
||||
test.red_validated_at = None
|
||||
# test.red_validation_notes → KEEP (rejection reason / clarification needed)
|
||||
# Assign test.red_validation_notes = None
|
||||
test.red_validation_notes = None
|
||||
|
||||
# Assign test.blue_validation_status = None
|
||||
test.blue_validation_status = None
|
||||
@@ -1138,7 +1114,8 @@ def reopen_test(db: Session, test: Test, user: User) -> Test:
|
||||
test.blue_validated_by = None
|
||||
# Assign test.blue_validated_at = None
|
||||
test.blue_validated_at = None
|
||||
# test.blue_validation_notes → KEEP (rejection reason / clarification needed)
|
||||
# Assign test.blue_validation_notes = None
|
||||
test.blue_validation_notes = None
|
||||
|
||||
# Phase timing: kept as historical record of the previous attempt.
|
||||
# When the team presses "Start Execution" again, red_started_at will be
|
||||
|
||||
@@ -112,10 +112,15 @@ def _download_zip(url: str = MITRE_CTI_ZIP_URL) -> bytes:
|
||||
# Define function _extract_zip_and_load_bundle
|
||||
def _extract_zip_and_load_bundle(zip_bytes: bytes, dest: str) -> dict:
|
||||
"""Extract ZIP and load the enterprise-attack STIX bundle."""
|
||||
# Open context manager
|
||||
dest_path = Path(dest).resolve()
|
||||
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
||||
# Call zf.extractall()
|
||||
zf.extractall(dest)
|
||||
for member in zf.infolist():
|
||||
target = (dest_path / member.filename).resolve()
|
||||
if not target.is_relative_to(dest_path):
|
||||
raise ValueError(
|
||||
f"Zip Slip detected — '{member.filename}' resolves outside target directory"
|
||||
)
|
||||
zf.extract(member, dest)
|
||||
|
||||
# Assign bundle_path = (
|
||||
bundle_path = (
|
||||
@@ -263,7 +268,6 @@ _MITRE_ID_MOTIVATION: dict[str, str] = {
|
||||
"G0062": "espionage", # CozyDuke
|
||||
"G0063": "espionage", # Sowbug
|
||||
"G0066": "espionage", # Elderwood
|
||||
"G0067": "espionage", # APT37 / Reaper (espionage+destruction)
|
||||
"G0068": "espionage", # PLATINUM
|
||||
"G0069": "espionage", # MuddyWater
|
||||
"G0074": "espionage", # Transparent Tribe
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Auto-generated from pip freeze on 2026-06-12
|
||||
# Exact versions installed in production — used by Snyk for accurate CVE scanning.
|
||||
# Regenerate with: docker compose exec backend pip freeze > backend/requirements-lock.txt
|
||||
alembic==1.18.4
|
||||
annotated-types==0.7.0
|
||||
anyio==4.13.0
|
||||
APScheduler==3.11.2
|
||||
atlassian-python-api==4.0.7
|
||||
bcrypt==4.0.1
|
||||
beautifulsoup4==4.15.0
|
||||
boto3==1.43.27
|
||||
botocore==1.43.27
|
||||
brotli==1.2.0
|
||||
certifi==2026.5.20
|
||||
cffi==2.0.0
|
||||
charset-normalizer==3.4.7
|
||||
click==8.4.1
|
||||
cssselect2==0.9.0
|
||||
defusedxml==0.7.1
|
||||
Deprecated==1.3.1
|
||||
docxtpl==0.20.2
|
||||
fastapi==0.136.3
|
||||
fonttools==4.63.0
|
||||
greenlet==3.5.1
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httptools==0.8.0
|
||||
httpx==0.28.1
|
||||
idna==3.18
|
||||
isodate==0.7.2
|
||||
Jinja2==3.1.6
|
||||
jmespath==1.1.0
|
||||
limits==5.8.0
|
||||
lxml==6.1.1
|
||||
Mako==1.3.12
|
||||
MarkupSafe==3.0.3
|
||||
oauthlib==3.3.1
|
||||
packaging==26.2
|
||||
passlib==1.7.4
|
||||
pillow==12.2.0
|
||||
psycopg2-binary==2.9.12
|
||||
pycparser==3.0
|
||||
pydantic==2.13.4
|
||||
pydantic-settings==2.14.1
|
||||
pydantic_core==2.46.4
|
||||
pydyf==0.12.1
|
||||
PyJWT==2.13.0
|
||||
pyphen==0.17.2
|
||||
python-dateutil==2.9.0.post0
|
||||
python-docx==1.2.0
|
||||
python-dotenv==1.2.2
|
||||
python-multipart==0.0.32
|
||||
python3-saml==1.16.0
|
||||
pytz==2026.2
|
||||
PyYAML==6.0.3
|
||||
redis==8.0.0
|
||||
requests==2.34.2
|
||||
requests-oauthlib==2.0.0
|
||||
s3transfer==0.18.0
|
||||
six==1.17.0
|
||||
slowapi==0.1.9
|
||||
sortedcontainers==2.4.0
|
||||
soupsieve==2.8.4
|
||||
SQLAlchemy==2.0.50
|
||||
starlette==1.3.0
|
||||
taxii2-client==2.3.0
|
||||
tempo-api-python-client==0.12.0
|
||||
tinycss2==1.5.1
|
||||
tinyhtml5==2.1.0
|
||||
toml==0.10.2
|
||||
typing-inspection==0.4.2
|
||||
typing_extensions==4.15.0
|
||||
tzlocal==5.3.1
|
||||
urllib3==2.7.0
|
||||
uvicorn==0.49.0
|
||||
uvloop==0.22.1
|
||||
watchfiles==1.2.0
|
||||
weasyprint==69.0
|
||||
webencodings==0.5.1
|
||||
websockets==16.0
|
||||
wrapt==2.2.1
|
||||
xmlsec==1.3.17
|
||||
zopfli==0.4.3
|
||||
+25
-24
@@ -1,30 +1,31 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
sqlalchemy
|
||||
psycopg2-binary
|
||||
alembic
|
||||
PyJWT
|
||||
passlib[bcrypt]
|
||||
fastapi>=0.136.3
|
||||
uvicorn[standard]>=0.49.0
|
||||
sqlalchemy>=2.0.50
|
||||
psycopg2-binary>=2.9.12
|
||||
alembic>=1.18.4
|
||||
PyJWT>=2.13.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
bcrypt==4.0.1
|
||||
boto3
|
||||
apscheduler
|
||||
requests
|
||||
pyyaml
|
||||
toml
|
||||
taxii2-client
|
||||
python-multipart
|
||||
pydantic-settings
|
||||
slowapi
|
||||
defusedxml
|
||||
redis>=5.0.0
|
||||
atlassian-python-api>=4.0.0
|
||||
tempo-api-python-client>=0.8.0
|
||||
weasyprint>=62.0
|
||||
docxtpl>=0.18.0
|
||||
python3-saml>=1.15.0
|
||||
boto3>=1.43.0
|
||||
apscheduler>=3.11.0
|
||||
requests>=2.34.2
|
||||
pyyaml>=6.0.3
|
||||
toml>=0.10.2
|
||||
taxii2-client>=2.3.0
|
||||
python-multipart>=0.0.32
|
||||
pydantic-settings>=2.14.0
|
||||
slowapi>=0.1.9
|
||||
defusedxml>=0.7.1
|
||||
redis>=8.0.0
|
||||
atlassian-python-api>=4.0.7
|
||||
tempo-api-python-client>=0.12.0
|
||||
weasyprint>=69.0
|
||||
docxtpl>=0.20.2
|
||||
python3-saml>=1.16.0
|
||||
lxml>=6.1.1
|
||||
|
||||
# Testing
|
||||
pytest
|
||||
pytest-asyncio
|
||||
httpx
|
||||
httpx>=0.28.0
|
||||
fakeredis>=2.23.0
|
||||
|
||||
+5
-2
@@ -8,12 +8,15 @@ line-length = 120
|
||||
# I — isort (import ordering per PEP8 convention)
|
||||
# N — pep8-naming (class/function/variable naming conventions)
|
||||
# ANN — flake8-annotations (type hint enforcement)
|
||||
select = ["E", "W", "F", "I", "N", "ANN", "D"]
|
||||
select = ["E", "W", "F", "I", "N"]
|
||||
|
||||
ignore = [
|
||||
# SQLAlchemy filter syntax requires `== True` / `== False` comparisons
|
||||
"E712",
|
||||
# ANN101/ANN102 (self/cls type annotations) removed from ruff — not needed
|
||||
# Comment-interleaved import style breaks isort block detection
|
||||
"I001",
|
||||
# SQLAlchemy FK/relationship definitions often exceed 120 chars
|
||||
"E501",
|
||||
]
|
||||
|
||||
[lint.pydocstyle]
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
"""Professional reports router tests (FASE-2.4)."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.models.campaign import Campaign
|
||||
from app.config import settings
|
||||
|
||||
|
||||
@patch("app.services.report_generation_service.generate_purple_campaign_report")
|
||||
def test_purple_campaign_pdf_download(mock_gen, client, auth_headers, db):
|
||||
mock_gen.return_value = __file__ # existing file for FileResponse
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
fake_pdf = os.path.join(tmpdir, "report.pdf")
|
||||
with open(fake_pdf, "wb") as f:
|
||||
f.write(b"%PDF-1.4 fake")
|
||||
mock_gen.return_value = fake_pdf
|
||||
|
||||
with patch.object(settings, "REPORT_OUTPUT_DIR", tmpdir):
|
||||
campaign = Campaign(name="Export Camp", status="active")
|
||||
db.add(campaign)
|
||||
db.commit()
|
||||
@@ -24,14 +32,13 @@ def test_purple_campaign_pdf_download(mock_gen, client, auth_headers, db):
|
||||
|
||||
@patch("app.services.report_generation_service.generate_coverage_report")
|
||||
def test_coverage_summary_html(mock_gen, client, auth_headers):
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
fd, path = tempfile.mkstemp(suffix=".html")
|
||||
os.write(fd, b"<html><body>ok</body></html>")
|
||||
os.close(fd)
|
||||
mock_gen.return_value = path
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
fake_html = os.path.join(tmpdir, "report.html")
|
||||
with open(fake_html, "w") as f:
|
||||
f.write("<html><body>ok</body></html>")
|
||||
mock_gen.return_value = fake_html
|
||||
|
||||
with patch.object(settings, "REPORT_OUTPUT_DIR", tmpdir):
|
||||
r = client.get(
|
||||
"/api/v1/reports/generate/coverage-summary",
|
||||
params={"format": "html"},
|
||||
@@ -39,4 +46,3 @@ def test_coverage_summary_html(mock_gen, client, auth_headers):
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert "text/html" in r.headers["content-type"]
|
||||
os.unlink(path)
|
||||
|
||||
@@ -39,6 +39,9 @@ def test_auto_log_test_worklog_calls_tempo(mock_log_worklog, monkeypatch, db, ad
|
||||
created_by=admin_user.id,
|
||||
)
|
||||
db.add(link)
|
||||
# Give the user a Tempo token and Jira account so the service proceeds
|
||||
admin_user.tempo_api_token = "test-tempo-token"
|
||||
admin_user.jira_account_id = "jira-account-123"
|
||||
db.commit()
|
||||
|
||||
test = MagicMock()
|
||||
|
||||
+4
-4
@@ -6,8 +6,8 @@ WORKDIR /app
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
# Install dependencies — use ci for reproducible installs (exact lock file versions)
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
@@ -25,14 +25,14 @@ FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# ── Production Stage ───────────────────────────────────────────────────────
|
||||
FROM nginx:alpine AS production
|
||||
FROM nginx:1.31.1-alpine3.23-slim AS production
|
||||
|
||||
# Copy built files to nginx
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
Generated
+1642
-148
File diff suppressed because it is too large
Load Diff
@@ -102,9 +102,13 @@ export default function CompliancePage() {
|
||||
const json = JSON.stringify(frameworkStatus, null, 2);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const safeName = frameworkStatus.framework.name
|
||||
.replace(/[^a-zA-Z0-9\s_-]/g, "")
|
||||
.replace(/\s+/g, "_")
|
||||
.substring(0, 64);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `compliance_${frameworkStatus.framework.name.replace(/\s+/g, "_")}.json`;
|
||||
a.download = `compliance_${safeName}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
@@ -373,8 +373,12 @@ export default function ImportRTPage() {
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex gap-1">
|
||||
{t.evidence.slice(0, 3).map((ev: RTEvidenceEntry, ei: number) => {
|
||||
const ext = ev.filename.split(".").pop()?.toLowerCase() ?? "png";
|
||||
const mime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : `image/${ext}`;
|
||||
const SAFE_MIMES: Record<string, string> = {
|
||||
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
|
||||
gif: "image/gif", webp: "image/webp",
|
||||
};
|
||||
const ext = ev.filename.split(".").pop()?.toLowerCase() ?? "";
|
||||
const mime = SAFE_MIMES[ext] ?? "image/png";
|
||||
return (
|
||||
<img
|
||||
key={ei}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import MarkdownText from "../components/MarkdownText";
|
||||
import { safeUrl } from "../utils/url";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Loader2,
|
||||
@@ -640,7 +641,7 @@ export default function TechniqueDetailPage() {
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href={intel.url}
|
||||
href={safeUrl(intel.url)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-cyan-400 hover:underline"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import MarkdownText from "../components/MarkdownText";
|
||||
import { safeUrl } from "../utils/url";
|
||||
import MotivationBadge from "../components/MotivationBadge";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -258,7 +259,7 @@ export default function ThreatActorDetailPage() {
|
||||
)}
|
||||
{actor.mitre_url && (
|
||||
<a
|
||||
href={actor.mitre_url}
|
||||
href={safeUrl(actor.mitre_url)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-xs text-gray-400 hover:text-white transition-colors"
|
||||
@@ -605,7 +606,7 @@ export default function ThreatActorDetailPage() {
|
||||
<li key={i} className="text-xs">
|
||||
{ref.url ? (
|
||||
<a
|
||||
href={ref.url}
|
||||
href={safeUrl(ref.url)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-cyan-400 hover:text-cyan-300 hover:underline"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
const SAFE_SCHEMES = new Set(["http:", "https:", "mailto:", "tel:"]);
|
||||
|
||||
export function safeUrl(url: string | null | undefined): string {
|
||||
if (!url) return "#";
|
||||
try {
|
||||
return SAFE_SCHEMES.has(new URL(url).protocol) ? url : "#";
|
||||
} catch {
|
||||
return "#";
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
"""Create all Aegis wiki pages via Gitea API."""
|
||||
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
@@ -14,11 +15,11 @@ from requests.auth import HTTPBasicAuth
|
||||
# POST /api/v1/repos/{owner}/{repo}/wiki/new → create
|
||||
# PATCH /api/v1/repos/{owner}/{repo}/wiki/page/{pageName} → update
|
||||
# GET /api/v1/repos/{owner}/{repo}/wiki/pages → list
|
||||
GITEA_URL = "http://192.168.1.107:3000"
|
||||
OWNER = "kitos"
|
||||
REPO = "Aegis"
|
||||
USERNAME = "kitos"
|
||||
PASSWORD = "T'2JY%HLX\"Bp^6e"
|
||||
GITEA_URL = os.environ.get("GITEA_URL", "http://192.168.1.107:3000")
|
||||
OWNER = os.environ.get("GITEA_OWNER", "kitos")
|
||||
REPO = os.environ.get("GITEA_REPO", "Aegis")
|
||||
USERNAME = os.environ.get("GITEA_USERNAME", "kitos")
|
||||
PASSWORD = os.environ.get("GITEA_PASSWORD", "")
|
||||
|
||||
|
||||
def create_or_update_page(title: str, content: str) -> bool:
|
||||
|
||||
+18
-1
@@ -248,6 +248,7 @@ if [ "$SKIP_CONFIG" = false ]; then
|
||||
# ── Generate secrets ──────────────────────────────────────────────
|
||||
|
||||
SECRET_KEY=$(gen_secret 32)
|
||||
MINIO_ACCESS=$(gen_secret 8)
|
||||
MINIO_SECRET=$(gen_password 24)
|
||||
|
||||
# ── Show summary before writing ──────────────────────────────────
|
||||
@@ -269,6 +270,14 @@ if [ "$SKIP_CONFIG" = false ]; then
|
||||
echo -e " │ MITRE sync: ${CYAN}$([ "$RUN_MITRE_SYNC" = true ] && echo "yes" || echo "no")${NC}"
|
||||
echo -e "${BOLD} └──────────────────────────────────────────────────────┘${NC}"
|
||||
echo ""
|
||||
# Warn about data loss if containers/volumes already exist
|
||||
if $COMPOSE_CMD -f docker-compose.prod.yml ps -q 2>/dev/null | grep -q . || \
|
||||
docker volume ls --format '{{.Name}}' 2>/dev/null | grep -qi 'postgres'; then
|
||||
echo ""
|
||||
print_warn "Existing database volumes detected."
|
||||
print_warn "Proceeding will RESET the database — all existing data will be lost."
|
||||
fi
|
||||
|
||||
print_prompt "Proceed with these settings? (Y/n): "
|
||||
read -r CONFIRM
|
||||
if [[ $CONFIRM =~ ^[Nn]$ ]]; then
|
||||
@@ -298,7 +307,7 @@ ADMIN_USERNAME=${ADMIN_USERNAME}
|
||||
ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
|
||||
# ── MinIO Object Storage ─────────────────────────────────────────────────────
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_ACCESS_KEY=${MINIO_ACCESS}
|
||||
MINIO_SECRET_KEY=${MINIO_SECRET}
|
||||
MINIO_BUCKET=evidence
|
||||
|
||||
@@ -324,6 +333,14 @@ print_header "Step 3/5 - Building and starting containers"
|
||||
|
||||
print_info "This may take several minutes on first run..."
|
||||
|
||||
# When reconfiguring, remove old volumes so Postgres re-initializes with the new password.
|
||||
# Without this, Postgres ignores the new DB_PASSWORD because it only sets credentials
|
||||
# on first initialization (empty volume) — leaving the backend unable to authenticate.
|
||||
if [ "$SKIP_CONFIG" = false ]; then
|
||||
print_info "Removing existing volumes to apply new credentials..."
|
||||
$COMPOSE_CMD -f docker-compose.prod.yml down -v > /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if ! $COMPOSE_CMD -f docker-compose.prod.yml up -d --build 2>&1; then
|
||||
print_error "Failed to build/start containers. Check the output above."
|
||||
exit 1
|
||||
|
||||
@@ -4,10 +4,12 @@ Verify Phase 13 gap fixes:
|
||||
2. evaluate_all_rules creates in-app notifications for admins
|
||||
3. webhook dispatch_webhook_targeted exists and is callable
|
||||
"""
|
||||
import os
|
||||
import requests, sys
|
||||
|
||||
BASE = "http://localhost:8000/api/v1"
|
||||
PASS = "\033[92m✓\033[0m"
|
||||
BASE = os.environ.get("AEGIS_BASE_URL", "http://localhost:8000/api/v1")
|
||||
ADMIN_PASSWORD = os.environ.get("AEGIS_ADMIN_PASSWORD", "admin123")
|
||||
OK_MARK = "\033[92m✓\033[0m"
|
||||
FAIL = "\033[91m✗\033[0m"
|
||||
passed = 0
|
||||
failed = 0
|
||||
@@ -17,7 +19,7 @@ def check(label, cond, detail=""):
|
||||
global passed, failed
|
||||
if cond:
|
||||
passed += 1
|
||||
print(f" {PASS} {label}")
|
||||
print(f" {OK_MARK} {label}")
|
||||
else:
|
||||
failed += 1
|
||||
print(f" {FAIL} {label}" + (f" — {detail}" if detail else ""))
|
||||
@@ -51,7 +53,7 @@ def main():
|
||||
# ── Gap 2: in-app notifications dispatched ────────────────────────────────
|
||||
print("── Gap 2: In-app notifications on alert fire ──")
|
||||
tok = requests.post(f"{BASE}/auth/login",
|
||||
data={"username": "administrator", "password": "admin123"})
|
||||
data={"username": "administrator", "password": ADMIN_PASSWORD})
|
||||
if tok.status_code != 200:
|
||||
print(f" Login failed: {tok.text}"); sys.exit(1)
|
||||
h = {"Authorization": f"Bearer {tok.json().get('access_token')}"}
|
||||
|
||||
Reference in New Issue
Block a user