diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ff3de3..2aef6fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,10 @@ jobs: python-version: ['3.10', '3.11', '3.12'] steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 @@ -29,39 +32,44 @@ jobs: run: | python -m pip install --upgrade pip pip install -e ".[dev]" + pip install pytest-asyncio pytest-cov - - name: Run Pytest + - name: Run Pytest with coverage run: | - python -m pytest tests/ -v --tb=short --junitxml=test-results.xml + pytest tests/ -v \ + --cov=src/qwed_a2a \ + --cov-report=xml \ + --cov-report=term-missing \ + --tb=short \ + --junitxml=test-results.xml + + - name: Upload coverage to Codecov + if: matrix.python-version == '3.11' + uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage.xml + flags: unittests + name: qwed-a2a + fail_ci_if_error: false - - name: Upload Test Results to Mergify CI Insights + - name: Upload test results to Mergify CI Insights uses: Mergifyio/gha-mergify-ci@668bd8fd12563b7816b51f69ad8a13652925ac55 # v6 if: ${{ always() && env.MERGIFY_TOKEN != '' }} with: token: ${{ secrets.MERGIFY_TOKEN }} report_path: test-results.xml - - name: SonarCloud Scan - if: matrix.python-version == '3.11' - uses: SonarSource/sonarqube-scan-action@bfd4e558cda28cda6b5defafb9232d191be8c203 # v4.2.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - - name: Run Snyk to check for vulnerabilities - if: matrix.python-version == '3.11' - uses: snyk/actions/python@9adf32b1121593767fc3c057af55b55db032dc04 # v1.0.0 - env: - SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} - with: - args: --severity-threshold=high - lint: permissions: contents: read runs-on: ubuntu-latest + steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 @@ -69,13 +77,10 @@ jobs: python-version: '3.11' - name: Install linters - run: | - pip install black ruff + run: pip install black ruff - name: Check formatting with Black - run: | - black --check src/ || echo "Formatting check failed (non-blocking)" + run: black --check --target-version py311 src/ - name: Lint with Ruff - run: | - ruff check src/ || echo "Lint check failed (non-blocking)" + run: ruff check src/ diff --git a/.github/workflows/snyk.yml b/.github/workflows/snyk.yml new file mode 100644 index 0000000..75e9cae --- /dev/null +++ b/.github/workflows/snyk.yml @@ -0,0 +1,75 @@ +# Snyk Security Scan — Dependency and SAST analysis +# Runs on PRs and weekly schedule + +name: Snyk Security + +on: + pull_request: + branches: [main] + schedule: + - cron: '0 9 * * 1' # weekly Monday 9 AM UTC + +permissions: + contents: read + security-events: write + +jobs: + snyk-dependencies: + name: Snyk Dependency Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 + with: + python-version: '3.11' + + - name: Install Snyk CLI + run: npm install -g snyk + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run Snyk dependency scan + continue-on-error: true + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + run: snyk test --org=${{ secrets.SNYK_ORG_ID }} --severity-threshold=high --sarif-file-output=snyk.sarif + + - name: Upload dependency scan results to GitHub Security + uses: github/codeql-action/upload-sarif@5c8a8a642e79153f5d047b10ec1cba1d1cc65699 # v3 + if: always() && hashFiles('snyk.sarif') != '' + with: + sarif_file: snyk.sarif + + snyk-code: + name: Snyk Code Analysis (SAST) + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + persist-credentials: false + + - name: Install Snyk CLI + run: npm install -g snyk + + - name: Run Snyk SAST scan + continue-on-error: true + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + run: snyk code test --org=${{ secrets.SNYK_ORG_ID }} --sarif-file-output=snyk-code.sarif + + - name: Upload SAST results to GitHub Security + uses: github/codeql-action/upload-sarif@5c8a8a642e79153f5d047b10ec1cba1d1cc65699 # v3 + if: always() && hashFiles('snyk-code.sarif') != '' + with: + sarif_file: snyk-code.sarif diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml new file mode 100644 index 0000000..9a90632 --- /dev/null +++ b/.github/workflows/sonar.yml @@ -0,0 +1,49 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +jobs: + sonarcloud: + name: SonarCloud + runs-on: ubuntu-latest + if: github.actor != 'dependabot[bot]' + + steps: + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + fetch-depth: 0 # full history for better analysis relevancy + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + pip install pytest-asyncio pytest-cov + + - name: Run tests with coverage + run: | + pytest tests/ -v \ + --cov=src/qwed_a2a \ + --cov-report=xml \ + --cov-report=term-missing \ + --tb=short + + - name: SonarCloud Scan + uses: SonarSource/sonarqube-scan-action@bfd4e558cda28cda6b5defafb9232d191be8c203 # v4.2.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/sonar-project.properties b/sonar-project.properties index 6a46f3c..50d201c 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -5,3 +5,4 @@ sonar.sources=src sonar.tests=tests sonar.exclusions=**/__init__.py,**/*.md,**/*.txt sonar.host.url=https://sonarcloud.io +sonar.python.coverage.reportPaths=coverage.xml diff --git a/src/qwed_a2a/interceptor.py b/src/qwed_a2a/interceptor.py index bd21cb2..54d4058 100644 --- a/src/qwed_a2a/interceptor.py +++ b/src/qwed_a2a/interceptor.py @@ -115,7 +115,11 @@ async def intercept( engine_result = self._route_to_engine(message) except Exception as exc: logger.error("Verification engine error: %s", exc) - status = VerdictStatus.BLOCKED if self.config.block_on_error else VerdictStatus.FORWARDED + status = ( + VerdictStatus.BLOCKED + if self.config.block_on_error + else VerdictStatus.FORWARDED + ) verdict = self._build_verdict( trace_id=trace_id, status=status, @@ -212,7 +216,9 @@ def _verify_financial(self, payload: Dict[str, Any]) -> Dict[str, Any]: quantity = item.get("quantity", 1) computed_total += Decimal(str(amount)) * Decimal(str(quantity)) - computed_total = computed_total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + computed_total = computed_total.quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) claimed_decimal = Decimal(str(claimed_total)).quantize( Decimal("0.01"), rounding=ROUND_HALF_UP ) diff --git a/src/qwed_a2a/protocol/endpoints.py b/src/qwed_a2a/protocol/endpoints.py index 30400e6..81c6b53 100644 --- a/src/qwed_a2a/protocol/endpoints.py +++ b/src/qwed_a2a/protocol/endpoints.py @@ -34,7 +34,7 @@ def _load_trusted_agents(interceptor: A2AVerificationInterceptor) -> None: logger.info("Trusted agent registered") logger.info( "Zero-trust boundary initialized with %d trusted agent(s)", - len([a.strip() for a in trusted_env.split(",") if a.strip()]) + len([a.strip() for a in trusted_env.split(",") if a.strip()]), ) else: logger.warning( @@ -49,7 +49,7 @@ def get_interceptor() -> A2AVerificationInterceptor: if _interceptor is None: _interceptor = A2AVerificationInterceptor() _load_trusted_agents(_interceptor) - + return _interceptor @@ -57,10 +57,10 @@ def configure_interceptor(config: InterceptorConfig) -> None: """Reconfigure the interceptor at runtime (atomic swap).""" global _interceptor new_interceptor = A2AVerificationInterceptor(config=config) - + # Reload trusted agents to maintain zero-trust allowlist _load_trusted_agents(new_interceptor) - + with _interceptor_lock: _interceptor = new_interceptor diff --git a/src/qwed_a2a/protocol/schema.py b/src/qwed_a2a/protocol/schema.py index c4327aa..0970d6d 100644 --- a/src/qwed_a2a/protocol/schema.py +++ b/src/qwed_a2a/protocol/schema.py @@ -38,10 +38,16 @@ class AgentMessage(BaseModel): """ sender_agent_id: str = Field( - ..., min_length=1, max_length=256, description="Unique identifier of the sending agent" + ..., + min_length=1, + max_length=256, + description="Unique identifier of the sending agent", ) receiver_agent_id: str = Field( - ..., min_length=1, max_length=256, description="Unique identifier of the receiving agent" + ..., + min_length=1, + max_length=256, + description="Unique identifier of the receiving agent", ) payload_type: PayloadType = Field( default=PayloadType.GENERAL, description="Classification of the payload content" @@ -54,10 +60,12 @@ class AgentMessage(BaseModel): description="ISO 8601 timestamp of message creation", ) signature: Optional[str] = Field( - default=None, description="Optional JWT signature from the sender for tamper detection" + default=None, + description="Optional JWT signature from the sender for tamper detection", ) metadata: Optional[Dict[str, Any]] = Field( - default=None, description="Optional metadata (correlation IDs, trace context, etc.)" + default=None, + description="Optional metadata (correlation IDs, trace context, etc.)", ) @field_validator("sender_agent_id", "receiver_agent_id") @@ -84,7 +92,8 @@ class VerificationVerdict(BaseModel): ..., description="Unique trace ID for this verification event" ) attestation_jwt: Optional[str] = Field( - default=None, description="Signed JWT attestation proving the verification took place" + default=None, + description="Signed JWT attestation proving the verification took place", ) engine_used: Optional[str] = Field( default=None, description="Which verification engine handled the check" @@ -113,10 +122,14 @@ class InterceptorConfig(BaseModel): default=True, description="Route code payloads to AST security scanning" ) block_on_error: bool = Field( - default=True, description="Block forwarding if verification encounters an internal error" + default=True, + description="Block forwarding if verification encounters an internal error", ) max_payload_size_bytes: int = Field( - default=1_048_576, ge=1024, le=10_485_760, description="Maximum payload size (1MB default)" + default=1_048_576, + ge=1024, + le=10_485_760, + description="Maximum payload size (1MB default)", ) trusted_agents: Optional[List[str]] = Field( default=None, description="Allowlist of agent IDs that bypass verification" diff --git a/src/qwed_a2a/security/crypto.py b/src/qwed_a2a/security/crypto.py index 1c226b2..0b0d33a 100644 --- a/src/qwed_a2a/security/crypto.py +++ b/src/qwed_a2a/security/crypto.py @@ -6,7 +6,6 @@ """ import hashlib -import json import time from dataclasses import dataclass from typing import Any, Dict, Optional, Tuple diff --git a/src/qwed_a2a/security/trust_boundary.py b/src/qwed_a2a/security/trust_boundary.py index 02d7b5d..4d68628 100644 --- a/src/qwed_a2a/security/trust_boundary.py +++ b/src/qwed_a2a/security/trust_boundary.py @@ -7,8 +7,7 @@ """ import time -from collections import defaultdict -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Dict, Optional, Set, Tuple @@ -98,15 +97,14 @@ def _evict_cold_buckets(self, now: float) -> None: return # Only run eviction once per minute self._last_eviction = now cold_pairs = [ - pair for pair, bucket in self._rate_limits.items() + pair + for pair, bucket in self._rate_limits.items() if now - bucket.last_refill > self._eviction_ttl ] for pair in cold_pairs: del self._rate_limits[pair] - def evaluate( - self, sender_id: str, receiver_id: str - ) -> Tuple[bool, Optional[str]]: + def evaluate(self, sender_id: str, receiver_id: str) -> Tuple[bool, Optional[str]]: """ Evaluate whether a sender->receiver communication is allowed. @@ -127,8 +125,14 @@ def evaluate( # Default policy check BEFORE rate-limit allocation (prevents map spray) if not self.default_allow: - if sender_id not in self._trusted_agents and receiver_id not in self._trusted_agents: - return False, f"Neither sender '{sender_id}' nor receiver '{receiver_id}' is in the trust allowlist" + if ( + sender_id not in self._trusted_agents + and receiver_id not in self._trusted_agents + ): + return ( + False, + f"Neither sender '{sender_id}' nor receiver '{receiver_id}' is in the trust allowlist", + ) # Token-bucket rate limiting (only reached by allowed pairs) now = time.monotonic() diff --git a/src/qwed_a2a/utils/telemetry.py b/src/qwed_a2a/utils/telemetry.py index b59c7fd..18452db 100644 --- a/src/qwed_a2a/utils/telemetry.py +++ b/src/qwed_a2a/utils/telemetry.py @@ -108,7 +108,9 @@ def init_telemetry( elif sentry_dsn and not HAS_SENTRY: logger.warning("Sentry DSN provided but sentry-sdk not installed. Skipping.") - logger.info("QWED A2A telemetry initialized (level=%s)", logging.getLevelName(log_level)) + logger.info( + "QWED A2A telemetry initialized (level=%s)", logging.getLevelName(log_level) + ) def record_intercept( @@ -148,9 +150,7 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: try: result = await func(*args, **kwargs) elapsed_ms = (time.perf_counter() - start) * 1000 - logger.debug( - "Exiting %s (%.2fms)", func.__name__, elapsed_ms - ) + logger.debug("Exiting %s (%.2fms)", func.__name__, elapsed_ms) return result except Exception as exc: