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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions .github/workflows/integrity.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,19 @@ jobs:
python-version: "3.12"
cache: pip

- name: Install package + dev extras
- name: Install system dependencies for pycairo / weasyprint
run: |
sudo apt-get update
sudo apt-get install -y libcairo2-dev pkg-config python3-dev

- name: Install package + all extras
# Install every optional extra so the full suite can exercise Azure,
# GCP, and voice tests without ModuleNotFoundError. The smoke jobs
# also need this because pytest collection imports test files, and
# voice/azure/gcp tests import their respective SDKs at module scope.
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
pip install -e ".[dev,azure,gcp,voice]"

- name: Doc-vs-code drift integrity tests
# The single most load-bearing test in the repo. Catches the
Expand Down Expand Up @@ -86,10 +95,19 @@ jobs:
python-version: "3.12"
cache: pip

- name: Install package + dev extras
- name: Install system dependencies for pycairo / weasyprint
run: |
sudo apt-get update
sudo apt-get install -y libcairo2-dev pkg-config python3-dev

- name: Install package + all extras
# Install every optional extra so the full suite can exercise Azure,
# GCP, and voice tests without ModuleNotFoundError. The smoke jobs
# also need this because pytest collection imports test files, and
# voice/azure/gcp tests import their respective SDKs at module scope.
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
pip install -e ".[dev,azure,gcp,voice]"

- name: Run full pytest suite
run: pytest -q --ignore=tests/integration
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,9 @@ line-length = 100

[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP"]
# E501 (line-too-long) is delegated to `ruff format`. The formatter respects
# the 100-char limit where it can and leaves long string literals / f-strings
# intact rather than fragmenting them. Linting the same rule produces noise on
# strings the formatter chose not to break, so we ignore it here and let the
# formatter own line-length policy.
ignore = ["E501"]
6 changes: 2 additions & 4 deletions src/shasta/aws/ai_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -768,9 +768,7 @@ def check_sagemaker_endpoint_encryption(
ep_config_name = detail.get("EndpointConfigName")
if ep_config_name:
try:
config_detail = sm.describe_endpoint_config(
EndpointConfigName=ep_config_name
)
config_detail = sm.describe_endpoint_config(EndpointConfigName=ep_config_name)
kms_key = config_detail.get("KmsKeyId")
except ClientError:
pass
Expand Down Expand Up @@ -1698,7 +1696,7 @@ def check_cloudtrail_ai_events(client: AWSClient, account_id: str, region: str)
trails_with_ai_events: list[str] = []

for trail in trails:
trail_arn = trail.get("TrailARN", "")
trail.get("TrailARN", "")
trail_name = trail.get("Name", "unknown")
try:
# Check event selectors (basic)
Expand Down
8 changes: 7 additions & 1 deletion src/shasta/aws/ai_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,13 @@ def discover_aws_ai_services(client: AWSClient) -> dict[str, Any]:
regions = [default_region]

results: dict[str, Any] = {
"sagemaker": {"available": False, "endpoints": [], "training_jobs": [], "models": [], "total_resources": 0},
"sagemaker": {
"available": False,
"endpoints": [],
"training_jobs": [],
"models": [],
"total_resources": 0,
},
"bedrock": {"available": False, "models": [], "total_resources": 0},
"comprehend": {"available": False, "endpoints": [], "total_resources": 0},
"lambda_ai": {"available": False, "functions": [], "total_resources": 0},
Expand Down
41 changes: 22 additions & 19 deletions src/shasta/aws/ai_sbom.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@

import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from datetime import UTC, datetime
from enum import StrEnum
from pathlib import Path

# ---------------------------------------------------------------------------
Expand All @@ -30,9 +30,18 @@

EXCLUDED_PATH_SEGMENTS: frozenset[str] = frozenset(
{
"node_modules", ".git", "__pycache__", ".venv", "venv",
".tox", ".mypy_cache", ".pytest_cache", "dist", "build",
".egg-info", ".eggs",
"node_modules",
".git",
"__pycache__",
".venv",
"venv",
".tox",
".mypy_cache",
".pytest_cache",
"dist",
"build",
".egg-info",
".eggs",
}
)

Expand All @@ -41,8 +50,7 @@
)

ALL_SCANNABLE_EXTENSIONS: frozenset[str] = SOURCE_CODE_EXTENSIONS | frozenset(
{".yaml", ".yml", ".json", ".toml", ".cfg", ".ini",
".txt", ".md", ".env", ".sh", ".bash"}
{".yaml", ".yml", ".json", ".toml", ".cfg", ".ini", ".txt", ".md", ".env", ".sh", ".bash"}
)

# Vulnerable SDK versions for SBOM vulnerability checks.
Expand Down Expand Up @@ -82,9 +90,7 @@
]

# Models with date suffixes (gpt-4-0613, claude-3-5-sonnet-20240620) are pinned.
PINNED_MODEL_PATTERN: re.Pattern[str] = re.compile(
r"""model\s*=\s*["'][a-z0-9-]+-\d{4,8}["']"""
)
PINNED_MODEL_PATTERN: re.Pattern[str] = re.compile(r"""model\s*=\s*["'][a-z0-9-]+-\d{4,8}["']""")


def _iter_files(
Expand Down Expand Up @@ -136,19 +142,15 @@ def _parse_requirements_txt(content: str) -> dict[str, str]:
for sep in ("==", ">=", "<=", "~=", "!="):
if sep in line:
name, version = line.split(sep, 1)
deps[name.strip().lower()] = (
version.strip().split(",")[0].split(";")[0].strip()
)
deps[name.strip().lower()] = version.strip().split(",")[0].split(";")[0].strip()
break
return deps


def _parse_pyproject_toml(content: str) -> dict[str, str]:
"""Best-effort extraction of dependencies from pyproject.toml."""
deps: dict[str, str] = {}
for m in re.finditer(
r'"([a-zA-Z0-9_-]+)\s*([><=!~]+)\s*([0-9][0-9a-zA-Z.]*)"', content
):
for m in re.finditer(r'"([a-zA-Z0-9_-]+)\s*([><=!~]+)\s*([0-9][0-9a-zA-Z.]*)"', content):
deps[m.group(1).lower()] = m.group(3)
return deps

Expand Down Expand Up @@ -192,6 +194,7 @@ def _version_matches_constraint(version_str: str, constraint: str) -> bool:
except (ValueError, TypeError):
return False


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -248,7 +251,7 @@ def _version_matches_constraint(version_str: str, constraint: str) -> bool:
# ---------------------------------------------------------------------------


class AIComponentType(str, Enum):
class AIComponentType(StrEnum):
"""Type of AI component in the inventory."""

SDK = "sdk"
Expand Down Expand Up @@ -481,7 +484,7 @@ def scan_azure_for_ai_components(client: object) -> list[AIComponent]:
# Azure OpenAI deployments
for dep in inventory.get("azure_openai", {}).get("deployments", []):
name = dep.get("name", "")
model = dep.get("model", "")
dep.get("model", "")
if name:
components.append(
AIComponent(
Expand Down Expand Up @@ -580,7 +583,7 @@ def generate_ai_sbom(
Returns a dict matching the CycloneDX 1.5 specification, with
Whitney-specific properties on each component.
"""
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
timestamp = now.strftime("%Y-%m-%dT%H:%M:%SZ")

# Map component types to CycloneDX types
Expand Down
88 changes: 47 additions & 41 deletions src/shasta/aws/backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@

from __future__ import annotations

from typing import Any

from botocore.exceptions import ClientError

from shasta.aws.client import AWSClient
Expand Down Expand Up @@ -63,15 +61,17 @@ def check_backup_cross_region_copy(
bk = client.client("backup")
plans_list = bk.list_backup_plans().get("BackupPlansList", [])
except ClientError as e:
return [Finding.not_assessed(
check_id="aws-backup-cross-region-copy",
title="Unable to check Backup cross-region copy",
description=f"API call failed: {e}",
domain=CheckDomain.LOGGING,
resource_type="AWS::Backup::BackupPlan",
account_id=account_id,
region=region,
)]
return [
Finding.not_assessed(
check_id="aws-backup-cross-region-copy",
title="Unable to check Backup cross-region copy",
description=f"API call failed: {e}",
domain=CheckDomain.LOGGING,
resource_type="AWS::Backup::BackupPlan",
account_id=account_id,
region=region,
)
]
if not plans_list:
return []

Expand Down Expand Up @@ -165,15 +165,17 @@ def check_backup_vault_access_policy(
try:
bk = client.client("backup")
except ClientError as e:
return [Finding.not_assessed(
check_id="aws-backup-vault-access-policy",
title="Unable to check Backup vault access policies",
description=f"API call failed: {e}",
domain=CheckDomain.LOGGING,
resource_type="AWS::Backup::BackupVault",
account_id=account_id,
region=region,
)]
return [
Finding.not_assessed(
check_id="aws-backup-vault-access-policy",
title="Unable to check Backup vault access policies",
description=f"API call failed: {e}",
domain=CheckDomain.LOGGING,
resource_type="AWS::Backup::BackupVault",
account_id=account_id,
region=region,
)
]
for v in _list_vaults(client):
name = v.get("BackupVaultName", "unknown")
arn = v.get("BackupVaultArn", "")
Expand Down Expand Up @@ -226,9 +228,9 @@ def check_backup_vault_access_policy(
account_id=account_id,
remediation=(
f"aws backup put-backup-vault-access-policy --backup-vault-name {name} "
'--policy file://deny-policy.json. The policy should Deny '
'backup:DeleteBackupVault, backup:DeleteRecoveryPoint, and '
'backup:StartCopyJob with a NotPrincipal of your break-glass role ARN.'
"--policy file://deny-policy.json. The policy should Deny "
"backup:DeleteBackupVault, backup:DeleteRecoveryPoint, and "
"backup:StartCopyJob with a NotPrincipal of your break-glass role ARN."
),
soc2_controls=["A1.1", "A1.2"],
cis_aws_controls=["2.x"],
Expand Down Expand Up @@ -280,15 +282,17 @@ def check_backup_vault_lock(client: AWSClient, account_id: str, region: str) ->
try:
bk = client.client("backup")
except ClientError as e:
return [Finding.not_assessed(
check_id="aws-backup-vault-lock",
title="Unable to check Backup vault lock status",
description=f"API call failed: {e}",
domain=CheckDomain.LOGGING,
resource_type="AWS::Backup::BackupVault",
account_id=account_id,
region=region,
)]
return [
Finding.not_assessed(
check_id="aws-backup-vault-lock",
title="Unable to check Backup vault lock status",
description=f"API call failed: {e}",
domain=CheckDomain.LOGGING,
resource_type="AWS::Backup::BackupVault",
account_id=account_id,
region=region,
)
]
for v in _list_vaults(client):
name = v.get("BackupVaultName", "unknown")
arn = v.get("BackupVaultArn", "")
Expand Down Expand Up @@ -430,15 +434,17 @@ def check_backup_plans(client: AWSClient, account_id: str, region: str) -> list[
bk = client.client("backup")
plans = bk.list_backup_plans().get("BackupPlansList", [])
except ClientError as e:
return [Finding.not_assessed(
check_id="aws-backup-plans",
title="Unable to check Backup plans",
description=f"API call failed: {e}",
domain=CheckDomain.LOGGING,
resource_type="AWS::Backup::BackupPlan",
account_id=account_id,
region=region,
)]
return [
Finding.not_assessed(
check_id="aws-backup-plans",
title="Unable to check Backup plans",
description=f"API call failed: {e}",
domain=CheckDomain.LOGGING,
resource_type="AWS::Backup::BackupPlan",
account_id=account_id,
region=region,
)
]

if plans:
return [
Expand Down
3 changes: 1 addition & 2 deletions src/shasta/aws/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

import json
from dataclasses import dataclass, field
from typing import Any

Expand Down Expand Up @@ -204,7 +203,7 @@ def get_enabled_regions(self) -> list[str]:
except ClientError:
return [self._region]

def for_region(self, region: str) -> "AWSClient":
def for_region(self, region: str) -> AWSClient:
"""Create a new AWSClient for a different region, reusing the same profile."""
return AWSClient(profile_name=self._profile_name, region=region)

Expand Down
Loading
Loading